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>
This commit is contained in:
+7
-2
@@ -1,6 +1,6 @@
|
||||
"""Authentication routes"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ def login():
|
||||
password = request.form.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
current_app.logger.warning('Login attempt with missing credentials')
|
||||
flash('Please provide both username and password', 'error')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
@@ -26,11 +27,13 @@ def login():
|
||||
|
||||
if user and user.is_active and user.check_password(password):
|
||||
login_user(user)
|
||||
current_app.logger.info(f'User logged in successfully: {username} (role: {user.role})')
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
|
||||
# Redirect to dashboard for product owners and administrators
|
||||
return redirect(url_for('dashboard.list'))
|
||||
else:
|
||||
current_app.logger.warning(f'Failed login attempt for username: {username}')
|
||||
flash('Invalid username or password', 'error')
|
||||
|
||||
return render_template('auth/login.html')
|
||||
@@ -40,6 +43,8 @@ def login():
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout"""
|
||||
username = current_user.username
|
||||
logout_user()
|
||||
current_app.logger.info(f'User logged out: {username}')
|
||||
flash('You have been logged out', 'info')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
+48
-41
@@ -60,52 +60,59 @@ def list():
|
||||
language: Filter by language
|
||||
search: Search query
|
||||
"""
|
||||
# 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')
|
||||
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
|
||||
# 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()
|
||||
# Get product IDs for current user
|
||||
product_ids = get_user_product_ids()
|
||||
|
||||
# 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
|
||||
)
|
||||
current_app.logger.info(f'Dashboard accessed by {current_user.username} (page={page}, filters={filters})')
|
||||
|
||||
# Load product names for display
|
||||
all_products = Product.get_all()
|
||||
product_names = {p.product_id: p.name for p in all_products}
|
||||
# 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
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
# 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>')
|
||||
|
||||
+28
-12
@@ -21,17 +21,25 @@ def form(product_slug):
|
||||
Returns:
|
||||
Rendered submission form template or 404
|
||||
"""
|
||||
# Load product by slug
|
||||
product = Product.get_by_slug(product_slug)
|
||||
try:
|
||||
# Load product by slug
|
||||
product = Product.get_by_slug(product_slug)
|
||||
|
||||
if not product:
|
||||
abort(404, description="Product not found")
|
||||
if not product:
|
||||
current_app.logger.warning(f'Product not found: {product_slug}')
|
||||
abort(404, description="Product not found")
|
||||
|
||||
# Check if product is archived
|
||||
if product.is_archived():
|
||||
abort(404, description="This product is no longer accepting feedback")
|
||||
# Check if product is archived
|
||||
if product.is_archived():
|
||||
current_app.logger.info(f'Attempt to access archived product: {product_slug}')
|
||||
abort(404, description="This product is no longer accepting feedback")
|
||||
|
||||
return render_template('submission/form.html', product=product)
|
||||
current_app.logger.info(f'Displaying submission form for product: {product_slug}')
|
||||
return render_template('submission/form.html', product=product)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error displaying submission form for {product_slug}: {e}', exc_info=True)
|
||||
abort(500)
|
||||
|
||||
|
||||
@bp.route('/<product_slug>', methods=['POST'])
|
||||
@@ -48,10 +56,12 @@ def submit(product_slug):
|
||||
product = Product.get_by_slug(product_slug)
|
||||
|
||||
if not product:
|
||||
current_app.logger.warning(f'Submission attempt for non-existent product: {product_slug}')
|
||||
abort(404, description="Product not found")
|
||||
|
||||
# Check if product is archived
|
||||
if product.is_archived():
|
||||
current_app.logger.warning(f'Submission attempt for archived product: {product_slug}')
|
||||
abort(404, description="This product is no longer accepting feedback")
|
||||
|
||||
# Get form data
|
||||
@@ -64,24 +74,28 @@ def submit(product_slug):
|
||||
|
||||
# Validation: Must provide either text or files
|
||||
if not feedback_text and not files:
|
||||
current_app.logger.info(f'Submission rejected: no content provided for {product_slug}')
|
||||
abort(400, description="Please provide either feedback text or attachments")
|
||||
|
||||
# Validation: Maximum 3 files
|
||||
if len(files) > 3:
|
||||
current_app.logger.warning(f'Submission rejected: too many files ({len(files)}) for {product_slug}')
|
||||
abort(400, description="Maximum 3 attachments allowed")
|
||||
|
||||
# Validate each file
|
||||
for file in files:
|
||||
is_valid, error_message = validate_file(file)
|
||||
if not is_valid:
|
||||
current_app.logger.warning(f'File validation failed for {product_slug}: {error_message}')
|
||||
abort(400, description=error_message)
|
||||
|
||||
# Scan for viruses
|
||||
is_clean, virus_message = scan_file_for_viruses(file)
|
||||
if not is_clean:
|
||||
current_app.logger.warning(f'Virus scan failed for {product_slug}: {virus_message}')
|
||||
abort(400, description=f"File rejected: {virus_message}")
|
||||
|
||||
# Save feedback
|
||||
# Save feedback (wrap only the save operation in try/except)
|
||||
try:
|
||||
feedback = FeedbackStorageService.save_complete_feedback(
|
||||
product_id=product.product_id,
|
||||
@@ -89,6 +103,8 @@ def submit(product_slug):
|
||||
files=files if files else None
|
||||
)
|
||||
|
||||
current_app.logger.info(f'Feedback submitted successfully for {product_slug}: {feedback.feedback_id}')
|
||||
|
||||
# Trigger background analysis (T084, T085)
|
||||
if feedback_text: # Only analyze if there's text content
|
||||
_trigger_background_analysis(feedback, feedback_text, product)
|
||||
@@ -99,7 +115,7 @@ def submit(product_slug):
|
||||
|
||||
except Exception as e:
|
||||
# Log error
|
||||
current_app.logger.error(f"Error saving feedback: {e}")
|
||||
current_app.logger.error(f"Error saving feedback for {product_slug}: {e}", exc_info=True)
|
||||
|
||||
return render_template('submission/error.html',
|
||||
product=product,
|
||||
@@ -175,5 +191,5 @@ def _analyze_feedback_background(app, product_id, feedback_id, feedback_text, ta
|
||||
FeedbackStorageService.update_feedback_status_by_id(
|
||||
product_id, feedback_id, 'analysis_failed'
|
||||
)
|
||||
# Log error
|
||||
print(f"Analysis failed for feedback {feedback_id}: {e}")
|
||||
# Log error (T193)
|
||||
app.logger.error(f"Background analysis failed for feedback {feedback_id}: {e}", exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user