From f7f225ad09c5b1bfe1fe11d8bcea9c274e5da49d Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Fri, 17 Oct 2025 14:54:51 +0200 Subject: [PATCH] Implement product selection landing page (Feature 002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds landing page at root URL (/) that displays all active products with links to feedback submission forms. This replaces the requirement for users to know direct product URLs. Changes: - Added Product.load_active() method to filter and sort active products alphabetically - Created landing route blueprint with error handling and structured logging - Registered landing blueprint in app factory, replacing old index route - Created landing page template with product list and empty state - Added comprehensive contract tests (6 tests) covering active products, filtering, sorting, XSS prevention - Added integration test for complete user flow from landing page to submission form All 7 tests pass. User Story 1 (P1 - MVP) complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/__init__.py | 10 +- app/models/product.py | 16 +++ app/routes/landing.py | 29 +++++ app/templates/landing/index.html | 37 ++++++ specs/002-product-list/tasks.md | 48 +++---- tests/contract/test_landing_routes.py | 166 +++++++++++++++++++++++++ tests/integration/test_landing_flow.py | 57 +++++++++ 7 files changed, 331 insertions(+), 32 deletions(-) create mode 100644 app/routes/landing.py create mode 100644 app/templates/landing/index.html create mode 100644 tests/contract/test_landing_routes.py create mode 100644 tests/integration/test_landing_flow.py diff --git a/app/__init__.py b/app/__init__.py index 41b90a0..dbf65b8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -182,18 +182,12 @@ def create_app(config_name='development'): ) # Register blueprints - from app.routes import submission, dashboard, admin, auth + from app.routes import submission, dashboard, admin, auth, landing app.register_blueprint(submission.bp) app.register_blueprint(dashboard.bp) app.register_blueprint(admin.bp) app.register_blueprint(auth.bp) - - # Set index route - @app.route('/') - def index(): - """Welcome page""" - from flask import render_template - return render_template('index.html') + app.register_blueprint(landing.landing_bp) # Landing page (product selection) # Health check endpoint (T208) @app.route('/health') diff --git a/app/models/product.py b/app/models/product.py index 038da4b..7fe4bc1 100644 --- a/app/models/product.py +++ b/app/models/product.py @@ -159,6 +159,22 @@ class Product: return products + @classmethod + def load_active(cls): + """Load all active products, sorted alphabetically by name then product_id + + Returns: + list[Product]: Active products with valid submission_url_slug, sorted by: + 1. name (case-insensitive alphabetical) + 2. product_id (alphabetical) as tiebreaker + + Products with missing/invalid submission_url_slug are excluded. + """ + all_products = cls.get_all() + active = [p for p in all_products + if p.status == 'active' and p.submission_url_slug] + return sorted(active, key=lambda p: (p.name.lower(), p.product_id)) + def save(self): """Save product to filesystem""" product_dir = self._get_product_dir(self.product_id) diff --git a/app/routes/landing.py b/app/routes/landing.py new file mode 100644 index 0000000..dba197b --- /dev/null +++ b/app/routes/landing.py @@ -0,0 +1,29 @@ +"""Landing page route - product selection""" +from flask import Blueprint, render_template, current_app +from app.models.product import Product + +landing_bp = Blueprint('landing', __name__) + + +@landing_bp.route('/') +def index(): + """Landing page showing all active products for feedback submission + + Returns: + Rendered HTML template with: + - List of active products (if any) + - Empty state message (if no active products) + """ + try: + products = Product.load_active() + current_app.logger.info( + f'Landing page accessed: {len(products)} active products available' + ) + return render_template('landing/index.html', products=products) + except Exception as e: + current_app.logger.error( + f'Error loading landing page: {e}', + exc_info=True + ) + # Graceful degradation - show empty product list + return render_template('landing/index.html', products=[]) diff --git a/app/templates/landing/index.html b/app/templates/landing/index.html new file mode 100644 index 0000000..5b34c1a --- /dev/null +++ b/app/templates/landing/index.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}Select Product - Reklamator{% endblock %} + +{% block content %} +

Submit Feedback

+ +{% if products %} +

Select a product to share your feedback, report issues, or suggest improvements.

+ +
+ {% for product in products %} +
+

+ {{ product.name }} +

+ + {% if product.description %} +

{{ product.description }}

+ {% endif %} + + + Submit Feedback + +
+ {% endfor %} +
+{% else %} +
+

+ No products are currently accepting feedback. Please check back later. +

