Implement MVP: Anonymous feedback submission (User Story 1)
Complete implementation of Phase 1-3 (64 tasks): - Phase 1: Project setup with Flask, pytest, configuration - Phase 2: Core infrastructure (auth, models, services, testing) - Phase 3: Anonymous feedback submission with file uploads Features: - Anonymous feedback submission (text and/or up to 3 file attachments) - Multi-language support (any language accepted) - File validation (type, size) and virus scanning (ClamAV) - Product management with active/archived status - File-based storage with YAML metadata - User authentication system (Flask-Login) - CSRF protection and rate limiting - Test coverage: 10 passing tests (contract + integration) Security: - No IP address logging (FR-055 compliance) - File type whitelist and size limits (10MB max) - Virus scanning with graceful degradation - Filename sanitization and secure storage Test Results: - 8 contract tests passed - 2 integration tests passed - End-to-end workflow verified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Routes package"""
|
||||
# Blueprints are imported here for registration in the app factory
|
||||
from app.routes import submission, dashboard, admin, auth
|
||||
|
||||
__all__ = ['submission', 'dashboard', 'admin', 'auth']
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Admin routes - administrator management"""
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
# Routes will be implemented in Phase 6 (User Story 4)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Authentication routes"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""User login page
|
||||
|
||||
GET: Display login form
|
||||
POST: Process login credentials
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
flash('Please provide both username and password', 'error')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
user = User.get_by_username(username)
|
||||
|
||||
if user and user.is_active and user.check_password(password):
|
||||
login_user(user)
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
|
||||
# Redirect based on role
|
||||
if user.role == 'administrator':
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
elif user.role == 'product_owner':
|
||||
return redirect(url_for('dashboard.list'))
|
||||
else:
|
||||
flash('Invalid username or password', 'error')
|
||||
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout"""
|
||||
logout_user()
|
||||
flash('You have been logged out', 'info')
|
||||
return redirect(url_for('submission.form'))
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Dashboard routes - product owner feedback management"""
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
|
||||
|
||||
bp = Blueprint('dashboard', __name__, url_prefix='/dashboard')
|
||||
|
||||
|
||||
# Routes will be implemented in Phase 5 (User Story 3)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Submission routes - anonymous feedback submission"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort
|
||||
from app.models.product import Product
|
||||
from app.services.feedback_storage import FeedbackStorageService
|
||||
from app.utils.file_validator import validate_file, scan_file_for_viruses
|
||||
|
||||
|
||||
bp = Blueprint('submission', __name__, url_prefix='/submit')
|
||||
|
||||
|
||||
@bp.route('/<product_slug>', methods=['GET'])
|
||||
def form(product_slug):
|
||||
"""Display feedback submission form
|
||||
|
||||
Args:
|
||||
product_slug: Product submission URL slug
|
||||
|
||||
Returns:
|
||||
Rendered submission form template or 404
|
||||
"""
|
||||
# Load product by slug
|
||||
product = Product.get_by_slug(product_slug)
|
||||
|
||||
if not product:
|
||||
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")
|
||||
|
||||
return render_template('submission/form.html', product=product)
|
||||
|
||||
|
||||
@bp.route('/<product_slug>', methods=['POST'])
|
||||
def submit(product_slug):
|
||||
"""Process feedback submission
|
||||
|
||||
Args:
|
||||
product_slug: Product submission URL slug
|
||||
|
||||
Returns:
|
||||
Redirect to success page or error page
|
||||
"""
|
||||
# Load product by slug
|
||||
product = Product.get_by_slug(product_slug)
|
||||
|
||||
if not product:
|
||||
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")
|
||||
|
||||
# Get form data
|
||||
feedback_text = request.form.get('feedback_text', '').strip()
|
||||
|
||||
# Get uploaded files
|
||||
uploaded_files = request.files.getlist('files')
|
||||
# Filter out empty file inputs
|
||||
files = [f for f in uploaded_files if f and f.filename != '']
|
||||
|
||||
# Validation: Must provide either text or files
|
||||
if not feedback_text and not files:
|
||||
abort(400, description="Please provide either feedback text or attachments")
|
||||
|
||||
# Validation: Maximum 3 files
|
||||
if len(files) > 3:
|
||||
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:
|
||||
abort(400, description=error_message)
|
||||
|
||||
# Scan for viruses
|
||||
is_clean, virus_message = scan_file_for_viruses(file)
|
||||
if not is_clean:
|
||||
abort(400, description=f"File rejected: {virus_message}")
|
||||
|
||||
# Save feedback
|
||||
try:
|
||||
feedback = FeedbackStorageService.save_complete_feedback(
|
||||
product_id=product.product_id,
|
||||
content_text=feedback_text if feedback_text else None,
|
||||
files=files if files else None
|
||||
)
|
||||
|
||||
return render_template('submission/success.html',
|
||||
product=product,
|
||||
feedback_id=feedback.feedback_id)
|
||||
|
||||
except Exception as e:
|
||||
# Log error
|
||||
from flask import current_app
|
||||
current_app.logger.error(f"Error saving feedback: {e}")
|
||||
|
||||
return render_template('submission/error.html',
|
||||
product=product,
|
||||
error_message="An error occurred while saving your feedback. Please try again."), 500
|
||||
Reference in New Issue
Block a user