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
|
from flask import render_template
|
||||||
return render_template('index.html')
|
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
|
return app
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class Feedback:
|
|||||||
category: Feedback category (set during analysis)
|
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',
|
def __init__(self, feedback_id, product_id, submitted_at=None, status='new',
|
||||||
content_preview='', has_attachments=False, attachment_count=0,
|
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
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint('auth', __name__, url_prefix='/auth')
|
bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/login', methods=['GET', 'POST'])
|
@bp.route('/login', methods=['GET', 'POST'])
|
||||||
@@ -28,13 +28,8 @@ def login():
|
|||||||
login_user(user)
|
login_user(user)
|
||||||
flash(f'Welcome back, {user.username}!', 'success')
|
flash(f'Welcome back, {user.username}!', 'success')
|
||||||
|
|
||||||
# TODO: Redirect based on role when dashboards are implemented
|
# Redirect to dashboard for product owners and administrators
|
||||||
# For now, redirect to index page
|
return redirect(url_for('dashboard.list'))
|
||||||
# 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'))
|
|
||||||
else:
|
else:
|
||||||
flash('Invalid username or password', 'error')
|
flash('Invalid username or password', 'error')
|
||||||
|
|
||||||
|
|||||||
+242
-4
@@ -1,9 +1,247 @@
|
|||||||
"""Dashboard routes - product owner feedback management"""
|
"""Dashboard routes - product owner feedback management"""
|
||||||
from flask import Blueprint
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, abort
|
||||||
from flask_login import login_required
|
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"""
|
"""Feedback storage service"""
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import yaml
|
||||||
|
from datetime import datetime
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
from app.utils.file_validator import get_safe_filename
|
from app.utils.file_validator import get_safe_filename
|
||||||
@@ -166,3 +168,306 @@ class FeedbackStorageService:
|
|||||||
|
|
||||||
if os.path.exists(feedback_dir):
|
if os.path.exists(feedback_dir):
|
||||||
shutil.rmtree(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 %}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user