From adbfd23c2694e951d31b7b795ab3dd8ef3420e86 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 16 Oct 2025 20:11:16 +0200 Subject: [PATCH] Implement Phase 5: Product Owner Dashboard (User Story 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ detail view with role-based access control - Implement POST /feedback//status for status updates - Implement GET /feedback//attachment/ 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 --- app/__init__.py | 13 + app/models/feedback.py | 2 +- app/routes/auth.py | 11 +- app/routes/dashboard.py | 246 ++++++++++++- app/services/feedback_storage.py | 305 +++++++++++++++++ app/templates/dashboard/detail.html | 109 ++++++ app/templates/dashboard/list.html | 131 +++++++ app/templates/error_403.html | 14 + app/templates/error_404.html | 14 + tests/contract/test_dashboard_routes.py | 324 ++++++++++++++++++ .../integration/test_dashboard_access_flow.py | 247 +++++++++++++ 11 files changed, 1403 insertions(+), 13 deletions(-) create mode 100644 app/templates/dashboard/detail.html create mode 100644 app/templates/dashboard/list.html create mode 100644 app/templates/error_403.html create mode 100644 app/templates/error_404.html create mode 100644 tests/contract/test_dashboard_routes.py create mode 100644 tests/integration/test_dashboard_access_flow.py diff --git a/app/__init__.py b/app/__init__.py index 588a1a2..b0942b4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/models/feedback.py b/app/models/feedback.py index 672ebe3..9b3daac 100644 --- a/app/models/feedback.py +++ b/app/models/feedback.py @@ -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, diff --git a/app/routes/auth.py b/app/routes/auth.py index cc8e40d..07634ef 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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') diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 3e8327d..d7c46dd 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -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/') +@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//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//attachment/') +@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 + ) diff --git a/app/services/feedback_storage.py b/app/services/feedback_storage.py index 89d855d..f4da459 100644 --- a/app/services/feedback_storage.py +++ b/app/services/feedback_storage.py @@ -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 diff --git a/app/templates/dashboard/detail.html b/app/templates/dashboard/detail.html new file mode 100644 index 0000000..02d372e --- /dev/null +++ b/app/templates/dashboard/detail.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block title %}Feedback Detail{% endblock %} + +{% block content %} +
+ + +

Feedback Detail

+ + +
+
+
+ Feedback ID: +

{{ feedback.feedback_id }}

+
+
+ Product: +

{{ product.name if product else feedback.product_id }}

+
+
+ Status: +

+ + {{ feedback.metadata.status }} + +

+
+
+ Category: +

+ + {{ feedback.metadata.category or 'Uncategorized' }} + +

+
+
+ Language: +

{{ feedback.metadata.original_language or 'Unknown' }}

+
+
+ Submitted: +

{{ feedback.metadata.submitted_at[:19] if feedback.metadata.submitted_at else 'Unknown' }}

+
+
+
+ + +
+
+ + + + +
+
+ + +
+

Original Feedback

+
{{ feedback.content }}
+
+ + + {% if feedback.analysis %} +
+

AI Analysis

+
+ {{ feedback.analysis|safe }} +
+
+ {% endif %} + + + {% if feedback.attachments %} +
+

Attachments ({{ feedback.attachments|length }})

+ +
+ {% else %} +
+

Attachments

+

No attachments

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/dashboard/list.html b/app/templates/dashboard/list.html new file mode 100644 index 0000000..76f86ca --- /dev/null +++ b/app/templates/dashboard/list.html @@ -0,0 +1,131 @@ +{% extends "base.html" %} + +{% block title %}Feedback Dashboard{% endblock %} + +{% block content %} +
+

Feedback Dashboard

+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Clear +
+
+
+ + +

+ Showing {{ feedback_list|length }} of {{ total }} feedback items + {% if filters.category or filters.status or filters.search %} + (filtered) + {% endif %} +

+ + + {% if feedback_list %} + + + + + + + + + + + + + + {% for feedback in feedback_list %} + + + + + + + + + + {% endfor %} + +
IDProductPreviewCategoryStatusDateActions
+ {{ feedback.feedback_id[:8] }}... + + {{ product_names.get(feedback.product_id, feedback.product_id) }} + + {{ feedback.content_preview }} + + + {{ feedback.category }} + + + + {{ feedback.status }} + + + {{ feedback.submitted_at[:10] if feedback.submitted_at else 'Unknown' }} + + View +
+ + + {% if pages > 1 %} +
+ {% if page > 1 %} + Previous + {% endif %} + + Page {{ page }} of {{ pages }} + + {% if page < pages %} + Next + {% endif %} +
+ {% endif %} + + {% else %} +

+ No feedback found. +

+ {% endif %} +
+{% endblock %} diff --git a/app/templates/error_403.html b/app/templates/error_403.html new file mode 100644 index 0000000..4777bb3 --- /dev/null +++ b/app/templates/error_403.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Access Denied{% endblock %} + +{% block content %} +
+

403 - Access Denied

+

You do not have permission to access this resource.

+

+ Return to Dashboard | + Go to Home +

+
+{% endblock %} diff --git a/app/templates/error_404.html b/app/templates/error_404.html new file mode 100644 index 0000000..43ac8d8 --- /dev/null +++ b/app/templates/error_404.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Not Found{% endblock %} + +{% block content %} +
+

404 - Not Found

+

The page or resource you requested could not be found.

+

+ Return to Dashboard | + Go to Home +

+
+{% endblock %} diff --git a/tests/contract/test_dashboard_routes.py b/tests/contract/test_dashboard_routes.py new file mode 100644 index 0000000..9061c97 --- /dev/null +++ b/tests/contract/test_dashboard_routes.py @@ -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'