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>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"""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
|
||||
@@ -166,3 +168,306 @@ class FeedbackStorageService:
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user