From 5fd3121efb50bbe9c55651435b37a9332eb5c133 Mon Sep 17 00:00:00 2001 From: gazdagergo Date: Fri, 20 Mar 2026 13:57:40 +0100 Subject: [PATCH 01/31] Add interactive service layer documentation for CSV upload Add admin-only developer tool page at /backoffice/dev/service-docs that provides interactive documentation for CSV upload service functions. Disabled in production for security. Features: - Documents import_respondents_from_csv, import_targets_from_csv, get_or_create_csv_config, and update_csv_config services - Shows code references, parameters, return types, and error cases - Interactive "Try It" sections to execute services directly - Sample data loading for testing - Tab navigation with URL query parameter sync - Adds x-cloak CSS rule for Alpine.js CSP build compatibility Co-Authored-By: Claude Opus 4.5 --- .../features/backoffice-csv-upload.feature | 20 + .../entrypoints/blueprints/backoffice.py | 257 +++++- backend/static/backoffice/src/main.css | 12 + .../templates/backoffice/assembly_data.html | 10 +- .../templates/backoffice/components/tabs.html | 14 +- .../templates/backoffice/service_docs.html | 795 ++++++++++++++++++ backend/tests/bdd/test_backoffice.py | 26 + 7 files changed, 1125 insertions(+), 9 deletions(-) create mode 100644 backend/features/backoffice-csv-upload.feature create mode 100644 backend/templates/backoffice/service_docs.html diff --git a/backend/features/backoffice-csv-upload.feature b/backend/features/backoffice-csv-upload.feature new file mode 100644 index 00000000..10607212 --- /dev/null +++ b/backend/features/backoffice-csv-upload.feature @@ -0,0 +1,20 @@ +Feature: Backoffice CSV Upload + As an administrator + I want to upload CSV files for assemblies + So that I can import participant data without using Google Spreadsheets. + + Scenario: Targets tab appears when CSV data source is selected + Given I am logged in as an admin user + And there is an assembly called "CSV Test Assembly" + When I visit the assembly data page for "CSV Test Assembly" + And I select "CSV file" from the data source selector + Then I should see a "Targets" tab in the assembly navigation + And the "Targets" tab should be disabled + + Scenario: Targets tab disappears when switching to Google Spreadsheet + Given I am logged in as an admin user + And there is an assembly called "CSV Switch Assembly" + When I visit the assembly data page for "CSV Switch Assembly" with source "csv" + Then I should see a "Targets" tab in the assembly navigation + When I select "Google Spreadsheet" from the data source selector + Then I should not see a "Targets" tab in the assembly navigation diff --git a/backend/src/opendlp/entrypoints/blueprints/backoffice.py b/backend/src/opendlp/entrypoints/blueprints/backoffice.py index ff61ad40..f66fb5b3 100644 --- a/backend/src/opendlp/entrypoints/blueprints/backoffice.py +++ b/backend/src/opendlp/entrypoints/blueprints/backoffice.py @@ -1,13 +1,15 @@ """ABOUTME: Backoffice routes for admin UI using Pines UI + Tailwind CSS ABOUTME: Provides /backoffice/* routes for dashboard, assembly CRUD, data source, and team members""" +import traceback import uuid +from typing import Any -from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for +from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, url_for from flask.typing import ResponseReturnValue from flask_login import current_user, login_required -from opendlp import bootstrap +from opendlp import bootstrap, config from opendlp.bootstrap import get_email_adapter, get_template_renderer, get_url_generator from opendlp.domain.value_objects import AssemblyRole from opendlp.entrypoints.forms import ( @@ -21,10 +23,14 @@ create_assembly, get_assembly_gsheet, get_assembly_with_permissions, + get_or_create_csv_config, + import_targets_from_csv, update_assembly, + update_csv_config, ) -from opendlp.service_layer.exceptions import InsufficientPermissions, NotFoundError +from opendlp.service_layer.exceptions import InsufficientPermissions, InvalidSelection, NotFoundError from opendlp.service_layer.permissions import has_global_admin +from opendlp.service_layer.respondent_service import import_respondents_from_csv from opendlp.service_layer.user_service import get_user_assemblies, grant_user_assembly_role, revoke_user_assembly_role from opendlp.translations import gettext as _ @@ -504,3 +510,248 @@ def search_demo() -> ResponseReturnValue: ] return jsonify(results), 200 + + +# ============================================================================= +# Service Layer Documentation (Admin-only developer tools) +# ============================================================================= + + +@backoffice_bp.route("/dev/service-docs") +@login_required +def service_docs() -> ResponseReturnValue: + """Interactive service layer documentation page for CSV upload services. + + Admin-only page that provides interactive testing of service layer functions. + Disabled in production for security. + """ + # Disable in production - developer tools should not be available + if config.is_production(): + abort(404) + + if not has_global_admin(current_user): + flash(_("You don't have permission to access developer tools"), "error") + return redirect(url_for("backoffice.dashboard")) + + # Get active tab from query parameter, default to 'respondents' + active_tab = request.args.get("tab", "respondents") + valid_tabs = ["respondents", "targets", "config"] + if active_tab not in valid_tabs: + active_tab = "respondents" + + # Get all assemblies for the dropdown (admin can see all via get_user_assemblies) + uow = bootstrap.bootstrap() + assemblies = get_user_assemblies(uow, current_user.id) + + return render_template("backoffice/service_docs.html", assemblies=assemblies, active_tab=active_tab), 200 + + +@backoffice_bp.route("/dev/service-docs/execute", methods=["POST"]) +@login_required +def service_docs_execute() -> ResponseReturnValue: + """Execute a service layer function for testing. + + Accepts JSON with service name and parameters, returns JSON result. + Disabled in production for security. + """ + # Disable in production - developer tools should not be available + if config.is_production(): + abort(404) + + if not has_global_admin(current_user): + return jsonify({"status": "error", "error": "Unauthorized", "error_type": "InsufficientPermissions"}), 403 + + try: + data = request.get_json() + if not data: + return jsonify({"status": "error", "error": "No JSON data provided", "error_type": "ValidationError"}), 400 + + service_name = data.get("service") + params = data.get("params", {}) + + result = _execute_service(service_name, params) + return jsonify(result), 200 + + except Exception as e: + current_app.logger.error(f"Service docs execute error: {e}") + current_app.logger.exception("Full traceback:") + return jsonify({ + "status": "error", + "error": str(e), + "error_type": type(e).__name__, + "traceback": traceback.format_exc(), + }), 500 + + +def _handle_import_respondents(uow: Any, params: dict[str, Any]) -> dict[str, Any]: + """Handle import_respondents_from_csv service call.""" + assembly_id = uuid.UUID(params["assembly_id"]) + csv_content = params["csv_content"] + replace_existing = params.get("replace_existing", False) + id_column = params.get("id_column") or None + + with uow: + try: + respondents, errors, resolved_id_column = import_respondents_from_csv( + uow=uow, + user_id=current_user.id, + assembly_id=assembly_id, + csv_content=csv_content, + replace_existing=replace_existing, + id_column=id_column, + ) + return { + "status": "success", + "imported_count": len(respondents), + "errors": errors, + "id_column_used": resolved_id_column, + "sample_respondents": [ + { + "external_id": r.external_id, + "attributes": r.attributes, + "email": r.email, + "consent": r.consent, + "eligible": r.eligible, + "can_attend": r.can_attend, + } + for r in respondents[:5] # Show first 5 as sample + ], + } + except InvalidSelection as e: + return {"status": "error", "error": str(e), "error_type": "InvalidSelection"} + except InsufficientPermissions as e: + return {"status": "error", "error": str(e), "error_type": "InsufficientPermissions"} + except NotFoundError as e: + return {"status": "error", "error": str(e), "error_type": "NotFoundError"} + + +def _handle_import_targets(uow: Any, params: dict[str, Any]) -> dict[str, Any]: + """Handle import_targets_from_csv service call.""" + assembly_id = uuid.UUID(params["assembly_id"]) + csv_content = params["csv_content"] + replace_existing = params.get("replace_existing", True) + + with uow: + try: + categories = import_targets_from_csv( + uow=uow, + user_id=current_user.id, + assembly_id=assembly_id, + csv_content=csv_content, + replace_existing=replace_existing, + ) + return { + "status": "success", + "categories_count": len(categories), + "total_values_count": sum(len(c.values) for c in categories), + "categories": [ + { + "name": c.name, + "values": [ + { + "value": v.value, + "min": v.min, + "max": v.max, + "min_flex": v.min_flex, + "max_flex": v.max_flex, + } + for v in c.values + ], + } + for c in categories + ], + } + except InvalidSelection as e: + return {"status": "error", "error": str(e), "error_type": "InvalidSelection"} + except InsufficientPermissions as e: + return {"status": "error", "error": str(e), "error_type": "InsufficientPermissions"} + except NotFoundError as e: + return {"status": "error", "error": str(e), "error_type": "NotFoundError"} + + +def _handle_get_csv_config(uow: Any, params: dict[str, Any]) -> dict[str, Any]: + """Handle get_or_create_csv_config service call.""" + assembly_id = uuid.UUID(params["assembly_id"]) + + with uow: + try: + csv_config = get_or_create_csv_config( + uow=uow, + user_id=current_user.id, + assembly_id=assembly_id, + ) + return { + "status": "success", + "config": { + "assembly_csv_id": str(csv_config.assembly_csv_id) if csv_config.assembly_csv_id else None, + "assembly_id": str(csv_config.assembly_id), + "id_column": csv_config.id_column, + "check_same_address": csv_config.check_same_address, + "check_same_address_cols": csv_config.check_same_address_cols, + "columns_to_keep": csv_config.columns_to_keep, + "selection_algorithm": csv_config.selection_algorithm, + "settings_confirmed": csv_config.settings_confirmed, + "last_import_filename": csv_config.last_import_filename, + "last_import_timestamp": csv_config.last_import_timestamp.isoformat() + if csv_config.last_import_timestamp + else None, + "created_at": csv_config.created_at.isoformat() if csv_config.created_at else None, + "updated_at": csv_config.updated_at.isoformat() if csv_config.updated_at else None, + }, + } + except InsufficientPermissions as e: + return {"status": "error", "error": str(e), "error_type": "InsufficientPermissions"} + except NotFoundError as e: + return {"status": "error", "error": str(e), "error_type": "NotFoundError"} + + +def _handle_update_csv_config(uow: Any, params: dict[str, Any]) -> dict[str, Any]: + """Handle update_csv_config service call.""" + assembly_id = uuid.UUID(params["assembly_id"]) + settings = {k: v for k, v in params.items() if k not in ("assembly_id",)} + + with uow: + try: + csv_config = update_csv_config( + uow=uow, + user_id=current_user.id, + assembly_id=assembly_id, + **settings, + ) + return { + "status": "success", + "config": { + "assembly_csv_id": str(csv_config.assembly_csv_id) if csv_config.assembly_csv_id else None, + "assembly_id": str(csv_config.assembly_id), + "id_column": csv_config.id_column, + "check_same_address": csv_config.check_same_address, + "check_same_address_cols": csv_config.check_same_address_cols, + "columns_to_keep": csv_config.columns_to_keep, + "selection_algorithm": csv_config.selection_algorithm, + "settings_confirmed": csv_config.settings_confirmed, + "updated_at": csv_config.updated_at.isoformat() if csv_config.updated_at else None, + }, + } + except InsufficientPermissions as e: + return {"status": "error", "error": str(e), "error_type": "InsufficientPermissions"} + except NotFoundError as e: + return {"status": "error", "error": str(e), "error_type": "NotFoundError"} + + +# Mapping of service names to their handler functions +_SERVICE_HANDLERS: dict[str, Any] = { + "import_respondents_from_csv": _handle_import_respondents, + "import_targets_from_csv": _handle_import_targets, + "get_or_create_csv_config": _handle_get_csv_config, + "update_csv_config": _handle_update_csv_config, +} + + +def _execute_service(service_name: str, params: dict[str, Any]) -> dict[str, Any]: + """Execute a service layer function and return the result as JSON-serializable dict.""" + handler = _SERVICE_HANDLERS.get(service_name) + if handler is None: + return {"status": "error", "error": f"Unknown service: {service_name}", "error_type": "ValidationError"} + + uow = bootstrap.bootstrap() + return handler(uow, params) diff --git a/backend/static/backoffice/src/main.css b/backend/static/backoffice/src/main.css index 3d95365c..93b9479c 100644 --- a/backend/static/backoffice/src/main.css +++ b/backend/static/backoffice/src/main.css @@ -2,6 +2,18 @@ @tailwind components; @tailwind utilities; +/* ======================================== + Alpine.js Utilities + ======================================== */ + +/* + * Hide elements with x-cloak until Alpine.js initializes. + * This prevents flash of unstyled content for modals, dropdowns, etc. + */ +[x-cloak] { + display: none !important; +} + /* * Design Tokens are loaded separately in templates: * - tokens/primitive.css (raw color values + @font-face + font family primitives) diff --git a/backend/templates/backoffice/assembly_data.html b/backend/templates/backoffice/assembly_data.html index 458198cd..804ba608 100644 --- a/backend/templates/backoffice/assembly_data.html +++ b/backend/templates/backoffice/assembly_data.html @@ -27,12 +27,14 @@

