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>
257 lines
7.6 KiB
Python
257 lines
7.6 KiB
Python
"""Feedback model"""
|
|
import os
|
|
import uuid
|
|
from datetime import datetime
|
|
import yaml
|
|
from flask import current_app
|
|
|
|
|
|
class Feedback:
|
|
"""Feedback submission model
|
|
|
|
Attributes:
|
|
feedback_id: Unique feedback identifier (UUID)
|
|
product_id: Associated product ID
|
|
submitted_at: Submission timestamp (ISO 8601)
|
|
status: Feedback status ('new', 'analyzing', 'analyzed', 'analysis_failed', 'archived')
|
|
content_preview: First 200 chars of feedback text
|
|
has_attachments: Whether feedback has file attachments
|
|
attachment_count: Number of attached files
|
|
original_language: Detected language of feedback (set during analysis)
|
|
category: Feedback category (set during analysis)
|
|
"""
|
|
|
|
VALID_STATUSES = ['new', 'in_progress', 'resolved', 'closed', 'analyzing', 'analyzed', 'analysis_failed', 'archived']
|
|
|
|
def __init__(self, feedback_id, product_id, submitted_at=None, status='new',
|
|
content_preview='', has_attachments=False, attachment_count=0,
|
|
original_language=None, category=None):
|
|
self.feedback_id = feedback_id
|
|
self.product_id = product_id
|
|
self.submitted_at = submitted_at or datetime.utcnow().isoformat()
|
|
self.status = status
|
|
self.content_preview = content_preview
|
|
self.has_attachments = has_attachments
|
|
self.attachment_count = attachment_count
|
|
self.original_language = original_language
|
|
self.category = category
|
|
|
|
def to_dict(self):
|
|
"""Convert feedback to dictionary
|
|
|
|
Returns:
|
|
dict: Feedback metadata
|
|
"""
|
|
data = {
|
|
'feedback_id': self.feedback_id,
|
|
'product_id': self.product_id,
|
|
'submitted_at': self.submitted_at,
|
|
'status': self.status,
|
|
'content_preview': self.content_preview,
|
|
'has_attachments': self.has_attachments,
|
|
'attachment_count': self.attachment_count
|
|
}
|
|
|
|
if self.original_language:
|
|
data['original_language'] = self.original_language
|
|
|
|
if self.category:
|
|
data['category'] = self.category
|
|
|
|
return data
|
|
|
|
@classmethod
|
|
def from_dict(cls, data):
|
|
"""Create feedback from dictionary
|
|
|
|
Args:
|
|
data: Dictionary with feedback data
|
|
|
|
Returns:
|
|
Feedback: Feedback instance
|
|
"""
|
|
return cls(
|
|
feedback_id=data['feedback_id'],
|
|
product_id=data['product_id'],
|
|
submitted_at=data.get('submitted_at'),
|
|
status=data.get('status', 'new'),
|
|
content_preview=data.get('content_preview', ''),
|
|
has_attachments=data.get('has_attachments', False),
|
|
attachment_count=data.get('attachment_count', 0),
|
|
original_language=data.get('original_language'),
|
|
category=data.get('category')
|
|
)
|
|
|
|
@staticmethod
|
|
def generate_id():
|
|
"""Generate unique feedback ID
|
|
|
|
Returns:
|
|
str: UUID-based feedback ID
|
|
"""
|
|
return str(uuid.uuid4())
|
|
|
|
@staticmethod
|
|
def _get_feedback_dir(product_id, feedback_id):
|
|
"""Get feedback directory path
|
|
|
|
Args:
|
|
product_id: Product ID
|
|
feedback_id: Feedback ID
|
|
|
|
Returns:
|
|
str: Path to feedback directory
|
|
"""
|
|
return os.path.join(
|
|
current_app.config['DATA_DIR'],
|
|
'products',
|
|
product_id,
|
|
'feedback',
|
|
feedback_id
|
|
)
|
|
|
|
@staticmethod
|
|
def _get_metadata_file(product_id, feedback_id):
|
|
"""Get metadata file path
|
|
|
|
Args:
|
|
product_id: Product ID
|
|
feedback_id: Feedback ID
|
|
|
|
Returns:
|
|
str: Path to metadata.yaml
|
|
"""
|
|
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
|
|
return os.path.join(feedback_dir, 'metadata.yaml')
|
|
|
|
@staticmethod
|
|
def _get_content_file(product_id, feedback_id):
|
|
"""Get content file path
|
|
|
|
Args:
|
|
product_id: Product ID
|
|
feedback_id: Feedback ID
|
|
|
|
Returns:
|
|
str: Path to content.txt
|
|
"""
|
|
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
|
|
return os.path.join(feedback_dir, 'content.txt')
|
|
|
|
@staticmethod
|
|
def _get_attachments_dir(product_id, feedback_id):
|
|
"""Get attachments directory path
|
|
|
|
Args:
|
|
product_id: Product ID
|
|
feedback_id: Feedback ID
|
|
|
|
Returns:
|
|
str: Path to attachments directory
|
|
"""
|
|
feedback_dir = Feedback._get_feedback_dir(product_id, feedback_id)
|
|
return os.path.join(feedback_dir, 'attachments')
|
|
|
|
@classmethod
|
|
def get_by_id(cls, product_id, feedback_id):
|
|
"""Load feedback by ID
|
|
|
|
Args:
|
|
product_id: Product ID
|
|
feedback_id: Feedback ID
|
|
|
|
Returns:
|
|
Feedback or None: Feedback instance if found, None otherwise
|
|
"""
|
|
metadata_file = cls._get_metadata_file(product_id, feedback_id)
|
|
|
|
if not os.path.exists(metadata_file):
|
|
return None
|
|
|
|
with open(metadata_file, 'r') as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
return cls.from_dict(data)
|
|
|
|
@classmethod
|
|
def get_all_for_product(cls, product_id):
|
|
"""Get all feedback for a product
|
|
|
|
Args:
|
|
product_id: Product ID
|
|
|
|
Returns:
|
|
list: List of Feedback instances, sorted by submitted_at (newest first)
|
|
"""
|
|
feedback_list = []
|
|
feedback_base_dir = os.path.join(
|
|
current_app.config['DATA_DIR'],
|
|
'products',
|
|
product_id,
|
|
'feedback'
|
|
)
|
|
|
|
if not os.path.exists(feedback_base_dir):
|
|
return feedback_list
|
|
|
|
for feedback_id in os.listdir(feedback_base_dir):
|
|
feedback_dir = os.path.join(feedback_base_dir, feedback_id)
|
|
|
|
if not os.path.isdir(feedback_dir):
|
|
continue
|
|
|
|
feedback = cls.get_by_id(product_id, feedback_id)
|
|
if feedback:
|
|
feedback_list.append(feedback)
|
|
|
|
# Sort by submitted_at (newest first)
|
|
feedback_list.sort(key=lambda f: f.submitted_at, reverse=True)
|
|
|
|
return feedback_list
|
|
|
|
def save_metadata(self):
|
|
"""Save feedback metadata to filesystem"""
|
|
feedback_dir = self._get_feedback_dir(self.product_id, self.feedback_id)
|
|
os.makedirs(feedback_dir, exist_ok=True)
|
|
|
|
metadata_file = self._get_metadata_file(self.product_id, self.feedback_id)
|
|
|
|
with open(metadata_file, 'w') as f:
|
|
yaml.dump(self.to_dict(), f, default_flow_style=False)
|
|
|
|
def get_content(self):
|
|
"""Load feedback content text
|
|
|
|
Returns:
|
|
str or None: Feedback content if exists, None otherwise
|
|
"""
|
|
content_file = self._get_content_file(self.product_id, self.feedback_id)
|
|
|
|
if not os.path.exists(content_file):
|
|
return None
|
|
|
|
with open(content_file, 'r') as f:
|
|
return f.read()
|
|
|
|
def get_attachments(self):
|
|
"""Get list of attachment filenames
|
|
|
|
Returns:
|
|
list: List of attachment filenames
|
|
"""
|
|
attachments_dir = self._get_attachments_dir(self.product_id, self.feedback_id)
|
|
|
|
if not os.path.exists(attachments_dir):
|
|
return []
|
|
|
|
return [f for f in os.listdir(attachments_dir)
|
|
if os.path.isfile(os.path.join(attachments_dir, f))]
|
|
|
|
def validate_status(self):
|
|
"""Validate feedback status
|
|
|
|
Returns:
|
|
bool: True if status is valid, False otherwise
|
|
"""
|
|
return self.status in self.VALID_STATUSES
|