This commit implements all remaining polish tasks (T193-T210) to make the application production-ready. ## Logging & Monitoring (T193, T194, T208, T209) - Add structured JSON logging for production environments - Add human-readable logging for development - Implement comprehensive error logging across all routes: * submission.py: product access, validation, success/failure * auth.py: login attempts, successes, failures, logouts * dashboard.py: access and errors - Add /health endpoint for monitoring (checks data dir, API key) - Add environment variable validation on startup ## Security Hardening (T196-T199, T207) - Add HSTS headers in production (1 year, includeSubDomains) - Add security headers: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection - Verify CSRF protection on all POST routes (Flask-WTF) - Verify session cookie security flags (HttpOnly, Secure, SameSite) - Verify XSS prevention (Jinja2 auto-escaping) - Verify no hardcoded secrets (only in test files) ## Documentation (T195, T203, T210) - Add comprehensive README.md with: * Features, quick start, project structure * Usage guides (end users, product owners, admins) * Configuration, testing, deployment instructions - Add detailed docs/deployment.md with: * Production deployment steps * ClamAV, Nginx, SSL/TLS setup * Security hardening, monitoring, backup strategies - Add requirements-dev.txt for development dependencies ## Performance Testing (T200, T201) - Add test_performance.py with 4 comprehensive tests: * 100 concurrent submissions (SC-012) * Dashboard load <3s for 1000 items (SC-008) * Large file upload handling * Rate limiting verification - Add performance marker to pytest.ini ## Testing - All 49 tests passing, 1 skipped - Fixed error handling to preserve HTTP status codes Phase 7 complete. Application is production-ready with comprehensive logging, security, monitoring, and documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
345 lines
9.9 KiB
Python
345 lines
9.9 KiB
Python
"""Dashboard routes - product owner feedback management"""
|
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, abort, current_app
|
|
from flask_login import login_required, current_user
|
|
from app.services.feedback_storage import FeedbackStorageService
|
|
from app.services.ai_analyzer import ClaudeAnalyzer
|
|
from app.models.product import Product
|
|
import os
|
|
import mimetypes
|
|
|
|
|
|
bp = Blueprint('dashboard', __name__)
|
|
|
|
|
|
def get_user_product_ids():
|
|
"""Get list of product IDs accessible to current user
|
|
|
|
Returns:
|
|
list: Product IDs or None for administrators (access to all)
|
|
"""
|
|
if not current_user.is_authenticated:
|
|
return []
|
|
|
|
# Administrators have access to all products
|
|
if current_user.role == 'administrator':
|
|
return None # None means all products
|
|
|
|
# Product owners see only assigned products
|
|
return current_user.product_ids
|
|
|
|
|
|
def check_product_access(product_id):
|
|
"""Check if current user has access to product
|
|
|
|
Args:
|
|
product_id: Product ID to check
|
|
|
|
Returns:
|
|
bool: True if user has access, False otherwise
|
|
"""
|
|
if not current_user.is_authenticated:
|
|
return False
|
|
|
|
# Administrators have access to all products
|
|
if current_user.role == 'administrator':
|
|
return True
|
|
|
|
# Product owners see only assigned products
|
|
return product_id in current_user.product_ids
|
|
|
|
|
|
@bp.route('/dashboard')
|
|
@login_required
|
|
def list():
|
|
"""Dashboard - list feedback with filters and search
|
|
|
|
Query parameters:
|
|
page: Page number (default 1)
|
|
category: Filter by category
|
|
status: Filter by status
|
|
language: Filter by language
|
|
search: Search query
|
|
"""
|
|
try:
|
|
# Get query parameters
|
|
page = request.args.get('page', 1, type=int)
|
|
category = request.args.get('category')
|
|
status = request.args.get('status')
|
|
language = request.args.get('language')
|
|
search_query = request.args.get('search')
|
|
|
|
# Build filters
|
|
filters = {}
|
|
if category:
|
|
filters['category'] = category
|
|
if status:
|
|
filters['status'] = status
|
|
if language:
|
|
filters['language'] = language
|
|
|
|
# Get product IDs for current user
|
|
product_ids = get_user_product_ids()
|
|
|
|
current_app.logger.info(f'Dashboard accessed by {current_user.username} (page={page}, filters={filters})')
|
|
|
|
# Load feedback list
|
|
result = FeedbackStorageService.load_feedback_list(
|
|
product_ids=product_ids,
|
|
page=page,
|
|
per_page=50,
|
|
filters=filters if filters else None,
|
|
search_query=search_query
|
|
)
|
|
|
|
# Load product names for display
|
|
all_products = Product.get_all()
|
|
product_names = {p.product_id: p.name for p in all_products}
|
|
|
|
return render_template(
|
|
'dashboard/list.html',
|
|
feedback_list=result['items'],
|
|
page=result['page'],
|
|
pages=result['pages'],
|
|
total=result['total'],
|
|
product_names=product_names,
|
|
filters={
|
|
'category': category,
|
|
'status': status,
|
|
'language': language,
|
|
'search': search_query
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(f'Error loading dashboard for {current_user.username}: {e}', exc_info=True)
|
|
abort(500)
|
|
|
|
|
|
@bp.route('/feedback/<feedback_id>')
|
|
@login_required
|
|
def detail(feedback_id):
|
|
"""Feedback detail view
|
|
|
|
Args:
|
|
feedback_id: Feedback ID to view
|
|
|
|
Returns:
|
|
Rendered template or 403/404 error
|
|
"""
|
|
# First check if feedback exists globally (to distinguish 403 from 404)
|
|
all_products = Product.get_all()
|
|
feedback_data = None
|
|
actual_product_id = None
|
|
|
|
for product in all_products:
|
|
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
|
if feedback_data:
|
|
actual_product_id = product.product_id
|
|
break
|
|
|
|
# If not found globally, return 404
|
|
if not feedback_data:
|
|
abort(404)
|
|
|
|
# Check if user has access to this product
|
|
if not check_product_access(actual_product_id):
|
|
abort(403)
|
|
|
|
# Load product info
|
|
product = Product.get_by_id(actual_product_id)
|
|
|
|
# Check if analysis exists
|
|
has_analysis = FeedbackStorageService.has_analysis(actual_product_id, feedback_id)
|
|
|
|
# Check if feedback can be analyzed (has text content)
|
|
can_analyze = bool(feedback_data.get('content'))
|
|
|
|
return render_template(
|
|
'dashboard/detail.html',
|
|
feedback=feedback_data,
|
|
product=product,
|
|
has_analysis=has_analysis,
|
|
can_analyze=can_analyze
|
|
)
|
|
|
|
|
|
@bp.route('/feedback/<feedback_id>/status', methods=['POST'])
|
|
@login_required
|
|
def update_status(feedback_id):
|
|
"""Update feedback status
|
|
|
|
Args:
|
|
feedback_id: Feedback ID to update
|
|
|
|
Returns:
|
|
Redirect to detail page or error
|
|
"""
|
|
new_status = request.form.get('status')
|
|
|
|
if not new_status:
|
|
flash('Status is required', 'error')
|
|
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
|
|
|
# Find feedback globally first
|
|
all_products = Product.get_all()
|
|
actual_product_id = None
|
|
|
|
for product in all_products:
|
|
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
|
if feedback_data:
|
|
actual_product_id = product.product_id
|
|
break
|
|
|
|
# If not found globally, return 404
|
|
if not actual_product_id:
|
|
abort(404)
|
|
|
|
# Check if user has access to this product
|
|
if not check_product_access(actual_product_id):
|
|
abort(403)
|
|
|
|
# Update status
|
|
success = FeedbackStorageService.update_feedback_status_by_id(
|
|
actual_product_id, feedback_id, new_status
|
|
)
|
|
|
|
if success:
|
|
flash(f'Status updated to {new_status}', 'success')
|
|
else:
|
|
flash('Failed to update status', 'error')
|
|
|
|
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
|
|
|
|
|
@bp.route('/feedback/<feedback_id>/analyze', methods=['POST'])
|
|
@login_required
|
|
def trigger_analysis(feedback_id):
|
|
"""Manually trigger AI analysis for feedback
|
|
|
|
Args:
|
|
feedback_id: Feedback ID to analyze
|
|
|
|
Returns:
|
|
Redirect to detail page with flash message
|
|
"""
|
|
# Find feedback globally first
|
|
all_products = Product.get_all()
|
|
actual_product_id = None
|
|
feedback_data = None
|
|
|
|
for product in all_products:
|
|
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
|
if feedback_data:
|
|
actual_product_id = product.product_id
|
|
break
|
|
|
|
# If not found globally, return 404
|
|
if not actual_product_id or not feedback_data:
|
|
abort(404)
|
|
|
|
# Check if user has access to this product
|
|
if not check_product_access(actual_product_id):
|
|
abort(403)
|
|
|
|
# Check if feedback has text content
|
|
if not feedback_data.get('content'):
|
|
flash('Cannot analyze feedback without text content', 'error')
|
|
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
|
|
|
# Get product info for owner language
|
|
product = Product.get_by_id(actual_product_id)
|
|
|
|
try:
|
|
# Update status to "analyzing"
|
|
FeedbackStorageService.update_feedback_status_by_id(
|
|
actual_product_id, feedback_id, 'analyzing'
|
|
)
|
|
|
|
# Get API key from app configuration
|
|
api_key = current_app.config.get('ANTHROPIC_API_KEY')
|
|
|
|
if not api_key:
|
|
raise Exception("ANTHROPIC_API_KEY not configured in app settings")
|
|
|
|
# Initialize analyzer
|
|
analyzer = ClaudeAnalyzer(api_key=api_key)
|
|
|
|
# Analyze feedback
|
|
result = analyzer.analyze_feedback(
|
|
feedback_text=feedback_data['content'],
|
|
target_language=product.owner_language,
|
|
product_id=actual_product_id
|
|
)
|
|
|
|
# Save analysis results
|
|
FeedbackStorageService.save_analysis(actual_product_id, feedback_id, result)
|
|
|
|
# Update status to "analyzed"
|
|
FeedbackStorageService.update_feedback_status_by_id(
|
|
actual_product_id, feedback_id, 'analyzed'
|
|
)
|
|
|
|
flash('Analysis completed successfully', 'success')
|
|
|
|
except Exception as e:
|
|
# Update status to "analysis_failed" on error
|
|
FeedbackStorageService.update_feedback_status_by_id(
|
|
actual_product_id, feedback_id, 'analysis_failed'
|
|
)
|
|
current_app.logger.error(f"Manual analysis failed for feedback {feedback_id}: {e}")
|
|
flash(f'Analysis failed: {str(e)}', 'error')
|
|
|
|
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
|
|
|
|
|
@bp.route('/feedback/<feedback_id>/attachment/<filename>')
|
|
@login_required
|
|
def download_attachment(feedback_id, filename):
|
|
"""Download attachment file
|
|
|
|
Args:
|
|
feedback_id: Feedback ID
|
|
filename: Attachment filename
|
|
|
|
Returns:
|
|
File download or error
|
|
"""
|
|
# Find feedback globally first
|
|
all_products = Product.get_all()
|
|
actual_product_id = None
|
|
|
|
for product in all_products:
|
|
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
|
if feedback_data:
|
|
actual_product_id = product.product_id
|
|
break
|
|
|
|
# If not found globally, return 404
|
|
if not actual_product_id:
|
|
abort(404)
|
|
|
|
# Check if user has access to this product
|
|
if not check_product_access(actual_product_id):
|
|
abort(403)
|
|
|
|
# Get attachment path
|
|
attachment_path = FeedbackStorageService.get_attachment_path(
|
|
actual_product_id, feedback_id, filename
|
|
)
|
|
|
|
if not attachment_path:
|
|
abort(404)
|
|
|
|
# Detect MIME type
|
|
mime_type, _ = mimetypes.guess_type(filename)
|
|
if not mime_type:
|
|
mime_type = 'application/octet-stream'
|
|
|
|
# Send file
|
|
return send_file(
|
|
attachment_path,
|
|
mimetype=mime_type,
|
|
as_attachment=True,
|
|
download_name=filename
|
|
)
|