+
+{% endif %} +{% endblock %} diff --git a/specs/002-product-list/tasks.md b/specs/002-product-list/tasks.md index 559cf37..a47d707 100644 --- a/specs/002-product-list/tasks.md +++ b/specs/002-product-list/tasks.md @@ -30,7 +30,7 @@ **⚠️ CRITICAL**: User Story 1 depends on this extension -- [ ] T001 [US1] Extend Product model with load_active() class method in app/models/product.py +- [X] T001 [US1] Extend Product model with load_active() class method in app/models/product.py **Checkpoint**: Product.load_active() method ready - User Story 1 implementation can begin @@ -44,21 +44,21 @@ ### Tests for User Story 1 (TDD - Write FIRST, ensure FAIL) -- [ ] T002 [P] [US1] Contract test: GET / with active products returns 200 with product list HTML in tests/contract/test_landing_routes.py -- [ ] T003 [P] [US1] Contract test: GET / with no active products returns 200 with empty state message in tests/contract/test_landing_routes.py -- [ ] T004 [P] [US1] Contract test: GET / excludes archived products in tests/contract/test_landing_routes.py -- [ ] T005 [P] [US1] Contract test: GET / sorts products alphabetically (name, then product_id) in tests/contract/test_landing_routes.py -- [ ] T006 [P] [US1] Contract test: GET / escapes HTML in product names (XSS prevention) in tests/contract/test_landing_routes.py -- [ ] T007 [P] [US1] Contract test: GET / excludes products with missing submission_url_slug in tests/contract/test_landing_routes.py -- [ ] T008 [US1] Integration test: Complete flow - landing page → click product → submission form in tests/integration/test_landing_flow.py +- [X] T002 [P] [US1] Contract test: GET / with active products returns 200 with product list HTML in tests/contract/test_landing_routes.py +- [X] T003 [P] [US1] Contract test: GET / with no active products returns 200 with empty state message in tests/contract/test_landing_routes.py +- [X] T004 [P] [US1] Contract test: GET / excludes archived products in tests/contract/test_landing_routes.py +- [X] T005 [P] [US1] Contract test: GET / sorts products alphabetically (name, then product_id) in tests/contract/test_landing_routes.py +- [X] T006 [P] [US1] Contract test: GET / escapes HTML in product names (XSS prevention) in tests/contract/test_landing_routes.py +- [X] T007 [P] [US1] Contract test: GET / excludes products with missing submission_url_slug in tests/contract/test_landing_routes.py +- [X] T008 [US1] Integration test: Complete flow - landing page → click product → submission form in tests/integration/test_landing_flow.py ### Implementation for User Story 1 -- [ ] T009 [US1] Create landing route blueprint in app/routes/landing.py -- [ ] T010 [US1] Register landing blueprint in app/__init__.py -- [ ] T011 [US1] Create landing page template with product list in app/templates/landing/index.html -- [ ] T012 [US1] Add logging for landing page access in app/routes/landing.py -- [ ] T013 [US1] Verify all tests pass for User Story 1 +- [X] T009 [US1] Create landing route blueprint in app/routes/landing.py +- [X] T010 [US1] Register landing blueprint in app/__init__.py +- [X] T011 [US1] Create landing page template with product list in app/templates/landing/index.html +- [X] T012 [US1] Add logging for landing page access in app/routes/landing.py +- [X] T013 [US1] Verify all tests pass for User Story 1 **Checkpoint**: User Story 1 complete and independently testable. MVP ready for demo/deploy. @@ -86,8 +86,8 @@ ### Verification for User Story 3 -- [ ] T014 [US3] Manual test: Verify direct URL `/submit/{slug}` still works without landing page interference -- [ ] T015 [US3] Manual test: Verify alphabetical sorting helps users find products efficiently on landing page +- [X] T014 [US3] Manual test: Verify direct URL `/submit/{slug}` still works without landing page interference +- [X] T015 [US3] Manual test: Verify alphabetical sorting helps users find products efficiently on landing page **Checkpoint**: Backwards compatibility confirmed. All 3 user stories validated. @@ -97,15 +97,15 @@ **Purpose**: Final validations and quality checks -- [ ] T016 [P] Manual verification: Visit `/` with 0 active products - see empty state message -- [ ] T017 [P] Manual verification: Visit `/` with 1 active product - see single product listed -- [ ] T018 [P] Manual verification: Visit `/` with 10+ active products - verify alphabetical order -- [ ] T019 [P] Manual verification: Check product with no description - verify no placeholder text shown -- [ ] T020 [P] Manual verification: Check product with long name - verify proper text wrapping -- [ ] T021 [P] Manual verification: Access `/` as anonymous user - page accessible -- [ ] T022 [P] Manual verification: Access `/` as authenticated user - same page shown (no redirect) -- [ ] T023 [P] Manual verification: View page source - confirm no JavaScript present -- [ ] T024 [P] Performance verification: Load landing page with 100 products - confirm <1 second load time +- [X] T016 [P] Manual verification: Visit `/` with 0 active products - see empty state message +- [X] T017 [P] Manual verification: Visit `/` with 1 active product - see single product listed +- [X] T018 [P] Manual verification: Visit `/` with 10+ active products - verify alphabetical order +- [X] T019 [P] Manual verification: Check product with no description - verify no placeholder text shown +- [X] T020 [P] Manual verification: Check product with long name - verify proper text wrapping +- [X] T021 [P] Manual verification: Access `/` as anonymous user - page accessible +- [X] T022 [P] Manual verification: Access `/` as authenticated user - same page shown (no redirect) +- [X] T023 [P] Manual verification: View page source - confirm no JavaScript present +- [X] T024 [P] Performance verification: Load landing page with 100 products - confirm <1 second load time - [ ] T025 Commit all changes with descriptive message --- diff --git a/tests/contract/test_landing_routes.py b/tests/contract/test_landing_routes.py new file mode 100644 index 0000000..3d979b8 --- /dev/null +++ b/tests/contract/test_landing_routes.py @@ -0,0 +1,166 @@ +"""Contract tests for landing page routes""" +import pytest +import os +import yaml + + +@pytest.fixture +def test_products(app): + """Create test products with various configurations""" + with app.app_context(): + products_dir = os.path.join(app.config['DATA_DIR'], 'products') + + # Product 1: Active with description + product1_dir = os.path.join(products_dir, 'product-001') + os.makedirs(product1_dir, exist_ok=True) + with open(os.path.join(product1_dir, 'config.yaml'), 'w') as f: + yaml.dump({ + 'product_id': 'product-001', + 'name': 'Zebra Product', + 'submission_url_slug': 'zebra-product', + 'owner_language': 'en', + 'assigned_owner_ids': [], + 'status': 'active', + 'description': 'A product for testing' + }, f) + + # Product 2: Active without description + product2_dir = os.path.join(products_dir, 'product-002') + os.makedirs(product2_dir, exist_ok=True) + with open(os.path.join(product2_dir, 'config.yaml'), 'w') as f: + yaml.dump({ + 'product_id': 'product-002', + 'name': 'Apple Product', + 'submission_url_slug': 'apple-product', + 'owner_language': 'en', + 'assigned_owner_ids': [], + 'status': 'active' + }, f) + + # Product 3: Archived (should not appear) + product3_dir = os.path.join(products_dir, 'product-003') + os.makedirs(product3_dir, exist_ok=True) + with open(os.path.join(product3_dir, 'config.yaml'), 'w') as f: + yaml.dump({ + 'product_id': 'product-003', + 'name': 'Archived Product', + 'submission_url_slug': 'archived-product', + 'owner_language': 'en', + 'assigned_owner_ids': [], + 'status': 'archived', + 'description': 'This product is archived' + }, f) + + # Product 4: Active but missing slug (should not appear) + product4_dir = os.path.join(products_dir, 'product-004') + os.makedirs(product4_dir, exist_ok=True) + with open(os.path.join(product4_dir, 'config.yaml'), 'w') as f: + yaml.dump({ + 'product_id': 'product-004', + 'name': 'No Slug Product', + 'submission_url_slug': '', + 'owner_language': 'en', + 'assigned_owner_ids': [], + 'status': 'active', + 'description': 'Product with missing slug' + }, f) + + # Product 5: XSS test product + product5_dir = os.path.join(products_dir, 'product-005') + os.makedirs(product5_dir, exist_ok=True) + with open(os.path.join(product5_dir, 'config.yaml'), 'w') as f: + yaml.dump({ + 'product_id': 'product-005', + 'name': 'Evil Product', + 'submission_url_slug': 'xss-product', + 'owner_language': 'en', + 'assigned_owner_ids': [], + 'status': 'active', + 'description': 'Malicious description' + }, f) + + yield + + +@pytest.mark.contract +def test_get_landing_page_with_products(client, test_products): + """T002: GET / with active products returns 200 with product list HTML""" + response = client.get('/') + + assert response.status_code == 200 + assert b'' not in html, "Script tag not escaped in product name" + assert 'alert("xss")' not in html or '<script>' in html, "XSS vulnerability in product name" + + # Image onerror should be escaped + assert '