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
+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