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:
@@ -70,4 +70,17 @@ def create_app(config_name='development'):
|
||||
from flask import render_template
|
||||
return render_template('index.html')
|
||||
|
||||
# Register error handlers
|
||||
@app.errorhandler(403)
|
||||
def forbidden(e):
|
||||
"""Handle 403 Forbidden errors"""
|
||||
from flask import render_template
|
||||
return render_template('error_403.html'), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
"""Handle 404 Not Found errors"""
|
||||
from flask import render_template
|
||||
return render_template('error_404.html'), 404
|
||||
|
||||
return app
|
||||
|
||||
@@ -21,7 +21,7 @@ class Feedback:
|
||||
category: Feedback category (set during analysis)
|
||||
"""
|
||||
|
||||
VALID_STATUSES = ['new', 'analyzing', 'analyzed', 'analysis_failed', 'archived']
|
||||
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,
|
||||
|
||||
+3
-8
@@ -4,7 +4,7 @@ from flask_login import login_user, logout_user, login_required
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
@@ -28,13 +28,8 @@ def login():
|
||||
login_user(user)
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
|
||||
# TODO: Redirect based on role when dashboards are implemented
|
||||
# For now, redirect to index page
|
||||
# if user.role == 'administrator':
|
||||
# return redirect(url_for('admin.dashboard'))
|
||||
# elif user.role == 'product_owner':
|
||||
# return redirect(url_for('dashboard.list'))
|
||||
return redirect(url_for('index'))
|
||||
# Redirect to dashboard for product owners and administrators
|
||||
return redirect(url_for('dashboard.list'))
|
||||
else:
|
||||
flash('Invalid username or password', 'error')
|
||||
|
||||
|
||||
+242
-4
@@ -1,9 +1,247 @@
|
||||
"""Dashboard routes - product owner feedback management"""
|
||||
from flask import Blueprint
|
||||
from flask_login import login_required
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, abort
|
||||
from flask_login import login_required, current_user
|
||||
from app.services.feedback_storage import FeedbackStorageService
|
||||
from app.models.product import Product
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
|
||||
bp = Blueprint('dashboard', __name__, url_prefix='/dashboard')
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
|
||||
# Routes will be implemented in Phase 5 (User Story 3)
|
||||
def get_user_product_ids():
|
||||
"""Get list of product IDs accessible to current user
|
||||
|
||||
Returns:
|
||||
list: Product IDs or None for administrators (access to all)
|
||||
"""
|
||||
if not current_user.is_authenticated:
|
||||
return []
|
||||
|
||||
# Administrators have access to all products
|
||||
if current_user.role == 'administrator':
|
||||
return None # None means all products
|
||||
|
||||
# Product owners see only assigned products
|
||||
return current_user.product_ids
|
||||
|
||||
|
||||
def check_product_access(product_id):
|
||||
"""Check if current user has access to product
|
||||
|
||||
Args:
|
||||
product_id: Product ID to check
|
||||
|
||||
Returns:
|
||||
bool: True if user has access, False otherwise
|
||||
"""
|
||||
if not current_user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Administrators have access to all products
|
||||
if current_user.role == 'administrator':
|
||||
return True
|
||||
|
||||
# Product owners see only assigned products
|
||||
return product_id in current_user.product_ids
|
||||
|
||||
|
||||
@bp.route('/dashboard')
|
||||
@login_required
|
||||
def list():
|
||||
"""Dashboard - list feedback with filters and search
|
||||
|
||||
Query parameters:
|
||||
page: Page number (default 1)
|
||||
category: Filter by category
|
||||
status: Filter by status
|
||||
language: Filter by language
|
||||
search: Search query
|
||||
"""
|
||||
# Get query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
category = request.args.get('category')
|
||||
status = request.args.get('status')
|
||||
language = request.args.get('language')
|
||||
search_query = request.args.get('search')
|
||||
|
||||
# Build filters
|
||||
filters = {}
|
||||
if category:
|
||||
filters['category'] = category
|
||||
if status:
|
||||
filters['status'] = status
|
||||
if language:
|
||||
filters['language'] = language
|
||||
|
||||
# Get product IDs for current user
|
||||
product_ids = get_user_product_ids()
|
||||
|
||||
# Load feedback list
|
||||
result = FeedbackStorageService.load_feedback_list(
|
||||
product_ids=product_ids,
|
||||
page=page,
|
||||
per_page=50,
|
||||
filters=filters if filters else None,
|
||||
search_query=search_query
|
||||
)
|
||||
|
||||
# Load product names for display
|
||||
all_products = Product.get_all()
|
||||
product_names = {p.product_id: p.name for p in all_products}
|
||||
|
||||
return render_template(
|
||||
'dashboard/list.html',
|
||||
feedback_list=result['items'],
|
||||
page=result['page'],
|
||||
pages=result['pages'],
|
||||
total=result['total'],
|
||||
product_names=product_names,
|
||||
filters={
|
||||
'category': category,
|
||||
'status': status,
|
||||
'language': language,
|
||||
'search': search_query
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/feedback/<feedback_id>')
|
||||
@login_required
|
||||
def detail(feedback_id):
|
||||
"""Feedback detail view
|
||||
|
||||
Args:
|
||||
feedback_id: Feedback ID to view
|
||||
|
||||
Returns:
|
||||
Rendered template or 403/404 error
|
||||
"""
|
||||
# First check if feedback exists globally (to distinguish 403 from 404)
|
||||
all_products = Product.get_all()
|
||||
feedback_data = None
|
||||
actual_product_id = None
|
||||
|
||||
for product in all_products:
|
||||
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
||||
if feedback_data:
|
||||
actual_product_id = product.product_id
|
||||
break
|
||||
|
||||
# If not found globally, return 404
|
||||
if not feedback_data:
|
||||
abort(404)
|
||||
|
||||
# Check if user has access to this product
|
||||
if not check_product_access(actual_product_id):
|
||||
abort(403)
|
||||
|
||||
# Load product info
|
||||
product = Product.get_by_id(actual_product_id)
|
||||
|
||||
return render_template(
|
||||
'dashboard/detail.html',
|
||||
feedback=feedback_data,
|
||||
product=product
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/feedback/<feedback_id>/status', methods=['POST'])
|
||||
@login_required
|
||||
def update_status(feedback_id):
|
||||
"""Update feedback status
|
||||
|
||||
Args:
|
||||
feedback_id: Feedback ID to update
|
||||
|
||||
Returns:
|
||||
Redirect to detail page or error
|
||||
"""
|
||||
new_status = request.form.get('status')
|
||||
|
||||
if not new_status:
|
||||
flash('Status is required', 'error')
|
||||
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
||||
|
||||
# Find feedback globally first
|
||||
all_products = Product.get_all()
|
||||
actual_product_id = None
|
||||
|
||||
for product in all_products:
|
||||
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
||||
if feedback_data:
|
||||
actual_product_id = product.product_id
|
||||
break
|
||||
|
||||
# If not found globally, return 404
|
||||
if not actual_product_id:
|
||||
abort(404)
|
||||
|
||||
# Check if user has access to this product
|
||||
if not check_product_access(actual_product_id):
|
||||
abort(403)
|
||||
|
||||
# Update status
|
||||
success = FeedbackStorageService.update_feedback_status_by_id(
|
||||
actual_product_id, feedback_id, new_status
|
||||
)
|
||||
|
||||
if success:
|
||||
flash(f'Status updated to {new_status}', 'success')
|
||||
else:
|
||||
flash('Failed to update status', 'error')
|
||||
|
||||
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
||||
|
||||
|
||||
@bp.route('/feedback/<feedback_id>/attachment/<filename>')
|
||||
@login_required
|
||||
def download_attachment(feedback_id, filename):
|
||||
"""Download attachment file
|
||||
|
||||
Args:
|
||||
feedback_id: Feedback ID
|
||||
filename: Attachment filename
|
||||
|
||||
Returns:
|
||||
File download or error
|
||||
"""
|
||||
# Find feedback globally first
|
||||
all_products = Product.get_all()
|
||||
actual_product_id = None
|
||||
|
||||
for product in all_products:
|
||||
feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id)
|
||||
if feedback_data:
|
||||
actual_product_id = product.product_id
|
||||
break
|
||||
|
||||
# If not found globally, return 404
|
||||
if not actual_product_id:
|
||||
abort(404)
|
||||
|
||||
# Check if user has access to this product
|
||||
if not check_product_access(actual_product_id):
|
||||
abort(403)
|
||||
|
||||
# Get attachment path
|
||||
attachment_path = FeedbackStorageService.get_attachment_path(
|
||||
actual_product_id, feedback_id, filename
|
||||
)
|
||||
|
||||
if not attachment_path:
|
||||
abort(404)
|
||||
|
||||
# Detect MIME type
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
if not mime_type:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
# Send file
|
||||
return send_file(
|
||||
attachment_path,
|
||||
mimetype=mime_type,
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Feedback Detail{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 900px; margin: 0 auto; padding: 20px;">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<a href="{{ url_for('dashboard.list') }}" style="color: #007bff; text-decoration: none;">← Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<h1>Feedback Detail</h1>
|
||||
|
||||
<!-- Metadata Section -->
|
||||
<div style="background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px;">
|
||||
<div>
|
||||
<strong>Feedback ID:</strong>
|
||||
<p style="margin: 5px 0; font-family: monospace; font-size: 0.9em;">{{ feedback.feedback_id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Product:</strong>
|
||||
<p style="margin: 5px 0;">{{ product.name if product else feedback.product_id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong>
|
||||
<p style="margin: 5px 0;">
|
||||
<span style="padding: 5px 10px; border-radius: 3px;
|
||||
{% if feedback.metadata.status == 'new' %}background: #d1ecf1; color: #0c5460;
|
||||
{% elif feedback.metadata.status == 'in_progress' %}background: #fff3cd; color: #856404;
|
||||
{% elif feedback.metadata.status == 'resolved' %}background: #d4edda; color: #155724;
|
||||
{% else %}background: #e2e3e5; color: #383d41;{% endif %}">
|
||||
{{ feedback.metadata.status }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Category:</strong>
|
||||
<p style="margin: 5px 0;">
|
||||
<span style="padding: 5px 10px; background: #e9ecef; border-radius: 3px;">
|
||||
{{ feedback.metadata.category or 'Uncategorized' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Language:</strong>
|
||||
<p style="margin: 5px 0;">{{ feedback.metadata.original_language or 'Unknown' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Submitted:</strong>
|
||||
<p style="margin: 5px 0;">{{ feedback.metadata.submitted_at[:19] if feedback.metadata.submitted_at else 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Status Form -->
|
||||
<div style="margin: 20px 0; padding: 15px; background: #fff3cd; border-radius: 5px;">
|
||||
<form method="post" action="{{ url_for('dashboard.update_status', feedback_id=feedback.feedback_id) }}" style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label for="status"><strong>Update Status:</strong></label>
|
||||
<select name="status" id="status" style="padding: 5px 10px; border: 1px solid #ccc; border-radius: 3px;">
|
||||
<option value="new" {% if feedback.metadata.status == 'new' %}selected{% endif %}>New</option>
|
||||
<option value="in_progress" {% if feedback.metadata.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="resolved" {% if feedback.metadata.status == 'resolved' %}selected{% endif %}>Resolved</option>
|
||||
<option value="closed" {% if feedback.metadata.status == 'closed' %}selected{% endif %}>Closed</option>
|
||||
</select>
|
||||
<button type="submit" style="padding: 5px 15px; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer;">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Original Content -->
|
||||
<div style="margin: 30px 0;">
|
||||
<h2>Original Feedback</h2>
|
||||
<div style="background: white; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px; white-space: pre-wrap;">{{ feedback.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Analysis (if exists) -->
|
||||
{% if feedback.analysis %}
|
||||
<div style="margin: 30px 0;">
|
||||
<h2>AI Analysis</h2>
|
||||
<div style="background: white; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px;">
|
||||
{{ feedback.analysis|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Attachments -->
|
||||
{% if feedback.attachments %}
|
||||
<div style="margin: 30px 0;">
|
||||
<h2>Attachments ({{ feedback.attachments|length }})</h2>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
{% for attachment in feedback.attachments %}
|
||||
<li style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-radius: 3px;">
|
||||
<a href="{{ url_for('dashboard.download_attachment', feedback_id=feedback.feedback_id, filename=attachment) }}"
|
||||
style="color: #007bff; text-decoration: none; display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.2em;">📎</span>
|
||||
<span>{{ attachment }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="margin: 30px 0;">
|
||||
<h2>Attachments</h2>
|
||||
<p style="color: #666;">No attachments</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Feedback Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 1200px; margin: 0 auto; padding: 20px;">
|
||||
<h1>Feedback Dashboard</h1>
|
||||
|
||||
<!-- Filters and Search -->
|
||||
<form method="get" action="{{ url_for('dashboard.list') }}" style="margin: 20px 0; padding: 15px; background: #f5f5f5; border-radius: 5px;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px;">
|
||||
<div>
|
||||
<label for="category">Category:</label>
|
||||
<select name="category" id="category">
|
||||
<option value="">All Categories</option>
|
||||
<option value="bug" {% if filters.category == 'bug' %}selected{% endif %}>Bug</option>
|
||||
<option value="feature_request" {% if filters.category == 'feature_request' %}selected{% endif %}>Feature Request</option>
|
||||
<option value="question" {% if filters.category == 'question' %}selected{% endif %}>Question</option>
|
||||
<option value="complaint" {% if filters.category == 'complaint' %}selected{% endif %}>Complaint</option>
|
||||
<option value="praise" {% if filters.category == 'praise' %}selected{% endif %}>Praise</option>
|
||||
<option value="other" {% if filters.category == 'other' %}selected{% endif %}>Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status">Status:</label>
|
||||
<select name="status" id="status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="new" {% if filters.status == 'new' %}selected{% endif %}>New</option>
|
||||
<option value="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="resolved" {% if filters.status == 'resolved' %}selected{% endif %}>Resolved</option>
|
||||
<option value="closed" {% if filters.status == 'closed' %}selected{% endif %}>Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="search">Search:</label>
|
||||
<input type="text" name="search" id="search" value="{{ filters.search or '' }}" placeholder="Search feedback...">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: flex-end; gap: 5px;">
|
||||
<button type="submit" style="padding: 8px 15px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">Filter</button>
|
||||
<a href="{{ url_for('dashboard.list') }}" style="padding: 8px 15px; background: #6c757d; color: white; text-decoration: none; border-radius: 3px; display: inline-block;">Clear</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<p style="margin: 10px 0; color: #666;">
|
||||
Showing {{ feedback_list|length }} of {{ total }} feedback items
|
||||
{% if filters.category or filters.status or filters.search %}
|
||||
(filtered)
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<!-- Feedback List -->
|
||||
{% if feedback_list %}
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
|
||||
<th style="padding: 12px; text-align: left;">ID</th>
|
||||
<th style="padding: 12px; text-align: left;">Product</th>
|
||||
<th style="padding: 12px; text-align: left;">Preview</th>
|
||||
<th style="padding: 12px; text-align: left;">Category</th>
|
||||
<th style="padding: 12px; text-align: left;">Status</th>
|
||||
<th style="padding: 12px; text-align: left;">Date</th>
|
||||
<th style="padding: 12px; text-align: left;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for feedback in feedback_list %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 12px; font-family: monospace; font-size: 0.9em;">
|
||||
{{ feedback.feedback_id[:8] }}...
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{{ product_names.get(feedback.product_id, feedback.product_id) }}
|
||||
</td>
|
||||
<td style="padding: 12px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ feedback.content_preview }}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<span style="padding: 3px 8px; background: #e9ecef; border-radius: 3px; font-size: 0.9em;">
|
||||
{{ feedback.category }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<span style="padding: 3px 8px; border-radius: 3px; font-size: 0.9em;
|
||||
{% if feedback.status == 'new' %}background: #d1ecf1; color: #0c5460;
|
||||
{% elif feedback.status == 'in_progress' %}background: #fff3cd; color: #856404;
|
||||
{% elif feedback.status == 'resolved' %}background: #d4edda; color: #155724;
|
||||
{% else %}background: #e2e3e5; color: #383d41;{% endif %}">
|
||||
{{ feedback.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px; font-size: 0.9em; color: #666;">
|
||||
{{ feedback.submitted_at[:10] if feedback.submitted_at else 'Unknown' }}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<a href="{{ url_for('dashboard.detail', feedback_id=feedback.feedback_id) }}"
|
||||
style="color: #007bff; text-decoration: none;">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if pages > 1 %}
|
||||
<div style="margin: 20px 0; text-align: center;">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('dashboard.list', page=page-1, category=filters.category, status=filters.status, search=filters.search) }}"
|
||||
style="padding: 8px 12px; margin: 0 2px; background: #007bff; color: white; text-decoration: none; border-radius: 3px;">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
<span style="padding: 8px 12px; margin: 0 5px;">Page {{ page }} of {{ pages }}</span>
|
||||
|
||||
{% if page < pages %}
|
||||
<a href="{{ url_for('dashboard.list', page=page+1, category=filters.category, status=filters.status, search=filters.search) }}"
|
||||
style="padding: 8px 12px; margin: 0 2px; background: #007bff; color: white; text-decoration: none; border-radius: 3px;">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p style="margin: 40px 0; text-align: center; color: #666;">
|
||||
No feedback found.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Access Denied{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 600px; margin: 100px auto; text-align: center;">
|
||||
<h1>403 - Access Denied</h1>
|
||||
<p>You do not have permission to access this resource.</p>
|
||||
<p>
|
||||
<a href="{{ url_for('dashboard.list') }}">Return to Dashboard</a> |
|
||||
<a href="{{ url_for('index') }}">Go to Home</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Not Found{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 600px; margin: 100px auto; text-align: center;">
|
||||
<h1>404 - Not Found</h1>
|
||||
<p>The page or resource you requested could not be found.</p>
|
||||
<p>
|
||||
<a href="{{ url_for('dashboard.list') }}">Return to Dashboard</a> |
|
||||
<a href="{{ url_for('index') }}">Go to Home</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user