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:
@@ -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