Adds landing page at root URL (/) that displays all active products with links to feedback submission forms. This replaces the requirement for users to know direct product URLs. Changes: - Added Product.load_active() method to filter and sort active products alphabetically - Created landing route blueprint with error handling and structured logging - Registered landing blueprint in app factory, replacing old index route - Created landing page template with product list and empty state - Added comprehensive contract tests (6 tests) covering active products, filtering, sorting, XSS prevention - Added integration test for complete user flow from landing page to submission form All 7 tests pass. User Story 1 (P1 - MVP) complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
207 lines
5.9 KiB
Python
207 lines
5.9 KiB
Python
"""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
|
|
|
|
@classmethod
|
|
def load_active(cls):
|
|
"""Load all active products, sorted alphabetically by name then product_id
|
|
|
|
Returns:
|
|
list[Product]: Active products with valid submission_url_slug, sorted by:
|
|
1. name (case-insensitive alphabetical)
|
|
2. product_id (alphabetical) as tiebreaker
|
|
|
|
Products with missing/invalid submission_url_slug are excluded.
|
|
"""
|
|
all_products = cls.get_all()
|
|
active = [p for p in all_products
|
|
if p.status == 'active' and p.submission_url_slug]
|
|
return sorted(active, key=lambda p: (p.name.lower(), p.product_id))
|
|
|
|
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'
|