Files
Reklamator/app/routes/dashboard.py
T
gurixandClaude 5675784502 Complete Phase 7: Polish & Cross-Cutting Concerns
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>
2025-10-17 13:32:09 +02:00

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
)