{{ assemb {{ _("Manage data for this assembly") }}

+ {% set source_param = "?source=" ~ data_source if data_source else "" %} {{ tabs( items=[ - {"label": _("Details"), "href": url_for('backoffice.view_assembly', assembly_id=assembly.id)}, - {"label": _("Data"), "href": url_for('backoffice.view_assembly_data', assembly_id=assembly.id), "active": true}, - {"label": _("Selection"), "href": url_for('gsheets.view_assembly_selection', assembly_id=assembly.id)}, - {"label": _("Team Members"), "href": url_for('backoffice.view_assembly_members', assembly_id=assembly.id)} + {"label": _("Details"), "href": url_for('backoffice.view_assembly', assembly_id=assembly.id) ~ source_param}, + {"label": _("Data"), "href": url_for('backoffice.view_assembly_data', assembly_id=assembly.id) ~ source_param, "active": true} + ] + ([{"label": _("Targets"), "href": "#", "disabled": true}] if data_source == "csv" else []) + [ + {"label": _("Selection"), "href": url_for('gsheets.view_assembly_selection', assembly_id=assembly.id) ~ source_param}, + {"label": _("Team Members"), "href": url_for('backoffice.view_assembly_members', assembly_id=assembly.id) ~ source_param} ], aria_label=_("Assembly sections") ) }} diff --git a/backend/templates/backoffice/components/tabs.html b/backend/templates/backoffice/components/tabs.html index 3194763a..764a0fa2 100644 --- a/backend/templates/backoffice/components/tabs.html +++ b/backend/templates/backoffice/components/tabs.html @@ -8,6 +8,7 @@ - label: Display text (required) - href: Link URL (required) - active: Whether this tab is currently active (optional, default false) + - disabled: Whether this tab is disabled/unclickable (optional, default false) - Any additional attributes are passed through to the anchor element (e.g., data-proto) aria_label: Accessible label for the navigation (required) @@ -18,6 +19,7 @@ items=[ {"label": "Details", "href": url_for('backoffice.view_assembly', assembly_id=assembly.id), "active": true}, {"label": "Data", "href": "#", "data-proto": "click:show:data-panel"}, + {"label": "Targets", "href": "#", "disabled": true}, {"label": "Selection", "href": "#"}, {"label": "Team Members", "href": url_for('backoffice.view_assembly_members', assembly_id=assembly.id)} ], @@ -31,12 +33,20 @@ {# Build extra attributes string from non-standard keys #} {% set extra_attrs = [] %} {% for key, value in item.items() %} - {% if key not in ['label', 'href', 'active'] %} + {% if key not in ['label', 'href', 'active', 'disabled'] %} {% set _ = extra_attrs.append(key ~ '="' ~ value ~ '"') %} {% endif %} {% endfor %}
  • - {% if item.active %} + {% if item.disabled %} + {# Disabled tab - rendered as non-clickable span #} + + {{ item.label }} + + {% elif item.active %} {# Active tab - directive handles scroll preservation #} + {{ breadcrumbs([ + {"label": _("Dashboard"), "href": url_for('backoffice.dashboard')}, + {"label": _("Developer Tools")}, + {"label": _("Service Docs")} + ]) }} + +{% endblock %} + +{% block page_content %} + {# Main Alpine.js controller for the entire page #} +
    + {# Page Header #} +
    +

    + ๐Ÿ“š{{ _("Service Layer Documentation") }} +

    +

    + {{ _("Interactive testing console for CSV upload services. Execute service functions directly and inspect responses.") }} +

    +

    + โš ๏ธ {{ _("Admin Only") }}: + {{ _("This page allows direct database modifications. Use with caution.") }} +

    +
    + + {# Tabs for service categories #} + {{ tabs( + items=[ + {"label": _("Respondents"), "href": url_for('backoffice.service_docs', tab='respondents'), "active": active_tab == 'respondents'}, + {"label": _("Targets"), "href": url_for('backoffice.service_docs', tab='targets'), "active": active_tab == 'targets'}, + {"label": _("CSV Config"), "href": url_for('backoffice.service_docs', tab='config'), "active": active_tab == 'config'} + ], + aria_label=_("Service categories") + ) }} + + {# ===== RESPONDENTS TAB ===== #} + {% if active_tab == 'respondents' %} +
    + {# import_respondents_from_csv #} + {% call card(title="import_respondents_from_csv()", composed=true) %} + {% call card_body() %} + {# Code Reference #} +
    + + respondent_service.py:63 + +
    + + ๐Ÿ”’ can_manage_assembly() + + +
    +
    + + {# Description #} +

    + {{ _("Imports respondents from CSV with flexible column mapping. Extracts special fields (consent, eligible, can_attend, email) and stores remaining columns as attributes.") }} +

    + + {# Parameters Table #} +
    +

    {{ _("Parameters") }}

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeRequiredDescription
    assembly_idUUIDโœ…Target assembly ID
    csv_contentstrโœ…CSV file contents (UTF-8)
    replace_existingboolโŒDelete existing respondents before import (default: false)
    id_columnstr | NoneโŒColumn name for respondent ID (default: first column)
    +
    +
    + + {# Returns #} +
    +

    {{ _("Returns") }}

    + + tuple[list[Respondent], list[str], str] โ†’ (respondents, errors, resolved_id_column) + +
    + + {# Error Cases #} +
    +

    {{ _("Error Cases") }}

    +
      +
    • InvalidSelection โ€” CSV has no header, missing id_column, or parsing fails
    • +
    • InsufficientPermissions โ€” User lacks can_manage_assembly() permission
    • +
    • NotFoundError โ€” Assembly or user not found
    • +
    +
    + + {# Try It Section #} +
    +

    + ๐Ÿงช{{ _("Try It") }} +

    + + {# Assembly Select #} +
    + + +
    + + {# CSV Content #} +
    +
    + + +
    + +
    + + {# Options Row #} +
    +
    + + +
    +
    + +
    +
    + + {# Action Buttons #} +
    + + +
    + + {# Response Display #} + +
    + {% endcall %} + {% endcall %} +
    + {% endif %} + + {# ===== TARGETS TAB ===== #} + {% if active_tab == 'targets' %} +
    + {# import_targets_from_csv #} + {% call card(title="import_targets_from_csv()", composed=true) %} + {% call card_body() %} + {# Code Reference #} +
    + + assembly_service.py:501 + +
    + + ๐Ÿ”’ can_manage_assembly() + + +
    +
    + + {# Description #} +

    + {{ _("Imports target categories from CSV using sortition-algorithms library. Parses and validates category definitions with min/max quotas.") }} +

    + + {# Parameters Table #} +
    +

    {{ _("Parameters") }}

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeRequiredDescription
    assembly_idUUIDโœ…Target assembly ID
    csv_contentstrโœ…CSV with columns: feature, value, min, max, [min_flex, max_flex]
    replace_existingboolโŒAlways replaces (default: true)
    +
    +
    + + {# Returns #} +
    +

    {{ _("Returns") }}

    + + list[TargetCategory] โ†’ List of imported categories with their values + +
    + + {# CSV Format Example #} +
    +

    {{ _("CSV Format") }}

    +
    feature,value,min,max,min_flex,max_flex
    +Gender,Male,3,7,0,2
    +Gender,Female,3,7,0,2
    +Age,18-30,2,5,1,1
    +Age,31-50,2,5,1,1
    +Age,51+,2,5,1,1
    +
    + + {# Try It Section #} +
    +

    + ๐Ÿงช{{ _("Try It") }} +

    + + {# Assembly Select #} +
    + + +
    + + {# CSV Content #} +
    +
    + + +
    + +
    + + {# Warning about replace #} +
    +

    + โš ๏ธ {{ _("This operation always replaces existing targets for the selected assembly.") }} +

    +
    + + {# Action Buttons #} +
    + + +
    + + {# Response Display #} + +
    + {% endcall %} + {% endcall %} +
    + {% endif %} + + {# ===== CSV CONFIG TAB ===== #} + {% if active_tab == 'config' %} +
    +
    + {# get_or_create_csv_config #} + {% call card(title="get_or_create_csv_config()", composed=true) %} + {% call card_body() %} + {# Code Reference #} +
    + + assembly_service.py:613 + +
    + + ๐Ÿ‘๏ธ can_view_assembly() + +
    +
    + + {# Description #} +

    + {{ _("Retrieves existing CSV config or creates a default one if none exists. Safe to call multiple times.") }} +

    + + {# Parameters #} +
    +

    {{ _("Parameters") }}

    + + + + + + + + + +
    assembly_idUUIDโœ…Target assembly ID
    +
    + + {# Returns #} +
    +

    {{ _("Returns") }}

    + + AssemblyCSV โ†’ Configuration object with all CSV settings + +
    + + {# Try It Section #} +
    +

    + ๐Ÿงช{{ _("Try It") }} +

    + +
    + + +
    + +
    + +
    + + +
    + {% endcall %} + {% endcall %} + + {# update_csv_config #} + {% call card(title="update_csv_config()", composed=true) %} + {% call card_body() %} + {# Code Reference #} +
    + + assembly_service.py:643 + +
    + + ๐Ÿ”’ can_manage_assembly() + +
    +
    + + {# Description #} +

    + {{ _("Updates CSV configuration settings. Only provided fields are updated.") }} +

    + + {# Parameters #} +
    +

    {{ _("Parameters") }}

    + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    assembly_idUUIDTarget assembly
    id_columnstrColumn name for respondent ID
    check_same_addressboolEnable address deduplication
    check_same_address_colslist[str]Address columns to check
    columns_to_keeplist[str]Columns to preserve in output
    selection_algorithmstrAlgorithm: "maximin", "random", etc.
    settings_confirmedboolMark settings as confirmed
    +
    + + {# Try It Section #} +
    +

    + ๐Ÿงช{{ _("Try It") }} +

    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + + +
    + {% endcall %} + {% endcall %} +
    +
    + {% endif %} + + {# Toast Notification #} +
    + +
    +
    + + +{% endblock %} diff --git a/backend/tests/bdd/test_backoffice.py b/backend/tests/bdd/test_backoffice.py index 33c2b875..14cd6a9f 100644 --- a/backend/tests/bdd/test_backoffice.py +++ b/backend/tests/bdd/test_backoffice.py @@ -20,6 +20,7 @@ scenarios("../../features/backoffice-assembly.feature") scenarios("../../features/backoffice-assembly-members.feature") scenarios("../../features/backoffice-assembly-gsheet.feature") +scenarios("../../features/backoffice-csv-upload.feature") # Store assembly data between steps @@ -1112,3 +1113,28 @@ def try_access_assembly_selection_page(page: Page, title: str, test_database): def see_assembly_selection_page(page: Page): """Verify we're on the assembly selection page.""" expect(page).to_have_url(re.compile(r".*/backoffice/assembly/.*/selection")) + + +# CSV Upload Tests + + +@then(parsers.parse('I should see a "{tab_name}" tab in the assembly navigation')) +def see_tab_in_navigation(page: Page, tab_name: str): + """Verify a specific tab is visible in the assembly navigation.""" + tab = page.locator("nav[aria-label] a, nav[aria-label] span").filter(has_text=tab_name) + expect(tab).to_be_visible() + + +@then(parsers.parse('the "{tab_name}" tab should be disabled')) +def tab_should_be_disabled(page: Page, tab_name: str): + """Verify a specific tab is disabled (shown as span, not a link).""" + # Disabled tabs are rendered as spans with data-disabled attribute, not links + disabled_tab = page.locator("nav[aria-label] span[data-disabled='true']").filter(has_text=tab_name) + expect(disabled_tab).to_be_visible() + + +@then(parsers.parse('I should not see a "{tab_name}" tab in the assembly navigation')) +def should_not_see_tab_in_navigation(page: Page, tab_name: str): + """Verify a specific tab is not visible in the assembly navigation.""" + tab = page.locator("nav[aria-label] a, nav[aria-label] span").filter(has_text=tab_name) + expect(tab).to_have_count(0) From 9bbe2ada64a795ad7758364b8e060921b077600e Mon Sep 17 00:00:00 2001 From: gazdagergo Date: Fri, 20 Mar 2026 15:41:53 +0100 Subject: [PATCH 02/31] Fix Alpine.js CSP compatibility in service docs page - Replace nested x-model paths with flat properties (CSP build limitation) - Update execute/reset/sample loader methods to use flat properties - Add X-CSRFToken header to fetch request for CSRF protection - Add x-cloak to loading spinners to prevent flash Co-Authored-By: Claude Opus 4.5 --- .../templates/backoffice/service_docs.html | 236 +++++++++++------- 1 file changed, 140 insertions(+), 96 deletions(-) diff --git a/backend/templates/backoffice/service_docs.html b/backend/templates/backoffice/service_docs.html index 5d5a69eb..84bbfae5 100644 --- a/backend/templates/backoffice/service_docs.html +++ b/backend/templates/backoffice/service_docs.html @@ -64,7 +64,7 @@

    ๐Ÿ”’ can_manage_assembly() -