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:
2025-10-16 15:14:51 +02:00
co-authored by Claude
parent 07e51d7468
commit b301def134
37 changed files with 2416 additions and 39 deletions
+73
View File
@@ -0,0 +1,73 @@
"""Flask application factory"""
import os
from flask import Flask
from flask_login import LoginManager
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_wtf.csrf import CSRFProtect
def create_app(config_name='development'):
"""Create and configure the Flask application
Args:
config_name: Configuration environment (development, production, testing)
Returns:
Flask application instance
"""
app = Flask(__name__)
# Load configuration
if config_name == 'production':
from config.production import ProductionConfig
app.config.from_object(ProductionConfig)
elif config_name == 'testing':
from config.testing import TestingConfig
app.config.from_object(TestingConfig)
else:
from config.development import DevelopmentConfig
app.config.from_object(DevelopmentConfig)
# Ensure data directory exists
os.makedirs(app.config['DATA_DIR'], exist_ok=True)
# Initialize Flask-WTF CSRF Protection
csrf = CSRFProtect()
csrf.init_app(app)
# Initialize Flask-Login
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
@login_manager.user_loader
def load_user(user_id):
"""Load user by ID for Flask-Login"""
from app.models.user import User
return User.get_by_id(user_id)
# Initialize Flask-Limiter
limiter = Limiter(
app=app,
key_func=get_remote_address,
storage_uri=app.config['RATELIMIT_STORAGE_URL'],
default_limits=[f"{app.config['RATELIMIT_PER_HOUR']}/hour"] if app.config.get('RATELIMIT_ENABLED') else []
)
# Register blueprints
from app.routes import submission, dashboard, admin, auth
app.register_blueprint(submission.bp)
app.register_blueprint(dashboard.bp)
app.register_blueprint(admin.bp)
app.register_blueprint(auth.bp)
# Set index route
@app.route('/')
def index():
"""Welcome page"""
from flask import render_template
return render_template('index.html')
return app
+7
View File
@@ -0,0 +1,7 @@
"""Models package"""
# Models are imported here for convenience
from app.models.user import User
from app.models.feedback import Feedback
from app.models.product import Product
__all__ = ['User', 'Feedback', 'Product']
+256
View File
@@ -0,0 +1,256 @@
"""Feedback model"""
import os
import uuid
from datetime import datetime
import yaml
from flask import current_app
class Feedback:
"""Feedback submission model
Attributes:
feedback_id: Unique feedback identifier (UUID)
product_id: Associated product ID
submitted_at: Submission timestamp (ISO 8601)
status: Feedback status ('new', 'analyzing', 'analyzed', 'analysis_failed', 'archived')
content_preview: First 200 chars of feedback text
has_attachments: Whether feedback has file attachments
attachment_count: Number of attached files
original_language: Detected language of feedback (set during analysis)
category: Feedback category (set during analysis)
"""
VALID_STATUSES = ['new', 'analyzing', 'analyzed', 'analysis_failed', 'archived']
def __init__(self, feedback_id, product_id, submitted_at=None, status='new',
content_preview='', has_attachments=False, attachment_count=0,
original_language=None, category=None):
self.feedback_id = feedback_id
self.product_id = product_id
self.submitted_at = submitted_at or datetime.utcnow().isoformat()
self.status = status
self.content_preview = content_preview
self.has_attachments = has_attachments
self.attachment_count = attachment_count
self.original_language = original_language
self.category = category
def to_dict(self):
"""Convert feedback to dictionary
Returns:
dict: Feedback metadata
"""
data = {
'feedback_id': self.feedback_id,
'product_id': self.product_id,
'submitted_at': self.submitted_at,
'status': self.status,
'content_preview': self.content_preview,
'has_attachments': self.has_attachments,
'attachment_count': self.attachment_count
}
if self.original_language:
data['original_language'] = self.original_language
if self.category:
data['category'] = self.category
return data
@classmethod
def from_dict(cls, data):
"""Create feedback from dictionary
Args:
data: Dictionary with feedback data
Returns:
Feedback: Feedback instance
"""
return cls(
feedback_id=data['feedback_id'],
product_id=data['product_id'],
submitted_at=data.get('submitted_at'),
status=data.get('status', 'new'),
content_preview=data.get('content_preview', ''),
has_attachments=data.get('has_attachments', False),
attachment_count=data.get('attachment_count', 0),
original_language=data.get('original_language'),
category=data.get('category')
)
@staticmethod
def generate_id():
"""Generate unique feedback ID
Returns:
str: UUID-based feedback ID
"""
return str(uuid.uuid4())
@staticmethod
def _get_feedback_dir(product_id, feedback_id):
"""Get feedback directory path
Args:
product_id: Product ID
feedback_id: Feedback ID
Returns:
str: Path to feedback directory
"""
return os.path.join(
current_app.config['DATA_DIR'],
'products',
product_id,
'feedback',
feedback_id
)
@staticmethod
def _get_metadata_file(product_id, feedback_id):
"""Get metadata file path
Args:
product_id: Product ID
feedback_id: Feedback ID
Returns:
str: Path to metadata.yaml
"""
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
return os.path.join(feedback_dir, 'metadata.yaml')
@staticmethod
def _get_content_file(product_id, feedback_id):
"""Get content file path
Args:
product_id: Product ID
feedback_id: Feedback ID
Returns:
str: Path to content.txt
"""
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
return os.path.join(feedback_dir, 'content.txt')
@staticmethod
def _get_attachments_dir(product_id, feedback_id):
"""Get attachments directory path
Args:
product_id: Product ID
feedback_id: Feedback ID
Returns:
str: Path to attachments directory
"""
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
return os.path.join(feedback_dir, 'attachments')
@classmethod
def get_by_id(cls, product_id, feedback_id):
"""Load feedback by ID
Args:
product_id: Product ID
feedback_id: Feedback ID
Returns:
Feedback or None: Feedback instance if found, None otherwise
"""
metadata_file = cls._get_metadata_file(product_id, feedback_id)
if not os.path.exists(metadata_file):
return None
with open(metadata_file, 'r') as f:
data = yaml.safe_load(f)
return cls.from_dict(data)
@classmethod
def get_all_for_product(cls, product_id):
"""Get all feedback for a product
Args:
product_id: Product ID
Returns:
list: List of Feedback instances, sorted by submitted_at (newest first)
"""
feedback_list = []
feedback_base_dir = os.path.join(
current_app.config['DATA_DIR'],
'products',
product_id,
'feedback'
)
if not os.path.exists(feedback_base_dir):
return feedback_list
for feedback_id in os.listdir(feedback_base_dir):
feedback_dir = os.path.join(feedback_base_dir, feedback_id)
if not os.path.isdir(feedback_dir):
continue
feedback = cls.get_by_id(product_id, feedback_id)
if feedback:
feedback_list.append(feedback)
# Sort by submitted_at (newest first)
feedback_list.sort(key=lambda f: f.submitted_at, reverse=True)
return feedback_list
def save_metadata(self):
"""Save feedback metadata to filesystem"""
feedback_dir = self._get_feedback_dir(self.product_id, self.feedback_id)
os.makedirs(feedback_dir, exist_ok=True)
metadata_file = self._get_metadata_file(self.product_id, self.feedback_id)
with open(metadata_file, 'w') as f:
yaml.dump(self.to_dict(), f, default_flow_style=False)
def get_content(self):
"""Load feedback content text
Returns:
str or None: Feedback content if exists, None otherwise
"""
content_file = self._get_content_file(self.product_id, self.feedback_id)
if not os.path.exists(content_file):
return None
with open(content_file, 'r') as f:
return f.read()
def get_attachments(self):
"""Get list of attachment filenames
Returns:
list: List of attachment filenames
"""
attachments_dir = self._get_attachments_dir(self.product_id, self.feedback_id)
if not os.path.exists(attachments_dir):
return []
return [f for f in os.listdir(attachments_dir)
if os.path.isfile(os.path.join(attachments_dir, f))]
def validate_status(self):
"""Validate feedback status
Returns:
bool: True if status is valid, False otherwise
"""
return self.status in self.VALID_STATUSES
+190
View File
@@ -0,0 +1,190 @@
"""Product model"""
import os
import yaml
from flask import current_app
class Product:
"""Product/Service model
Attributes:
product_id: Unique product identifier
name: Product/service name
submission_url_slug: URL slug for submission form
owner_language: Preferred language for product owner
assigned_owner_ids: List of product owner user IDs
status: Product status ('active' or 'archived')
"""
def __init__(self, product_id, name, submission_url_slug, owner_language,
assigned_owner_ids, status='active'):
self.product_id = product_id
self.name = name
self.submission_url_slug = submission_url_slug
self.owner_language = owner_language
self.assigned_owner_ids = assigned_owner_ids or []
self.status = status
def to_dict(self):
"""Convert product to dictionary
Returns:
dict: Product data
"""
return {
'product_id': self.product_id,
'name': self.name,
'submission_url_slug': self.submission_url_slug,
'owner_language': self.owner_language,
'assigned_owner_ids': self.assigned_owner_ids,
'status': self.status
}
@classmethod
def from_dict(cls, data):
"""Create product from dictionary
Args:
data: Dictionary with product data
Returns:
Product: Product instance
"""
return cls(
product_id=data['product_id'],
name=data['name'],
submission_url_slug=data['submission_url_slug'],
owner_language=data['owner_language'],
assigned_owner_ids=data.get('assigned_owner_ids', []),
status=data.get('status', 'active')
)
@staticmethod
def _get_product_dir(product_id):
"""Get product directory path
Args:
product_id: Product ID
Returns:
str: Path to product directory
"""
return os.path.join(current_app.config['DATA_DIR'], 'products', product_id)
@staticmethod
def _get_config_file(product_id):
"""Get product config file path
Args:
product_id: Product ID
Returns:
str: Path to config.yaml
"""
product_dir = Product._get_product_dir(product_id)
return os.path.join(product_dir, 'config.yaml')
@classmethod
def get_by_id(cls, product_id):
"""Load product by ID
Args:
product_id: Product ID to load
Returns:
Product or None: Product instance if found, None otherwise
"""
config_file = cls._get_config_file(product_id)
if not os.path.exists(config_file):
return None
with open(config_file, 'r') as f:
data = yaml.safe_load(f)
return cls.from_dict(data)
@classmethod
def get_by_slug(cls, slug):
"""Load product by submission URL slug
Args:
slug: Submission URL slug
Returns:
Product or None: Product instance if found, None otherwise
"""
# Scan all product directories
products_dir = os.path.join(current_app.config['DATA_DIR'], 'products')
if not os.path.exists(products_dir):
return None
for product_id in os.listdir(products_dir):
product_dir = os.path.join(products_dir, product_id)
if not os.path.isdir(product_dir):
continue
config_file = os.path.join(product_dir, 'config.yaml')
if not os.path.exists(config_file):
continue
with open(config_file, 'r') as f:
data = yaml.safe_load(f)
if data.get('submission_url_slug') == slug:
return cls.from_dict(data)
return None
@classmethod
def get_all(cls):
"""Get all products
Returns:
list: List of Product instances
"""
products = []
products_dir = os.path.join(current_app.config['DATA_DIR'], 'products')
if not os.path.exists(products_dir):
return products
for product_id in os.listdir(products_dir):
product = cls.get_by_id(product_id)
if product:
products.append(product)
return products
def save(self):
"""Save product to filesystem"""
product_dir = self._get_product_dir(self.product_id)
os.makedirs(product_dir, exist_ok=True)
config_file = self._get_config_file(self.product_id)
with open(config_file, 'w') as f:
yaml.dump(self.to_dict(), f, default_flow_style=False)
def delete(self):
"""Delete product (not implemented - use archive instead)"""
raise NotImplementedError("Products should be archived, not deleted")
def is_active(self):
"""Check if product is active
Returns:
bool: True if status is 'active', False otherwise
"""
return self.status == 'active'
def is_archived(self):
"""Check if product is archived
Returns:
bool: True if status is 'archived', False otherwise
"""
return self.status == 'archived'
+248
View File
@@ -0,0 +1,248 @@
"""User model for authentication"""
import os
import yaml
from flask_login import UserMixin
import bcrypt
class User(UserMixin):
"""User model for product owners and administrators
Attributes:
user_id: Unique user identifier
username: Username for login
email: User email address
password_hash: Bcrypt hashed password
role: User role ('product_owner' or 'administrator')
product_ids: List of product IDs (for product_owner role)
is_active: Whether user account is active
"""
def __init__(self, user_id, username, email, password_hash, role, product_ids=None, is_active=True):
self.user_id = user_id
self.username = username
self.email = email
self.password_hash = password_hash
self.role = role
self.product_ids = product_ids or []
self.is_active = is_active
def get_id(self):
"""Get user ID for Flask-Login"""
return self.user_id
@property
def is_authenticated(self):
"""Check if user is authenticated"""
return True
@property
def is_anonymous(self):
"""Check if user is anonymous"""
return False
def check_password(self, password):
"""Verify password against stored hash
Args:
password: Plain text password to verify
Returns:
bool: True if password matches, False otherwise
"""
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
@staticmethod
def hash_password(password):
"""Hash password using bcrypt
Args:
password: Plain text password
Returns:
str: Hashed password
"""
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def to_dict(self):
"""Convert user to dictionary for storage
Returns:
dict: User data
"""
return {
'user_id': self.user_id,
'username': self.username,
'email': self.email,
'password_hash': self.password_hash,
'role': self.role,
'product_ids': self.product_ids,
'is_active': self.is_active
}
@classmethod
def from_dict(cls, data):
"""Create user from dictionary
Args:
data: Dictionary with user data
Returns:
User: User instance
"""
return cls(
user_id=data['user_id'],
username=data['username'],
email=data['email'],
password_hash=data['password_hash'],
role=data['role'],
product_ids=data.get('product_ids', []),
is_active=data.get('is_active', True)
)
@staticmethod
def _get_users_file():
"""Get path to users YAML file
Returns:
str: Path to users.yaml
"""
from flask import current_app
return os.path.join(current_app.config['DATA_DIR'], 'users.yaml')
@staticmethod
def _load_all_users():
"""Load all users from storage
Returns:
dict: Dictionary of user_id -> user_data
"""
users_file = User._get_users_file()
if not os.path.exists(users_file):
return {}
with open(users_file, 'r') as f:
data = yaml.safe_load(f) or {}
return data.get('users', {})
@staticmethod
def _save_all_users(users_dict):
"""Save all users to storage
Args:
users_dict: Dictionary of user_id -> user_data
"""
users_file = User._get_users_file()
os.makedirs(os.path.dirname(users_file), exist_ok=True)
with open(users_file, 'w') as f:
yaml.dump({'users': users_dict}, f, default_flow_style=False)
@classmethod
def get_by_id(cls, user_id):
"""Load user by ID
Args:
user_id: User ID to load
Returns:
User or None: User instance if found, None otherwise
"""
users = cls._load_all_users()
user_data = users.get(user_id)
if user_data:
return cls.from_dict(user_data)
return None
@classmethod
def get_by_username(cls, username):
"""Load user by username
Args:
username: Username to search for
Returns:
User or None: User instance if found, None otherwise
"""
users = cls._load_all_users()
for user_data in users.values():
if user_data['username'] == username:
return cls.from_dict(user_data)
return None
@classmethod
def get_all(cls):
"""Get all users
Returns:
list: List of User instances
"""
users = cls._load_all_users()
return [cls.from_dict(data) for data in users.values()]
def save(self):
"""Save user to storage"""
users = self._load_all_users()
users[self.user_id] = self.to_dict()
self._save_all_users(users)
def delete(self):
"""Delete user from storage"""
users = self._load_all_users()
if self.user_id in users:
del users[self.user_id]
self._save_all_users(users)
@classmethod
def create(cls, username, email, password, role, product_ids=None):
"""Create new user
Args:
username: Username for login
email: User email
password: Plain text password
role: User role ('product_owner' or 'administrator')
product_ids: List of product IDs (for product_owner)
Returns:
User: Created user instance
Raises:
ValueError: If username already exists or role is invalid
"""
# Validate role
if role not in ['product_owner', 'administrator']:
raise ValueError(f"Invalid role: {role}")
# Check if username exists
if cls.get_by_username(username):
raise ValueError(f"Username already exists: {username}")
# Generate user ID
users = cls._load_all_users()
if users:
max_id = max([int(uid.replace('usr_', '')) for uid in users.keys()])
user_id = f"usr_{max_id + 1:04d}"
else:
user_id = "usr_0001"
# Hash password
password_hash = cls.hash_password(password)
# Create user
user = cls(
user_id=user_id,
username=username,
email=email,
password_hash=password_hash,
role=role,
product_ids=product_ids or [],
is_active=True
)
user.save()
return user
+5
View File
@@ -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']
+9
View File
@@ -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)
+48
View File
@@ -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'))
+9
View File
@@ -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)
+100
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
"""Services package"""
# Services provide business logic and external integrations
+52
View File
@@ -0,0 +1,52 @@
"""Authentication service"""
import bcrypt
from app.models.user import User
def verify_credentials(username, password):
"""Verify username and password
Args:
username: Username to check
password: Plain text password to verify
Returns:
User or None: User object if credentials valid, None otherwise
"""
if not username or not password:
return None
user = User.get_by_username(username)
if not user or not user.is_active:
return None
if user.check_password(password):
return user
return None
def hash_password(password):
"""Hash password using bcrypt
Args:
password: Plain text password
Returns:
str: Hashed password
"""
return User.hash_password(password)
def check_password(password, password_hash):
"""Check password against hash
Args:
password: Plain text password
password_hash: Bcrypt hash to check against
Returns:
bool: True if password matches, False otherwise
"""
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
+168
View File
@@ -0,0 +1,168 @@
"""Feedback storage service"""
import os
import shutil
from flask import current_app
from app.models.feedback import Feedback
from app.utils.file_validator import get_safe_filename
class FeedbackStorageService:
"""Service for storing feedback to filesystem"""
@staticmethod
def create_feedback(product_id, content_text=None, files=None):
"""Create new feedback entry
Args:
product_id: Product ID
content_text: Feedback text content (optional)
files: List of uploaded files (optional)
Returns:
Feedback: Created feedback instance
"""
# Generate unique feedback ID
feedback_id = Feedback.generate_id()
# Create content preview (first 200 chars)
content_preview = ''
if content_text:
content_preview = content_text[:200]
# Check attachments
has_attachments = bool(files and len(files) > 0)
attachment_count = len(files) if files else 0
# Create feedback instance
feedback = Feedback(
feedback_id=feedback_id,
product_id=product_id,
status='new',
content_preview=content_preview,
has_attachments=has_attachments,
attachment_count=attachment_count
)
# Create directory structure
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
os.makedirs(feedback_dir, exist_ok=True)
return feedback
@staticmethod
def save_metadata(feedback):
"""Save feedback metadata to YAML file
Args:
feedback: Feedback instance to save
"""
feedback.save_metadata()
@staticmethod
def save_content(feedback, content_text):
"""Save feedback content to text file
Args:
feedback: Feedback instance
content_text: Feedback text content
"""
if not content_text:
return
content_file = Feedback._get_content_file(feedback.product_id, feedback.feedback_id)
with open(content_file, 'w', encoding='utf-8') as f:
f.write(content_text)
@staticmethod
def save_attachments(feedback, files):
"""Save attachment files
Args:
feedback: Feedback instance
files: List of Werkzeug FileStorage objects
Returns:
list: List of saved filenames
"""
if not files:
return []
attachments_dir = Feedback._get_attachments_dir(feedback.product_id, feedback.feedback_id)
os.makedirs(attachments_dir, exist_ok=True)
saved_files = []
for file in files:
if not file or file.filename == '':
continue
# Sanitize filename
safe_filename = get_safe_filename(file.filename)
# Save file
file_path = os.path.join(attachments_dir, safe_filename)
file.save(file_path)
saved_files.append(safe_filename)
return saved_files
@staticmethod
def save_complete_feedback(product_id, content_text=None, files=None):
"""Create and save complete feedback submission
Args:
product_id: Product ID
content_text: Feedback text content (optional)
files: List of uploaded files (optional)
Returns:
Feedback: Created and saved feedback instance
"""
# Create feedback
feedback = FeedbackStorageService.create_feedback(product_id, content_text, files)
# Save content
if content_text:
FeedbackStorageService.save_content(feedback, content_text)
# Save attachments
if files:
FeedbackStorageService.save_attachments(feedback, files)
# Save metadata
FeedbackStorageService.save_metadata(feedback)
return feedback
@staticmethod
def update_feedback_status(feedback, new_status):
"""Update feedback status
Args:
feedback: Feedback instance
new_status: New status value
Returns:
bool: True if updated successfully, False otherwise
"""
if new_status not in Feedback.VALID_STATUSES:
return False
feedback.status = new_status
feedback.save_metadata()
return True
@staticmethod
def delete_feedback(feedback):
"""Delete feedback and all associated files
Args:
feedback: Feedback instance to delete
"""
feedback_dir = Feedback._get_feedback_dir(feedback.product_id, feedback.feedback_id)
if os.path.exists(feedback_dir):
shutil.rmtree(feedback_dir)
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Login - Reklamator{% endblock %}
{% block content %}
<h1>Login</h1>
<form method="POST">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
<p style="margin-top: 20px;">
<a href="{{ url_for('submission.form') }}">Return to feedback submission</a>
</p>
{% endblock %}
+217
View File
@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Reklamator - Anonymous Feedback{% endblock %}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1, h2, h3 {
margin-bottom: 20px;
color: #2c3e50;
}
h1 {
font-size: 2em;
border-bottom: 3px solid #3498db;
padding-bottom: 10px;
}
h2 {
font-size: 1.5em;
}
.flash-messages {
margin-bottom: 20px;
}
.flash {
padding: 12px 20px;
margin-bottom: 10px;
border-radius: 4px;
border-left: 4px solid;
}
.flash.success {
background-color: #d4edda;
border-color: #28a745;
color: #155724;
}
.flash.error {
background-color: #f8d7da;
border-color: #dc3545;
color: #721c24;
}
.flash.info {
background-color: #d1ecf1;
border-color: #17a2b8;
color: #0c5460;
}
.flash.warning {
background-color: #fff3cd;
border-color: #ffc107;
color: #856404;
}
form {
margin: 20px 0;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #555;
}
input[type="text"],
input[type="email"],
input[type="password"],
textarea,
select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
font-family: inherit;
}
textarea {
min-height: 150px;
resize: vertical;
}
button,
.btn {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
text-decoration: none;
transition: background-color 0.3s;
}
button:hover,
.btn:hover {
background-color: #2980b9;
}
button.secondary,
.btn.secondary {
background-color: #95a5a6;
}
button.secondary:hover,
.btn.secondary:hover {
background-color: #7f8c8d;
}
button.danger,
.btn.danger {
background-color: #e74c3c;
}
button.danger:hover,
.btn.danger:hover {
background-color: #c0392b;
}
.nav {
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.nav a {
margin-right: 20px;
color: #3498db;
text-decoration: none;
font-weight: 500;
}
.nav a:hover {
text-decoration: underline;
}
.error-text {
color: #e74c3c;
font-size: 0.9em;
margin-top: 5px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #555;
}
tr:hover {
background-color: #f8f9fa;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 3px;
font-size: 0.85em;
font-weight: 600;
}
.badge.new {
background-color: #3498db;
color: white;
}
.badge.analyzed {
background-color: #2ecc71;
color: white;
}
.badge.archived {
background-color: #95a5a6;
color: white;
}
</style>
</head>
<body>
<div class="container">
{% if current_user and current_user.is_authenticated %}
<div class="nav">
{% if current_user.role == 'product_owner' %}
<a href="{{ url_for('dashboard.list') }}">Dashboard</a>
<a href="{{ url_for('dashboard.products') }}">Products</a>
{% elif current_user.role == 'administrator' %}
<a href="{{ url_for('admin.dashboard') }}">Admin Dashboard</a>
<a href="{{ url_for('admin.users') }}">Users</a>
<a href="{{ url_for('admin.products') }}">Products</a>
{% endif %}
<a href="{{ url_for('auth.logout') }}" style="float: right;">Logout ({{ current_user.username }})</a>
</div>
{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Welcome - Reklamator{% endblock %}
{% block content %}
<div style="text-align: center; padding: 60px 20px;">
<h1 style="font-size: 2.5em; margin-bottom: 20px;">Reklamator</h1>
<p style="font-size: 1.3em; color: #666; margin-bottom: 40px;">
Anonymous Feedback Platform
</p>
<div style="max-width: 600px; margin: 0 auto; text-align: left;">
<h2>Submit Feedback</h2>
<p>If you have a product-specific submission link, use it to submit your feedback anonymously.</p>
<h2 style="margin-top: 40px;">Product Owners & Administrators</h2>
<p>
<a href="{{ url_for('auth.login') }}" class="btn">Login to Dashboard</a>
</p>
</div>
</div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Error - {{ product.name }}{% endblock %}
{% block content %}
<div style="text-align: center; padding: 40px 20px;">
<div style="font-size: 4em; color: #e74c3c; margin-bottom: 20px;"></div>
<h1>Oops! Something went wrong</h1>
<p style="font-size: 1.1em; margin: 20px 0; color: #555;">
{{ error_message }}
</p>
<div style="margin-top: 40px;">
<a href="{{ url_for('submission.form', product_slug=product.submission_url_slug) }}" class="btn">
Try Again
</a>
</div>
</div>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Submit Feedback - {{ product.name }}{% endblock %}
{% block content %}
<h1>Submit Feedback</h1>
<h2>{{ product.name }}</h2>
<p>We value your feedback. Please share your thoughts, report issues, or suggest improvements below.</p>
<form method="POST" enctype="multipart/form-data">
<div class="form-group">
<label for="feedback_text">Your Feedback</label>
<textarea id="feedback_text" name="feedback_text"
placeholder="Describe your feedback in any language..."></textarea>
<p style="font-size: 0.9em; color: #666; margin-top: 5px;">
You can write in any language. Optional if you attach files.
</p>
</div>
<div class="form-group">
<label for="files">Attachments (Optional)</label>
<input type="file" id="files" name="files" multiple>
<p style="font-size: 0.9em; color: #666; margin-top: 5px;">
You can attach up to 3 files (max 10MB each). Allowed types: images (PNG, JPG, GIF),
documents (PDF, TXT, DOC, DOCX), spreadsheets (XLS, XLSX, CSV).
</p>
</div>
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin: 20px 0;">
<h3 style="margin-top: 0; font-size: 1.1em;">Privacy Notice</h3>
<ul style="margin: 10px 0; padding-left: 20px; line-height: 1.8;">
<li>Your feedback is submitted anonymously</li>
<li>We do not collect or store your IP address</li>
<li>All files are scanned for malware</li>
<li>Please do not include personal information unless necessary</li>
</ul>
</div>
<button type="submit">Submit Feedback</button>
</form>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Feedback Submitted - {{ product.name }}{% endblock %}
{% block content %}
<div style="text-align: center; padding: 40px 20px;">
<div style="font-size: 4em; color: #2ecc71; margin-bottom: 20px;"></div>
<h1>Thank You!</h1>
<p style="font-size: 1.2em; margin: 20px 0;">
Your feedback has been successfully submitted.
</p>
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 4px; margin: 30px 0; text-align: left;">
<h3 style="margin-top: 0;">What happens next?</h3>
<ul style="line-height: 2;">
<li>Your feedback will be analyzed automatically</li>
<li>The product team will review your submission</li>
<li>They may use your feedback to improve {{ product.name }}</li>
</ul>
<p style="margin: 20px 0 10px 0; font-size: 0.9em; color: #666;">
<strong>Reference ID:</strong> {{ feedback_id }}
</p>
<p style="margin: 0; font-size: 0.9em; color: #666;">
(This ID is for your reference only. We cannot track individual submissions.)
</p>
</div>
<p style="margin-top: 40px;">
<a href="{{ url_for('submission.form', product_slug=product.submission_url_slug) }}" class="btn">
Submit More Feedback
</a>
</p>
</div>
{% endblock %}
+2
View File
@@ -0,0 +1,2 @@
"""Utilities package"""
# Utility functions for validation, security, etc.
+144
View File
@@ -0,0 +1,144 @@
"""File upload validation utilities"""
import os
from werkzeug.utils import secure_filename
import clamd
from flask import current_app
# Allowed file extensions for attachments
ALLOWED_EXTENSIONS = {
'txt', 'log', 'pdf', 'png', 'jpg', 'jpeg', 'gif',
'doc', 'docx', 'xls', 'xlsx', 'csv'
}
# Maximum file size (10MB)
MAX_FILE_SIZE = 10 * 1024 * 1024
def allowed_file(filename):
"""Check if file extension is allowed
Args:
filename: Name of the uploaded file
Returns:
bool: True if extension is allowed, False otherwise
"""
if not filename:
return False
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def validate_file_size(file_stream):
"""Check if file size is within limits
Args:
file_stream: File stream object
Returns:
bool: True if size is acceptable, False otherwise
"""
# Seek to end to get file size
file_stream.seek(0, os.SEEK_END)
size = file_stream.tell()
# Reset to beginning
file_stream.seek(0)
return size <= MAX_FILE_SIZE
def get_safe_filename(filename):
"""Get secure version of filename
Args:
filename: Original filename
Returns:
str: Secure filename safe for filesystem storage
"""
return secure_filename(filename)
def validate_file(file):
"""Validate uploaded file
Args:
file: Werkzeug FileStorage object
Returns:
tuple: (is_valid, error_message)
is_valid: bool indicating if file is valid
error_message: str with error description or None
"""
if not file:
return False, "No file provided"
if file.filename == '':
return False, "No file selected"
if not allowed_file(file.filename):
return False, f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}"
if not validate_file_size(file.stream):
return False, f"File size exceeds maximum of {MAX_FILE_SIZE / (1024 * 1024):.0f}MB"
return True, None
def scan_file_for_viruses(file):
"""Scan file for viruses using ClamAV
Args:
file: Werkzeug FileStorage object
Returns:
tuple: (is_clean, error_message)
is_clean: bool indicating if file is clean (True) or infected (False)
error_message: str with error description or None
"""
try:
# Connect to ClamAV daemon
clamd_socket = current_app.config.get('CLAMD_SOCKET')
if not clamd_socket:
# ClamAV not configured, skip scanning
current_app.logger.warning("ClamAV socket not configured, skipping virus scan")
return True, None
cd = clamd.ClamdUnixSocket(clamd_socket)
# Ping to check if ClamAV is available
try:
cd.ping()
except Exception as e:
current_app.logger.warning(f"ClamAV not available: {e}, skipping virus scan")
return True, None
# Read file content
file.stream.seek(0)
file_data = file.stream.read()
file.stream.seek(0) # Reset for later use
# Scan file
scan_result = cd.instream(file_data)
# Check result
if scan_result and 'stream' in scan_result:
status, virus_name = scan_result['stream']
if status == 'OK':
return True, None
elif status == 'FOUND':
return False, f"Virus detected: {virus_name}"
else:
return False, f"Scan error: {status}"
return True, None
except Exception as e:
current_app.logger.error(f"ClamAV scanning error: {e}")
# On error, we'll allow the file but log the error
# In production, you might want to reject files if scanning fails
return True, None