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,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']
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user