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,2 @@
|
||||
"""Services package"""
|
||||
# Services provide business logic and external integrations
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Authentication service"""
|
||||
import bcrypt
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def verify_credentials(username, password):
|
||||
"""Verify username and password
|
||||
|
||||
Args:
|
||||
username: Username to check
|
||||
password: Plain text password to verify
|
||||
|
||||
Returns:
|
||||
User or None: User object if credentials valid, None otherwise
|
||||
"""
|
||||
if not username or not password:
|
||||
return None
|
||||
|
||||
user = User.get_by_username(username)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
|
||||
if user.check_password(password):
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""Hash password using bcrypt
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
str: Hashed password
|
||||
"""
|
||||
return User.hash_password(password)
|
||||
|
||||
|
||||
def check_password(password, password_hash):
|
||||
"""Check password against hash
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
password_hash: Bcrypt hash to check against
|
||||
|
||||
Returns:
|
||||
bool: True if password matches, False otherwise
|
||||
"""
|
||||
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Feedback storage service"""
|
||||
import os
|
||||
import shutil
|
||||
from flask import current_app
|
||||
from app.models.feedback import Feedback
|
||||
from app.utils.file_validator import get_safe_filename
|
||||
|
||||
|
||||
class FeedbackStorageService:
|
||||
"""Service for storing feedback to filesystem"""
|
||||
|
||||
@staticmethod
|
||||
def create_feedback(product_id, content_text=None, files=None):
|
||||
"""Create new feedback entry
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
content_text: Feedback text content (optional)
|
||||
files: List of uploaded files (optional)
|
||||
|
||||
Returns:
|
||||
Feedback: Created feedback instance
|
||||
"""
|
||||
# Generate unique feedback ID
|
||||
feedback_id = Feedback.generate_id()
|
||||
|
||||
# Create content preview (first 200 chars)
|
||||
content_preview = ''
|
||||
if content_text:
|
||||
content_preview = content_text[:200]
|
||||
|
||||
# Check attachments
|
||||
has_attachments = bool(files and len(files) > 0)
|
||||
attachment_count = len(files) if files else 0
|
||||
|
||||
# Create feedback instance
|
||||
feedback = Feedback(
|
||||
feedback_id=feedback_id,
|
||||
product_id=product_id,
|
||||
status='new',
|
||||
content_preview=content_preview,
|
||||
has_attachments=has_attachments,
|
||||
attachment_count=attachment_count
|
||||
)
|
||||
|
||||
# Create directory structure
|
||||
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
|
||||
os.makedirs(feedback_dir, exist_ok=True)
|
||||
|
||||
return feedback
|
||||
|
||||
@staticmethod
|
||||
def save_metadata(feedback):
|
||||
"""Save feedback metadata to YAML file
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance to save
|
||||
"""
|
||||
feedback.save_metadata()
|
||||
|
||||
@staticmethod
|
||||
def save_content(feedback, content_text):
|
||||
"""Save feedback content to text file
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance
|
||||
content_text: Feedback text content
|
||||
"""
|
||||
if not content_text:
|
||||
return
|
||||
|
||||
content_file = Feedback._get_content_file(feedback.product_id, feedback.feedback_id)
|
||||
|
||||
with open(content_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content_text)
|
||||
|
||||
@staticmethod
|
||||
def save_attachments(feedback, files):
|
||||
"""Save attachment files
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance
|
||||
files: List of Werkzeug FileStorage objects
|
||||
|
||||
Returns:
|
||||
list: List of saved filenames
|
||||
"""
|
||||
if not files:
|
||||
return []
|
||||
|
||||
attachments_dir = Feedback._get_attachments_dir(feedback.product_id, feedback.feedback_id)
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
|
||||
saved_files = []
|
||||
|
||||
for file in files:
|
||||
if not file or file.filename == '':
|
||||
continue
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = get_safe_filename(file.filename)
|
||||
|
||||
# Save file
|
||||
file_path = os.path.join(attachments_dir, safe_filename)
|
||||
file.save(file_path)
|
||||
|
||||
saved_files.append(safe_filename)
|
||||
|
||||
return saved_files
|
||||
|
||||
@staticmethod
|
||||
def save_complete_feedback(product_id, content_text=None, files=None):
|
||||
"""Create and save complete feedback submission
|
||||
|
||||
Args:
|
||||
product_id: Product ID
|
||||
content_text: Feedback text content (optional)
|
||||
files: List of uploaded files (optional)
|
||||
|
||||
Returns:
|
||||
Feedback: Created and saved feedback instance
|
||||
"""
|
||||
# Create feedback
|
||||
feedback = FeedbackStorageService.create_feedback(product_id, content_text, files)
|
||||
|
||||
# Save content
|
||||
if content_text:
|
||||
FeedbackStorageService.save_content(feedback, content_text)
|
||||
|
||||
# Save attachments
|
||||
if files:
|
||||
FeedbackStorageService.save_attachments(feedback, files)
|
||||
|
||||
# Save metadata
|
||||
FeedbackStorageService.save_metadata(feedback)
|
||||
|
||||
return feedback
|
||||
|
||||
@staticmethod
|
||||
def update_feedback_status(feedback, new_status):
|
||||
"""Update feedback status
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance
|
||||
new_status: New status value
|
||||
|
||||
Returns:
|
||||
bool: True if updated successfully, False otherwise
|
||||
"""
|
||||
if new_status not in Feedback.VALID_STATUSES:
|
||||
return False
|
||||
|
||||
feedback.status = new_status
|
||||
feedback.save_metadata()
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def delete_feedback(feedback):
|
||||
"""Delete feedback and all associated files
|
||||
|
||||
Args:
|
||||
feedback: Feedback instance to delete
|
||||
"""
|
||||
feedback_dir = Feedback._get_feedback_dir(feedback.product_id, feedback.feedback_id)
|
||||
|
||||
if os.path.exists(feedback_dir):
|
||||
shutil.rmtree(feedback_dir)
|
||||
Reference in New Issue
Block a user