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