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