Files
Reklamator/app/services/feedback_storage.py
T
gurixandClaude adbfd23c26 Implement Phase 5: Product Owner Dashboard (User Story 3)
Add complete dashboard functionality for product owners and administrators to view, filter, search, and manage feedback submissions following Test-First Discipline.

Tests (T093-T105):
- Add 12 contract tests for dashboard routes (authentication, listing, filtering, search, detail view, status updates, attachment downloads, access control)
- Add 2 integration tests for complete dashboard workflow and access control enforcement
- All tests written first and verified to fail before implementation

Services (T111-T118):
- Enhance FeedbackStorageService with load_feedback_list() for pagination, filtering, searching, and sorting
- Add load_feedback_detail() to load complete feedback with attachments and analysis
- Add update_feedback_status_by_id() for status management
- Add get_attachment_path() with path traversal prevention

Routes (T119-T134):
- Implement GET /dashboard with filters, search, and pagination (50 items/page)
- Implement GET /feedback/<id> detail view with role-based access control
- Implement POST /feedback/<id>/status for status updates
- Implement GET /feedback/<id>/attachment/<filename> for secure file downloads
- Add access control helpers (administrators see all products, owners see only assigned)

Templates (T135-T136):
- Create dashboard/list.html with filter form, search, and pagination
- Create dashboard/detail.html with status update form and attachment links
- Create error_403.html for access denied
- Create error_404.html for not found

Integration & Bug Fixes:
- Update auth routes to remove /auth prefix and redirect to dashboard after login
- Update Feedback.VALID_STATUSES to include dashboard statuses (in_progress, resolved, closed)
- Register error handlers for 403 and 404 in app factory
- Fix test fixtures to use correct users.yaml format and User.hash_password()

