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:
2025-10-16 20:11:16 +02:00
co-authored by Claude
parent 0b15d8e3bc
commit adbfd23c26
11 changed files with 1403 additions and 13 deletions
+13
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
)
+305
View File
@@ -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
+109
View File
@@ -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 %}
+131
View File
@@ -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 %}
+14
View File
@@ -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 %}
+14
View File
@@ -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 %}
+324
View File
@@ -0,0 +1,324 @@
"""Contract tests for dashboard routes"""
import pytest
import os
import yaml
import io
from app.models.user import User
@pytest.fixture
def test_product(app):
"""Create a test product with feedback"""
with app.app_context():
# Create test product directory and config
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'test-product')
os.makedirs(product_dir, exist_ok=True)
# Create product config
config_file = os.path.join(product_dir, 'config.yaml')
config_data = {
'product_id': 'test-product',
'name': 'Test Product',
'submission_url_slug': 'test-product',
'owner_language': 'en',
'assigned_owner_ids': ['usr_owner1'],
'status': 'active'
}
with open(config_file, 'w') as f:
yaml.dump(config_data, f)
# Create feedback directory
feedback_dir = os.path.join(product_dir, 'feedback')
os.makedirs(feedback_dir, exist_ok=True)
# Create test feedback
feedback_id = 'test-feedback-001'
feedback_path = os.path.join(feedback_dir, feedback_id)
os.makedirs(feedback_path, exist_ok=True)
# Create feedback metadata
metadata = {
'feedback_id': feedback_id,
'product_id': 'test-product',
'status': 'new',
'submitted_at': '2025-10-16T10:00:00Z',
'has_attachments': True,
'attachment_count': 1,
'category': 'bug',
'original_language': 'en'
}
with open(os.path.join(feedback_path, 'metadata.yaml'), 'w') as f:
yaml.dump(metadata, f)
# Create feedback content
with open(os.path.join(feedback_path, 'content.txt'), 'w') as f:
f.write('Test feedback content')
# Create attachments directory and file
attachments_dir = os.path.join(feedback_path, 'attachments')
os.makedirs(attachments_dir, exist_ok=True)
with open(os.path.join(attachments_dir, 'test.txt'), 'w') as f:
f.write('test attachment content')
yield {
'product_id': 'test-product',
'feedback_id': feedback_id
}
@pytest.fixture
def test_users(app):
"""Create test users (admin and product owner)"""
users_file = os.path.join(app.config['DATA_DIR'], 'users.yaml')
# User model expects format: {'users': {user_id: user_data}}
users_data = {
'users': {
'usr_admin': {
'user_id': 'usr_admin',
'username': 'admin',
'email': 'admin@example.com',
'password_hash': User.hash_password('admin123'),
'role': 'administrator',
'product_ids': [],
'is_active': True
},
'usr_owner1': {
'user_id': 'usr_owner1',
'username': 'owner1',
'email': 'owner1@example.com',
'password_hash': User.hash_password('owner123'),
'role': 'product_owner',
'product_ids': ['test-product'],
'is_active': True
},
'usr_owner2': {
'user_id': 'usr_owner2',
'username': 'owner2',
'email': 'owner2@example.com',
'password_hash': User.hash_password('owner456'),
'role': 'product_owner',
'product_ids': ['other-product'],
'is_active': True
}
}
}
with open(users_file, 'w') as f:
yaml.dump(users_data, f)
yield users_data
@pytest.mark.contract
def test_get_login(client):
"""T093: Contract test for GET /login
Expected: 200 OK with HTML login form
"""
response = client.get('/login')
assert response.status_code == 200
assert b'<form' in response.data
assert b'username' in response.data.lower() or b'email' in response.data.lower()
assert b'password' in response.data.lower()
@pytest.mark.contract
def test_post_login_valid_credentials(client, app, test_users):
"""T094: Contract test for POST /login with valid credentials
Expected: 302 redirect to dashboard with session established
"""
data = {
'username': 'admin',
'password': 'admin123'
}
response = client.post('/login', data=data, follow_redirects=False)
# Should redirect (302) to dashboard or home
assert response.status_code == 302
# Follow redirect and verify user is logged in
response_redirected = client.get(response.location, follow_redirects=True)
assert response_redirected.status_code == 200
@pytest.mark.contract
def test_post_login_invalid_credentials(client, app, test_users):
"""T095: Contract test for POST /login with invalid credentials (401)
Expected: 401 Unauthorized or redirect back to login with error message
"""
data = {
'username': 'admin',
'password': 'wrongpassword'
}
response = client.post('/login', data=data)
# Should return error (401 or 200 with error message)
assert response.status_code in [200, 401]
if response.status_code == 200:
# If returns 200, should show error message
assert b'invalid' in response.data.lower() or b'incorrect' in response.data.lower() or b'error' in response.data.lower()
@pytest.mark.contract
def test_get_logout(client, app, test_users):
"""T096: Contract test for GET /logout
Expected: 302 redirect to login or home, session cleared
"""
# First login
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
# Then logout
response = client.get('/logout', follow_redirects=False)
assert response.status_code == 302
@pytest.mark.contract
def test_get_dashboard_authenticated(client, app, test_users, test_product):
"""T097: Contract test for GET /dashboard (authenticated)
Expected: 200 OK with dashboard showing feedback list
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
response = client.get('/dashboard')
assert response.status_code == 200
assert b'feedback' in response.data.lower() or b'dashboard' in response.data.lower()
@pytest.mark.contract
def test_get_dashboard_unauthenticated(client):
"""T098: Contract test for GET /dashboard (unauthenticated redirect)
Expected: 302 redirect to login page
"""
response = client.get('/dashboard', follow_redirects=False)
# Should redirect to login
assert response.status_code == 302
assert '/login' in response.location
@pytest.mark.contract
def test_get_dashboard_with_filters(client, app, test_users, test_product):
"""T099: Contract test for GET /dashboard with filters
Expected: 200 OK with filtered feedback list
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
# Request with filters
response = client.get('/dashboard?category=bug&status=new')
assert response.status_code == 200
@pytest.mark.contract
def test_get_dashboard_with_search(client, app, test_users, test_product):
"""T100: Contract test for GET /dashboard with search query
Expected: 200 OK with search results
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
# Request with search query
response = client.get('/dashboard?search=test')
assert response.status_code == 200
@pytest.mark.contract
def test_get_feedback_detail(client, app, test_users, test_product):
"""T101: Contract test for GET /feedback/{id} detail view
Expected: 200 OK with feedback detail page showing content, metadata, attachments
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
feedback_id = test_product['feedback_id']
response = client.get(f'/feedback/{feedback_id}')
assert response.status_code == 200
assert b'Test feedback content' in response.data or b'feedback' in response.data.lower()
@pytest.mark.contract
def test_post_feedback_status_update(client, app, test_users, test_product):
"""T102: Contract test for POST /feedback/{id}/status update
Expected: 200/302 success, metadata.yaml updated with new status
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
feedback_id = test_product['feedback_id']
data = {
'status': 'in_progress'
}
response = client.post(f'/feedback/{feedback_id}/status', data=data)
# Should succeed
assert response.status_code in [200, 302]
@pytest.mark.contract
def test_get_attachment_download(client, app, test_users, test_product):
"""T103: Contract test for GET /feedback/{id}/attachment/{filename} download
Expected: 200 OK with file content, correct Content-Disposition header
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
feedback_id = test_product['feedback_id']
response = client.get(f'/feedback/{feedback_id}/attachment/test.txt')
assert response.status_code == 200
assert b'test attachment content' in response.data
# Should have download headers
assert 'Content-Disposition' in response.headers or 'content-disposition' in response.headers
@pytest.mark.contract
def test_access_control_owner_products(client, app, test_users, test_product):
"""T104: Contract test for access control (owner sees only assigned products)
Expected: Product owner can only access feedback for their assigned products
"""
# Login as owner1 (has access to test-product)
client.post('/login', data={'username': 'owner1', 'password': 'owner123'})
feedback_id = test_product['feedback_id']
# Should have access to feedback from test-product
response = client.get(f'/feedback/{feedback_id}')
assert response.status_code == 200
# Logout
client.get('/logout')
# Login as owner2 (only has access to other-product)
client.post('/login', data={'username': 'owner2', 'password': 'owner456'})
# Should NOT have access to feedback from test-product
response = client.get(f'/feedback/{feedback_id}')
assert response.status_code == 403 # Forbidden
@@ -0,0 +1,247 @@
"""Integration test for complete dashboard access flow"""
import pytest
import os
import yaml
from app.models.user import User
@pytest.fixture
def test_product_with_feedback(app):
"""Create a test product with multiple feedback items"""
with app.app_context():
# Create test product directory and config
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'dashboard-test-product')
os.makedirs(product_dir, exist_ok=True)
# Create product config
config_file = os.path.join(product_dir, 'config.yaml')
config_data = {
'product_id': 'dashboard-test-product',
'name': 'Dashboard Test Product',
'submission_url_slug': 'dashboard-test-product',
'owner_language': 'en',
'assigned_owner_ids': ['usr_dashboard_owner'],
'status': 'active'
}
with open(config_file, 'w') as f:
yaml.dump(config_data, f)
# Create feedback directory
feedback_dir = os.path.join(product_dir, 'feedback')
os.makedirs(feedback_dir, exist_ok=True)
# Create multiple test feedback items
feedback_items = [
{
'id': 'feedback-bug-001',
'category': 'bug',
'status': 'new',
'content': 'Found a critical bug in the login system',
'has_attachments': True,
'attachment': 'bug-screenshot.png'
},
{
'id': 'feedback-feature-001',
'category': 'feature_request',
'status': 'new',
'content': 'Please add dark mode to the application',
'has_attachments': False,
'attachment': None
},
{
'id': 'feedback-bug-002',
'category': 'bug',
'status': 'in_progress',
'content': 'Error when uploading large files',
'has_attachments': True,
'attachment': 'error.log'
}
]
for item in feedback_items:
feedback_path = os.path.join(feedback_dir, item['id'])
os.makedirs(feedback_path, exist_ok=True)
# Create metadata
metadata = {
'feedback_id': item['id'],
'product_id': 'dashboard-test-product',
'status': item['status'],
'submitted_at': '2025-10-16T10:00:00Z',
'has_attachments': item['has_attachments'],
'attachment_count': 1 if item['has_attachments'] else 0,
'category': item['category'],
'original_language': 'en'
}
with open(os.path.join(feedback_path, 'metadata.yaml'), 'w') as f:
yaml.dump(metadata, f)
# Create content
with open(os.path.join(feedback_path, 'content.txt'), 'w') as f:
f.write(item['content'])
# Create attachment if needed
if item['has_attachments']:
attachments_dir = os.path.join(feedback_path, 'attachments')
os.makedirs(attachments_dir, exist_ok=True)
with open(os.path.join(attachments_dir, item['attachment']), 'w') as f:
f.write(f'Attachment content for {item["id"]}')
yield {
'product_id': 'dashboard-test-product',
'feedback_items': feedback_items
}
@pytest.fixture
def dashboard_test_users(app):
"""Create test users for dashboard testing"""
users_file = os.path.join(app.config['DATA_DIR'], 'users.yaml')
# User model expects format: {'users': {user_id: user_data}}
users_data = {
'users': {
'usr_dashboard_owner': {
'user_id': 'usr_dashboard_owner',
'username': 'dashboard_owner',
'email': 'dashboard@example.com',
'password_hash': User.hash_password('dashboard123'),
'role': 'product_owner',
'product_ids': ['dashboard-test-product'],
'is_active': True
}
}
}
with open(users_file, 'w') as f:
yaml.dump(users_data, f)
yield users_data
@pytest.mark.integration
def test_complete_dashboard_access_flow(client, app, test_product_with_feedback, dashboard_test_users):
"""T105: Integration test for complete dashboard access flow
Test the entire product owner journey:
1. Owner logs in with credentials
2. Owner views dashboard with feedback list
3. Owner filters feedback by category
4. Owner searches for specific feedback
5. Owner views feedback detail
6. Owner downloads attachment
7. Owner updates feedback status
8. Owner logs out
"""
# Step 1: Login as product owner
login_response = client.post('/login', data={
'username': 'dashboard_owner',
'password': 'dashboard123'
}, follow_redirects=False)
assert login_response.status_code == 302 # Redirect after successful login
# Step 2: View dashboard with feedback list
dashboard_response = client.get('/dashboard')
assert dashboard_response.status_code == 200
assert b'feedback' in dashboard_response.data.lower() or b'dashboard' in dashboard_response.data.lower()
# Verify feedback items are shown
# (At least should show some feedback indicators)
# Step 3: Filter feedback by category (bug)
filter_response = client.get('/dashboard?category=bug')
assert filter_response.status_code == 200
# Step 4: Search for specific feedback
search_response = client.get('/dashboard?search=login')
assert search_response.status_code == 200
# Step 5: View feedback detail
feedback_id = test_product_with_feedback['feedback_items'][0]['id']
detail_response = client.get(f'/feedback/{feedback_id}')
assert detail_response.status_code == 200
# Should show the feedback content
assert b'Found a critical bug in the login system' in detail_response.data or b'feedback' in detail_response.data.lower()
# Step 6: Download attachment
attachment_response = client.get(f'/feedback/{feedback_id}/attachment/bug-screenshot.png')
assert attachment_response.status_code == 200
assert b'Attachment content' in attachment_response.data
# Should have download headers
assert 'Content-Disposition' in attachment_response.headers or 'content-disposition' in attachment_response.headers
# Step 7: Update feedback status
status_update_response = client.post(f'/feedback/{feedback_id}/status', data={
'status': 'in_progress'
}, follow_redirects=False)
assert status_update_response.status_code in [200, 302]
# Verify status was updated in filesystem
with app.app_context():
data_dir = app.config['DATA_DIR']
metadata_file = os.path.join(
data_dir,
'products',
'dashboard-test-product',
'feedback',
feedback_id,
'metadata.yaml'
)
with open(metadata_file, 'r') as f:
metadata = yaml.safe_load(f)
assert metadata['status'] == 'in_progress'
# Step 8: Logout
logout_response = client.get('/logout', follow_redirects=False)
assert logout_response.status_code == 302
# Verify user is logged out (accessing dashboard should redirect to login)
protected_response = client.get('/dashboard', follow_redirects=False)
assert protected_response.status_code == 302
assert '/login' in protected_response.location
@pytest.mark.integration
def test_dashboard_access_control_enforcement(client, app, test_product_with_feedback, dashboard_test_users):
"""Integration test for access control - owner can only see assigned products"""
# Create another product owner with different product access
users_file = os.path.join(app.config['DATA_DIR'], 'users.yaml')
with open(users_file, 'r') as f:
users_data = yaml.safe_load(f)
# Add new user to the users dict
users_data['users']['usr_other_owner'] = {
'user_id': 'usr_other_owner',
'username': 'other_owner',
'email': 'other@example.com',
'password_hash': User.hash_password('other123'),
'role': 'product_owner',
'product_ids': ['different-product'],
'is_active': True
}
with open(users_file, 'w') as f:
yaml.dump(users_data, f)
# Login as owner without access to dashboard-test-product
client.post('/login', data={
'username': 'other_owner',
'password': 'other123'
})
# Try to access feedback from product they don't own
feedback_id = test_product_with_feedback['feedback_items'][0]['id']
response = client.get(f'/feedback/{feedback_id}')
# Should be denied access (403 Forbidden)
assert response.status_code == 403