Test Results: 39 passed, 1 skipped (all Phase 5 tests passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 20:11:16 +02:00

474 lines
14 KiB
Python

"""Feedback storage service"""
import os
import shutil
import yaml
from datetime import datetime
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)
@staticmethod
def load_feedback_list(product_ids=None, page=1, per_page=50, filters=None, search_query=None):
"""Load feedback list with filtering, searching, and pagination
Args:
product_ids: List of product IDs to load feedback for (None = all products)
page: Page number (1-indexed)
per_page: Items per page
filters: Dict with filter criteria (category, status, language, date_range)
search_query: Search query string
Returns:
dict: {
'items': List of feedback dicts,
'total': Total count,
'page': Current page,
'per_page': Items per page,
'pages': Total pages
}
"""
data_dir = current_app.config['DATA_DIR']
products_dir = os.path.join(data_dir, 'products')
all_feedback = []
# If no product_ids specified, load all products
if product_ids is None:
product_ids = []
if os.path.exists(products_dir):
for item in os.listdir(products_dir):
if os.path.isdir(os.path.join(products_dir, item)):
product_ids.append(item)
# Load feedback from each product
for product_id in product_ids:
feedback_dir = os.path.join(products_dir, product_id, 'feedback')
if not os.path.exists(feedback_dir):
continue
for feedback_id in os.listdir(feedback_dir):
feedback_path = os.path.join(feedback_dir, feedback_id)
if not os.path.isdir(feedback_path):
continue
# Load metadata
metadata_file = os.path.join(feedback_path, 'metadata.yaml')
if not os.path.exists(metadata_file):
continue
with open(metadata_file, 'r') as f:
metadata = yaml.safe_load(f)
# Load content preview
content_file = os.path.join(feedback_path, 'content.txt')
content_preview = ''
if os.path.exists(content_file):
with open(content_file, 'r', encoding='utf-8') as f:
content = f.read()
content_preview = content[:200]
# Add to list
feedback_data = {
'feedback_id': feedback_id,
'product_id': product_id,
'status': metadata.get('status', 'new'),
'category': metadata.get('category', 'uncategorized'),
'original_language': metadata.get('original_language', 'unknown'),
'submitted_at': metadata.get('submitted_at'),
'has_attachments': metadata.get('has_attachments', False),
'attachment_count': metadata.get('attachment_count', 0),
'content_preview': content_preview
}
all_feedback.append(feedback_data)
# Apply filters
if filters:
all_feedback = FeedbackStorageService._apply_filters(all_feedback, filters)
# Apply search
if search_query:
all_feedback = FeedbackStorageService._apply_search(all_feedback, search_query)
# Sort by timestamp (newest first)
all_feedback.sort(key=lambda x: x.get('submitted_at', ''), reverse=True)
# Calculate pagination
total = len(all_feedback)
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
# Get page items
items = all_feedback[start_idx:end_idx]
return {
'items': items,
'total': total,
'page': page,
'per_page': per_page,
'pages': total_pages
}
@staticmethod
def _apply_filters(feedback_list, filters):
"""Apply filters to feedback list
Args:
feedback_list: List of feedback dicts
filters: Dict with filter criteria
Returns:
list: Filtered feedback list
"""
filtered = feedback_list
# Filter by category
if filters.get('category'):
filtered = [f for f in filtered if f.get('category') == filters['category']]
# Filter by status
if filters.get('status'):
filtered = [f for f in filtered if f.get('status') == filters['status']]
# Filter by language
if filters.get('language'):
filtered = [f for f in filtered if f.get('original_language') == filters['language']]
# Filter by date range
if filters.get('date_from') or filters.get('date_to'):
date_from = filters.get('date_from')
date_to = filters.get('date_to')
def in_date_range(feedback):
submitted_at = feedback.get('submitted_at')
if not submitted_at:
return False
if date_from and submitted_at < date_from:
return False
if date_to and submitted_at > date_to:
return False
return True
filtered = [f for f in filtered if in_date_range(f)]
return filtered
@staticmethod
def _apply_search(feedback_list, search_query):
"""Apply search query to feedback list
Searches in content preview, category, and status
Args:
feedback_list: List of feedback dicts
search_query: Search string
Returns:
list: Filtered feedback list
"""
if not search_query:
return feedback_list
query_lower = search_query.lower()
def matches_search(feedback):
# Search in content preview
if query_lower in feedback.get('content_preview', '').lower():
return True
# Search in category
if query_lower in feedback.get('category', '').lower():
return True
# Search in feedback ID
if query_lower in feedback.get('feedback_id', '').lower():
return True
return False
return [f for f in feedback_list if matches_search(f)]
@staticmethod
def load_feedback_detail(product_id, feedback_id):
"""Load complete feedback details
Args:
product_id: Product ID
feedback_id: Feedback ID
Returns:
dict: Complete feedback data or None if not found
"""
data_dir = current_app.config['DATA_DIR']
feedback_path = os.path.join(data_dir, 'products', product_id, 'feedback', feedback_id)
if not os.path.exists(feedback_path):
return None
# Load metadata
metadata_file = os.path.join(feedback_path, 'metadata.yaml')
if not os.path.exists(metadata_file):
return None
with open(metadata_file, 'r') as f:
metadata = yaml.safe_load(f)
# Load content
content_file = os.path.join(feedback_path, 'content.txt')
content = ''
if os.path.exists(content_file):
with open(content_file, 'r', encoding='utf-8') as f:
content = f.read()
# Load analysis if exists
analysis_file = os.path.join(feedback_path, 'analysis.md')
analysis = ''
if os.path.exists(analysis_file):
with open(analysis_file, 'r', encoding='utf-8') as f:
analysis = f.read()
# List attachments
attachments = []
attachments_dir = os.path.join(feedback_path, 'attachments')
if os.path.exists(attachments_dir):
attachments = os.listdir(attachments_dir)
return {
'feedback_id': feedback_id,
'product_id': product_id,
'metadata': metadata,
'content': content,
'analysis': analysis,
'attachments': attachments
}
@staticmethod
def update_feedback_status_by_id(product_id, feedback_id, new_status):
"""Update feedback status by IDs
Args:
product_id: Product ID
feedback_id: Feedback ID
new_status: New status value
Returns:
bool: True if updated successfully, False otherwise
"""
data_dir = current_app.config['DATA_DIR']
metadata_file = os.path.join(
data_dir, 'products', product_id, 'feedback', feedback_id, 'metadata.yaml'
)
if not os.path.exists(metadata_file):
return False
# Load metadata
with open(metadata_file, 'r') as f:
metadata = yaml.safe_load(f)
# Update status
if new_status not in Feedback.VALID_STATUSES:
return False
metadata['status'] = new_status
# Save metadata
with open(metadata_file, 'w') as f:
yaml.dump(metadata, f)
return True
@staticmethod
def get_attachment_path(product_id, feedback_id, filename):
"""Get path to attachment file
Args:
product_id: Product ID
feedback_id: Feedback ID
filename: Attachment filename
Returns:
str: Full path to attachment file or None if not found
"""
data_dir = current_app.config['DATA_DIR']
attachment_path = os.path.join(
data_dir, 'products', product_id, 'feedback', feedback_id, 'attachments', filename
)
if not os.path.exists(attachment_path):
return None
# Check for path traversal
attachments_dir = os.path.join(data_dir, 'products', product_id, 'feedback', feedback_id, 'attachments')
if not os.path.abspath(attachment_path).startswith(os.path.abspath(attachments_dir)):
return None
return attachment_path