- {{ input(
- name="id_column",
- label=_("ID Column"),
+
+ {{ input(name="id_column",
+ label=_("ID Column") ,
value=gsheet_form.id_column.data or "",
placeholder=_("e.g., ID"),
hint=_("Column name containing unique participant identifiers."),
readonly=readonly,
error=e(gsheet_form.id_column.errors)
) }}
- {{ input(
- name="check_same_address_cols_string",
- label=_("Address Columns"),
+ {{ input(name="check_same_address_cols_string",
+ label=_("Address Columns") ,
value=gsheet_form.check_same_address_cols_string.data or "",
placeholder=_("e.g., Street, City, PostCode"),
hint=_("Comma-separated column names used for address matching."),
readonly=readonly,
error=e(gsheet_form.check_same_address_cols_string.errors)
) }}
- {{ input(
- name="columns_to_keep_string",
- label=_("Columns to Keep"),
+ {{ input(name="columns_to_keep_string",
+ label=_("Columns to Keep") ,
value=gsheet_form.columns_to_keep_string.data or "",
placeholder=_("e.g., Name, Email, Phone"),
hint=_("Comma-separated column names to include in output."),
@@ -277,63 +245,168 @@
{{ _("Google S
{# In view mode, show the actual saved column configuration values directly #}
{{ _("Column Configuration") }}
- {{ input(
- name="id_column",
- label=_("ID Column"),
+ {{ input(name="id_column",
+ label=_("ID Column") ,
value=gsheet_form.id_column.data or "",
hint=_("Column name containing unique participant identifiers."),
readonly=readonly
) }}
- {{ input(
- name="check_same_address_cols_string",
- label=_("Address Columns"),
+ {{ input(name="check_same_address_cols_string",
+ label=_("Address Columns") ,
value=gsheet_form.check_same_address_cols_string.data or "",
hint=_("Comma-separated column names used for address matching."),
readonly=readonly
) }}
- {{ input(
- name="columns_to_keep_string",
- label=_("Columns to Keep"),
+ {{ input(name="columns_to_keep_string",
+ label=_("Columns to Keep") ,
value=gsheet_form.columns_to_keep_string.data or "",
hint=_("Comma-separated column names to include in output."),
readonly=readonly
) }}
{% endif %}
-
{# Form Actions #}
{% if not readonly %}
-
- {{ button(_("Save Configuration"), type="submit", variant="primary") }}
+
+ {{ button(_("Save Configuration") , type="submit", variant="primary") }}
{% if is_edit %}
- {{ button(_("Cancel"), href=url_for('backoffice.view_assembly_data', assembly_id=assembly.id) ~ "?source=gsheet", variant="outline") }}
+ {{ button(_("Cancel") , href=url_for('backoffice.view_assembly_data', assembly_id=assembly.id) ~ "?source=gsheet", variant="outline") }}
{% endif %}
{% endif %}
{# Delete button in edit mode - separate form outside the save form #}
{% if is_edit %}
-
-
{% endif %}
{% endmacro %}
-
-
{# CSV file content macro #}
{% macro render_csv_content() %}
+ {% set has_targets = csv_status.has_targets if csv_status else false %}
+ {% set has_respondents = csv_status.has_respondents if csv_status else false %}
+ {% set targets_count = csv_status.targets_count if csv_status else 0 %}
+ {% set respondents_count = csv_status.respondents_count if csv_status else 0 %}
- {{ _("CSV File") }}
-
- {{ _("Upload a CSV file to import participant data.") }}
+
{{ _("CSV Files Upload") }}
+
+ {{ _("Upload CSV files to import participant data.") }}
- {# TODO: Add CSV upload UI #}
+
+ {# Target Upload Card #}
+
+
{{ _("Target") }}
+
+ {{ _("Upload target categories for selection criteria.") }}
+
+ {% if has_targets %}
+ {# Show uploaded status #}
+
+
+
+
+
+ {{ _("%(count)d categories uploaded", count=targets_count) }}
+
+
+ {# Delete button #}
+
+ {% else %}
+ {# Upload form #}
+
+ {% endif %}
+
+ {# People Upload Card #}
+
+
{{ _("People") }}
+
+ {{ _("Upload respondent data for selection.") }}
+
+ {% if has_respondents %}
+ {# Show uploaded status #}
+
+
+
+
+
+ {{ _("%(count)d respondents uploaded", count=respondents_count) }}
+
+
+ {# Delete button #}
+
+ {% else %}
+ {# Upload form #}
+
+ {% endif %}
+
+
{% endmacro %}
diff --git a/backend/templates/backoffice/assembly_details.html b/backend/templates/backoffice/assembly_details.html
index b1cfc30e..9776439e 100644
--- a/backend/templates/backoffice/assembly_details.html
+++ b/backend/templates/backoffice/assembly_details.html
@@ -5,7 +5,7 @@
{% extends "backoffice/base_page.html" %}
{% from "backoffice/components/breadcrumbs.html" import breadcrumbs %}
{% from "backoffice/components/button.html" import button %}
-{% from "backoffice/components/tabs.html" import tabs %}
+{% from "backoffice/components/assembly_tabs.html" import assembly_tabs %}
{% block title %}{{ assembly.title }} - Assembly{% endblock %}
@@ -25,14 +25,14 @@
{{ assemb
{{ _("Assembly Details") }}
- {{ tabs(
- items=[
- {"label": _("Details"), "href": url_for('backoffice.view_assembly', assembly_id=assembly.id), "active": true},
- {"label": _("Data"), "href": url_for('backoffice.view_assembly_data', assembly_id=assembly.id)},
- {"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)}
- ],
- aria_label=_("Assembly sections")
+ {{ assembly_tabs(
+ assembly=assembly,
+ active_tab="details",
+ data_source=data_source|default(""),
+ gsheet=gsheet|default(none),
+ targets_enabled=targets_enabled|default(false),
+ respondents_enabled=respondents_enabled|default(false),
+ selection_enabled=selection_enabled|default(false)
) }}
{# Assembly Question Section #}
diff --git a/backend/templates/backoffice/assembly_members.html b/backend/templates/backoffice/assembly_members.html
index 3b5cbfec..ca2221d7 100644
--- a/backend/templates/backoffice/assembly_members.html
+++ b/backend/templates/backoffice/assembly_members.html
@@ -7,7 +7,7 @@
{% from "backoffice/components/button.html" import button %}
{% from "backoffice/components/card.html" import card %}
{% from "backoffice/components/search_dropdown.html" import search_dropdown %}
-{% from "backoffice/components/tabs.html" import tabs %}
+{% from "backoffice/components/assembly_tabs.html" import assembly_tabs %}
{% block title %}{{ _("Team Members") }} - {{ assembly.title }}{% endblock %}
@@ -28,14 +28,14 @@ {{ assemb
{{ _("Manage team members for this assembly") }}
- {{ 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)},
- {"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), "active": true}
- ],
- aria_label=_("Assembly sections")
+ {{ assembly_tabs(
+ assembly=assembly,
+ active_tab="members",
+ data_source=data_source|default(""),
+ gsheet=gsheet|default(none),
+ targets_enabled=targets_enabled|default(false),
+ respondents_enabled=respondents_enabled|default(false),
+ selection_enabled=selection_enabled|default(false)
) }}
{# Team Members Section #}
diff --git a/backend/templates/backoffice/assembly_respondents.html b/backend/templates/backoffice/assembly_respondents.html
new file mode 100644
index 00000000..617be83c
--- /dev/null
+++ b/backend/templates/backoffice/assembly_respondents.html
@@ -0,0 +1,97 @@
+{#
+ABOUTME: Backoffice assembly respondents page
+ABOUTME: Displays respondent data configuration for assemblies
+#}
+{% extends "backoffice/base_page.html" %}
+{% from "backoffice/components/breadcrumbs.html" import breadcrumbs %}
+{% from "backoffice/components/button.html" import button %}
+{% from "backoffice/components/alert.html" import alert %}
+{% from "backoffice/components/assembly_tabs.html" import assembly_tabs %}
+
+{% block title %}{{ _("Respondents") }} - {{ assembly.title }}{% endblock %}
+
+{% block breadcrumb_section %}
+
+ {{ breadcrumbs([
+ {"label": _("Dashboard"), "href": url_for('backoffice.dashboard')},
+ {"label": assembly.title, "href": url_for('backoffice.view_assembly', assembly_id=assembly.id)},
+ {"label": _("Respondents")}
+ ]) }}
+
+{% endblock %}
+
+{% block page_content %}
+ {# Page Heading #}
+ {{ assembly.title }}
+
+ {{ _("Manage respondent data for this assembly") }}
+
+
+ {{ assembly_tabs(
+ assembly=assembly,
+ active_tab="respondents",
+ data_source=data_source,
+ gsheet=gsheet,
+ targets_enabled=targets_enabled,
+ respondents_enabled=respondents_enabled,
+ selection_enabled=selection_enabled|default(false)
+ ) }}
+
+ {% if data_source == "gsheet" and gsheet %}
+ {# Google Sheet source - show info about where respondents are configured #}
+
+
+
+
+
+
{{ _("Respondents are configured in Google Sheets") }}
+
+ {{ _("Respondent data (participant information) is stored in your Google Spreadsheet.") }}
+
+
+
+
+
{{ _("Initial Selection Respondents Tab:") }}
+
+ {{ gsheet.select_registrants_tab or _("Not configured") }}
+
+
+
+
{{ _("Replacement Respondents Tab:") }}
+
+ {{ gsheet.replace_registrants_tab or _("Not configured") }}
+
+
+
+
+
+ {{ button(_("View Data Configuration"), href=url_for('backoffice.view_assembly_data', assembly_id=assembly.id) ~ "?source=gsheet", variant="outline") }}
+
+
+
+
+
+ {% elif data_source == "csv" %}
+ {# CSV source - placeholder for future implementation #}
+
+ {{ alert(_("CSV respondents details will be visible here."), variant="info") }}
+
+ {% else %}
+ {# No data source selected #}
+
+ {{ alert(_("Please configure a data source in the Data tab first."), variant="warning") }}
+
+ {{ button(_("Configure Data Source"), href=url_for('backoffice.view_assembly_data', assembly_id=assembly.id), variant="primary") }}
+
+
+ {% endif %}
+
+ {# Actions #}
+
+ {{ button(_("Back to Dashboard"), href=url_for('backoffice.dashboard'), variant="outline") }}
+
+{% endblock %}
diff --git a/backend/templates/backoffice/assembly_selection.html b/backend/templates/backoffice/assembly_selection.html
index 4c107425..ac845729 100644
--- a/backend/templates/backoffice/assembly_selection.html
+++ b/backend/templates/backoffice/assembly_selection.html
@@ -7,7 +7,7 @@
{% from "backoffice/components/button.html" import button %}
{% from "backoffice/components/card.html" import card, card_body, card_footer %}
{% from "backoffice/components/alert.html" import alert %}
-{% from "backoffice/components/tabs.html" import tabs %}
+{% from "backoffice/components/assembly_tabs.html" import assembly_tabs %}
{% block title %}{{ _("Selection") }} - {{ assembly.title }}{% endblock %}
{% block breadcrumb_section %}
@@ -24,13 +24,14 @@
{{ assemb
{{ _("Run selection and manage generated outputs") }}
- {{ 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)},
- {"label": _("Selection"), "href": url_for('gsheets.view_assembly_selection', assembly_id=assembly.id), "active": true},
- {"label": _("Team Members"), "href": url_for('backoffice.view_assembly_members', assembly_id=assembly.id)}
- ],
- aria_label=_("Assembly sections")
+ {{ assembly_tabs(
+ assembly=assembly,
+ active_tab="selection",
+ data_source=data_source|default(""),
+ gsheet=gsheet|default(none),
+ targets_enabled=targets_enabled|default(false),
+ respondents_enabled=respondents_enabled|default(false),
+ selection_enabled=selection_enabled|default(false)
) }}
{# Selection Progress Modal - shown when current_selection parameter is present #}
{# Pure HTMX approach: server controls close button state, HTMX swaps entire modal content #}
@@ -45,7 +46,13 @@
{{ assemb
{% if edit_number_modal_open %}
{% include "backoffice/components/edit_number_to_select_modal.html" %}
{% endif %}
- {% if not gsheet %}
+ {% if data_source == "csv" %}
+ {# CSV source - placeholder for future implementation #}
+ {{ alert(_("CSV based selection will be implemented here."), variant="info") }}
+
+ {{ button(_("Back to Dashboard") , href=url_for('backoffice.dashboard'), variant="outline") }}
+
+ {% elif not gsheet %}
{# No Google Sheet configured - show configuration prompt #}
{{ alert(_("Please use the Data tab to tell us about your data, before running a selection.") , variant="warning") }}
diff --git a/backend/templates/backoffice/assembly_targets.html b/backend/templates/backoffice/assembly_targets.html
new file mode 100644
index 00000000..db6ae6fa
--- /dev/null
+++ b/backend/templates/backoffice/assembly_targets.html
@@ -0,0 +1,97 @@
+{#
+ABOUTME: Backoffice assembly targets page
+ABOUTME: Displays target categories configuration for assemblies
+#}
+{% extends "backoffice/base_page.html" %}
+{% from "backoffice/components/breadcrumbs.html" import breadcrumbs %}
+{% from "backoffice/components/button.html" import button %}
+{% from "backoffice/components/alert.html" import alert %}
+{% from "backoffice/components/assembly_tabs.html" import assembly_tabs %}
+
+{% block title %}{{ _("Targets") }} - {{ assembly.title }}{% endblock %}
+
+{% block breadcrumb_section %}
+
+ {{ breadcrumbs([
+ {"label": _("Dashboard"), "href": url_for('backoffice.dashboard')},
+ {"label": assembly.title, "href": url_for('backoffice.view_assembly', assembly_id=assembly.id)},
+ {"label": _("Targets")}
+ ]) }}
+
+{% endblock %}
+
+{% block page_content %}
+ {# Page Heading #}
+ {{ assembly.title }}
+
+ {{ _("Configure selection targets for this assembly") }}
+
+
+ {{ assembly_tabs(
+ assembly=assembly,
+ active_tab="targets",
+ data_source=data_source,
+ gsheet=gsheet,
+ targets_enabled=targets_enabled,
+ respondents_enabled=respondents_enabled,
+ selection_enabled=selection_enabled|default(false)
+ ) }}
+
+ {% if data_source == "gsheet" and gsheet %}
+ {# Google Sheet source - show info about where targets are configured #}
+
+
+
+
+
+
{{ _("Targets are configured in Google Sheets") }}
+
+ {{ _("Selection targets (demographic categories and quotas) are defined in your Google Spreadsheet.") }}
+
+
+
+
+
{{ _("Initial Selection Categories Tab:") }}
+
+ {{ gsheet.select_targets_tab or _("Not configured") }}
+
+
+
+
{{ _("Replacement Categories Tab:") }}
+
+ {{ gsheet.replace_targets_tab or _("Not configured") }}
+
+
+
+
+
+ {{ button(_("View Data Configuration"), href=url_for('backoffice.view_assembly_data', assembly_id=assembly.id) ~ "?source=gsheet", variant="outline") }}
+
+
+
+
+
+ {% elif data_source == "csv" %}
+ {# CSV source - placeholder for future implementation #}
+
+ {{ alert(_("CSV target details will be visible here."), variant="info") }}
+
+ {% else %}
+ {# No data source selected #}
+
+ {{ alert(_("Please configure a data source in the Data tab first."), variant="warning") }}
+
+ {{ button(_("Configure Data Source"), href=url_for('backoffice.view_assembly_data', assembly_id=assembly.id), variant="primary") }}
+
+
+ {% endif %}
+
+ {# Actions #}
+
+ {{ button(_("Back to Dashboard"), href=url_for('backoffice.dashboard'), variant="outline") }}
+
+{% endblock %}
diff --git a/backend/templates/backoffice/components/assembly_tabs.html b/backend/templates/backoffice/components/assembly_tabs.html
new file mode 100644
index 00000000..6c2bba6d
--- /dev/null
+++ b/backend/templates/backoffice/components/assembly_tabs.html
@@ -0,0 +1,65 @@
+{#
+ABOUTME: Shared assembly navigation tabs macro
+ABOUTME: Ensures consistent tab display across all assembly pages
+#}
+{% from "backoffice/components/tabs.html" import tabs %}
+
+{% macro assembly_tabs(assembly, active_tab, data_source="", gsheet=none, targets_enabled=false, respondents_enabled=false, selection_enabled=false) %}
+ {#
+ Render consistent assembly navigation tabs across all assembly pages.
+
+ Args:
+ assembly: The assembly object
+ active_tab: Current active tab - one of: "details", "data", "targets", "respondents", "selection", "members"
+ data_source: Current data source ("gsheet", "csv", or "")
+ gsheet: Google Sheet configuration object (if exists)
+ targets_enabled: Whether the Targets tab should be clickable
+ respondents_enabled: Whether the Respondents tab should be clickable
+ selection_enabled: Whether the Selection tab should be clickable
+
+ Usage:
+ assembly_tabs(assembly, "details")
+ assembly_tabs(assembly, "data", data_source="csv", targets_enabled=false)
+ assembly_tabs(assembly, "targets", data_source="gsheet", gsheet=gsheet, targets_enabled=true, selection_enabled=true)
+ #}
+ {% set source_param = "?source=" ~ data_source if data_source else "" %}
+
+ {{ tabs(
+ items=[
+ {
+ "label": _("Details"),
+ "href": url_for('backoffice.view_assembly', assembly_id=assembly.id) ~ source_param,
+ "active": active_tab == "details"
+ },
+ {
+ "label": _("Data"),
+ "href": url_for('backoffice.view_assembly_data', assembly_id=assembly.id) ~ source_param,
+ "active": active_tab == "data"
+ },
+ {
+ "label": _("Targets"),
+ "href": url_for('backoffice.view_assembly_targets', assembly_id=assembly.id) ~ source_param,
+ "active": active_tab == "targets",
+ "disabled": not targets_enabled
+ },
+ {
+ "label": _("Respondents"),
+ "href": url_for('backoffice.view_assembly_respondents', assembly_id=assembly.id) ~ source_param,
+ "active": active_tab == "respondents",
+ "disabled": not respondents_enabled
+ },
+ {
+ "label": _("Selection"),
+ "href": url_for('gsheets.view_assembly_selection', assembly_id=assembly.id) ~ source_param,
+ "active": active_tab == "selection",
+ "disabled": not selection_enabled
+ },
+ {
+ "label": _("Team Members"),
+ "href": url_for('backoffice.view_assembly_members', assembly_id=assembly.id) ~ source_param,
+ "active": active_tab == "members"
+ }
+ ],
+ aria_label=_("Assembly sections")
+ ) }}
+{% endmacro %}
diff --git a/backend/templates/backoffice/components/input.html b/backend/templates/backoffice/components/input.html
index 48bf8787..c573f699 100644
--- a/backend/templates/backoffice/components/input.html
+++ b/backend/templates/backoffice/components/input.html
@@ -1,6 +1,6 @@
{#
-ABOUTME: Text input component macro for backoffice design system
-ABOUTME: Supports text, email, password, number inputs with label and optional hint/error
+ABOUTME: Form input component macros for backoffice design system
+ABOUTME: Supports text, email, password, number, textarea, select, checkbox, radio, and file inputs
#}
{% macro first_error(errors) %}
@@ -421,3 +421,75 @@
{% endif %}
{% endmacro %}
+
+
+{% macro file_input(name, label="", hint="", error="", accept="", required=false, disabled=false, id="", classes="", attrs="") %}
+ {#
+ File input component using semantic design tokens.
+
+ This is a stateless UI component. For file upload handling logic
+ (form submission, reading content, validation), see the File Upload
+ pattern in /backoffice/dev/patterns.
+
+ Args:
+ name: Input name attribute (required)
+ label: Label text displayed above the input
+ hint: Help text displayed below the label
+ error: Error message (displays input in error state)
+ accept: Comma-separated list of accepted file types (e.g., ".csv", ".csv,.xlsx", "text/csv")
+ required: Boolean to mark field as required
+ disabled: Boolean to disable the input
+ id: Optional id attribute (defaults to name)
+ classes: Additional CSS classes for the input
+ attrs: Additional HTML attributes (e.g., 'x-model="file"' for Alpine.js binding)
+
+ Usage:
+ file_input("csv_file", label="CSV File", accept=".csv", required=true)
+ file_input("import_file", label="Import File", hint="Select a CSV file", accept=".csv", error=first_error(form.csv_file.errors))
+ file_input("csv_file", label="CSV File", accept=".csv", attrs='@change="onFileSelect($event)"')
+ #}
+ {% set input_id = id if id else name %}
+ {% set has_error = error | length > 0 %}
+
+
+ {# Label #}
+ {% if label %}
+
+ {{ label }}
+ {% if required %}* {% endif %}
+
+ {% endif %}
+
+ {# Hint text #}
+ {% if hint %}
+
{{ hint }}
+ {% endif %}
+
+ {# File input with custom styling #}
+
+
+
+
+ {# Error message #}
+ {% if has_error %}
+
{{ error }}
+ {% endif %}
+
+{% endmacro %}
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")}
+ ]) }}
+
+{% endblock %}
+
+{% block page_content %}
+
+ 🛠️ {{ _("Developer Tools") }}
+
+
+ {{ _("Interactive documentation and testing tools for development.") }}
+
+
+ {# Warning banner #}
+
+
+ ⚠️ {{ _("Development Only") }} —
+ {{ _("These tools are disabled in production and only available to admin users.") }}
+
+
+
+ {# Developer Tools Cards #}
+
+ {{ _("Documentation & Testing") }}
+
+
+ {# Service Layer Docs #}
+ {% call link_card(
+ href=url_for('backoffice.service_docs'),
+ title=_("Service Layer Docs"),
+ subtitle=_("CSV Upload Services"),
+ id="service-docs-card"
+ ) %}
+
+ {{ _("Interactive documentation for CSV upload service layer functions. Test import_respondents, import_targets, and CSV config services.") }}
+
+
+ 📄 {{ _("4 services documented") }}
+
+{% endcall %}
+
+ {# Frontend Patterns #}
+{% call link_card(
+href=url_for('backoffice.patterns'),
+title=_("Frontend Patterns"),
+subtitle=_("Alpine.js & CSP Guidelines"),
+id="patterns-card"
+) %}
+
+ {{ _("Living documentation for CSP-compatible Alpine.js patterns. Includes dropdown bindings, form state, and AJAX with working examples.") }}
+
+
+ 🎨 {{ _("Patterns with implementation index") }}
+
+{% endcall %}
+
+ {# Component Showcase #}
+{% call link_card(
+href=url_for('backoffice.showcase'),
+title=_("Component Showcase"),
+subtitle=_("Design System Reference"),
+id="showcase-card"
+) %}
+
+ {{ _("Visual reference for all backoffice UI components. Buttons, cards, forms, modals, typography, and color tokens.") }}
+
+
+ 🧩 {{ _("All components in one place") }}
+
+{% endcall %}
+
+
+
+ {# Quick Links #}
+
+ {{ _("Quick Reference") }}
+
+
+
+ CLAUDE.md —
+ {{ _("Project guidance for AI assistants (includes Alpine.js CSP constraints)") }}
+
+
+ docs/agent/ —
+ {{ _("Agent-specific documentation for frontend, testing, and code quality") }}
+
+
+ static/backoffice/js/alpine-components.js —
+ {{ _("Reusable Alpine.js components (urlSelect, autocomplete, modal)") }}
+
+
+
+
+{% endblock %}
diff --git a/backend/templates/backoffice/patterns.html b/backend/templates/backoffice/patterns.html
new file mode 100644
index 00000000..b5cef97d
--- /dev/null
+++ b/backend/templates/backoffice/patterns.html
@@ -0,0 +1,720 @@
+{#
+ABOUTME: Interactive frontend patterns documentation page
+ABOUTME: Documents Alpine.js patterns, form handling, and AJAX with live examples and code
+#}
+{% extends "backoffice/base_page.html" %}
+
+{% from "backoffice/components/breadcrumbs.html" import breadcrumbs %}
+{% from "backoffice/components/card.html" import card, card_body %}
+{% from "backoffice/components/tabs.html" import tabs %}
+
+{% block title %}{{ _("Frontend Patterns") }}{% endblock %}
+
+{% block breadcrumb_section %}
+
+ {{ breadcrumbs([
+ {"label": _("Dashboard"), "href": url_for('backoffice.dashboard')},
+ {"label": _("Developer Tools"), "href": url_for('backoffice.dev_dashboard')},
+ {"label": _("Patterns")}
+ ]) }}
+
+{% endblock %}
+
+{% block page_content %}
+
+ {# Page Header #}
+
+
+ 🎨 {{ _("Frontend Patterns") }}
+
+
+ {{ _("Living documentation for Alpine.js patterns used in the backoffice. Each pattern includes working examples, code snippets, and links to implementations.") }}
+
+
+ 💡 {{ _("Tip") }}:
+ {{ _("These patterns are CSP-compatible and work with the Alpine.js CSP build.") }}
+
+
+
+ {# Tabs for pattern categories #}
+ {{ tabs(
+ items=[
+ {"label": _("Dropdown"), "href": url_for('backoffice.patterns', tab='dropdown'), "active": active_tab == 'dropdown'},
+ {"label": _("Form State"), "href": url_for('backoffice.patterns', tab='form'), "active": active_tab == 'form'},
+ {"label": _("AJAX"), "href": url_for('backoffice.patterns', tab='ajax'), "active": active_tab == 'ajax'},
+ {"label": _("File Upload"), "href": url_for('backoffice.patterns', tab='file-upload'), "active": active_tab == 'file-upload'}
+ ],
+ aria_label=_("Pattern categories")
+ ) }}
+
+ {# ===== DROPDOWN PATTERN ===== #}
+ {% if active_tab == 'dropdown' %}
+
+ {# URL Select Pattern #}
+ {% call card(title="URL Select (Navigation Dropdown)", composed=true) %}
+ {% call card_body() %}
+ {# Description #}
+
+ {{ _("A dropdown that navigates to a URL when selection changes. Uses the reusable urlSelect Alpine component with focus restoration for keyboard users.") | safe }}
+
+
+ {# When to Use #}
+
+
{{ _("When to Use") }}
+
+ {{ _("Dropdown selection triggers a page navigation/reload") }}
+ {{ _("Value is passed as URL query parameter") }}
+ {{ _("Server-side rendering based on selection") }}
+
+
+
+ {# Live Example #}
+
+
+ 🧪 {{ _("Live Example") }}
+
+
+
+
{{ _("Select an option") }}
+
+ {{ _("Choose...") }}
+ {{ _("Option 1") }}
+ {{ _("Option 2") }}
+ {{ _("Option 3") }}
+
+ {% if request.args.get('demo') %}
+
+ ✅ {{ _("Selected: %(value)s (see URL)", value=request.args.get('demo')) }}
+
+ {% endif %}
+
+
+
+
+ {# Code Example #}
+
+
+
{{ _("Code") }}
+
+ 📋 {{ _("Copy") }}
+
+
+
<div x-data="urlSelect({
+ baseUrl: '{% raw %}{{ url_for(...) }}{% endraw %}',
+ paramName: 'source',
+ initialValue: '{% raw %}{{ current_value }}{% endraw %}'
+})">
+ <select x-model="selected"
+ @change="navigate($event)"
+ data-focus-id="my-select">
+ <option value="">Choose...</option>
+ <option value="opt1">Option 1</option>
+ </select>
+</div>
+
+
+ {# CSP Notes #}
+
+
⚠️ {{ _("CSP Compatibility Notes") }}
+
+ x-model must use flat properties — x-model="selected" works, x-model="form.field" does NOT
+ Component options via object — Pass config as object to Alpine.data(), not inline expressions
+ No string arguments in handlers — @click="doThing()" works, @click="doThing('arg')" does NOT
+
+
+
+ {# Implementation Index #}
+
+
+ 📍 {{ _("Implementations") }}
+
+
+
+
+
+ {{ _("File") }}
+ {{ _("Line") }}
+ {{ _("Usage") }}
+
+
+
+
+ static/backoffice/js/alpine-components.js
+ 229
+ Component definition
+
+
+ templates/backoffice/assembly_data.html
+ 47
+ Data source selector
+
+
+ templates/backoffice/patterns.html
+ —
+ This documentation page
+
+
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {# Inline State Dropdown Pattern #}
+ {% call card(title="Inline State Dropdown (Form Binding)", composed=true) %}
+ {% call card_body() %}
+ {# Description #}
+
+ {{ _("A dropdown that binds to Alpine.js state for use in forms or interactive components. Does NOT navigate — stores value in component state.") }}
+
+
+ {# When to Use #}
+
+
{{ _("When to Use") }}
+
+ {{ _("Selection is part of a larger form") }}
+ {{ _("Value used in AJAX request or local computation") }}
+ {{ _("No page reload needed") }}
+
+
+
+ {# Live Example #}
+
+
+ 🧪 {{ _("Live Example") }}
+
+
+
+
{{ _("Assembly") }}
+
+ {{ _("Select an assembly...") }}
+ {% for assembly in assemblies %}
+ {{ assembly.title }}
+ {% endfor %}
+
+
+ {{ _("Selected ID:") }}
+
+
+
+
+
+ {# Code Example #}
+
+
+
{{ _("Code") }}
+
+ 📋 {{ _("Copy") }}
+
+
+
// In your Alpine component, declare FLAT property:
+Alpine.data('myComponent', () => ({
+ assemblyId: '', // Flat property
+ // NOT: form: { assemblyId: '' } // Nested - won't work with CSP
+
+ executeAction() {
+ // Use the flat property
+ fetch('/api/endpoint', {
+ body: JSON.stringify({ assembly_id: this.assemblyId })
+ });
+ }
+}));
+
+// In template:
+<select x-model="assemblyId">
+ <option value="">Select...</option>
+ {% raw %}{% for a in assemblies %}{% endraw %}
+ <option value="{% raw %}{{ a.id }}{% endraw %}">{% raw %}{{ a.title }}{% endraw %}</option>
+ {% raw %}{% endfor %}{% endraw %}
+</select>
+
+
+ {# CSP Notes #}
+
+
🚫 {{ _("Common Mistake") }}
+
// ❌ THIS DOES NOT WORK with CSP build:
+x-model="forms.import.assembly_id"
+
+// ✅ USE FLAT PROPERTIES INSTEAD:
+x-model="importAssemblyId"
+
+ {{ _("The CSP-compatible Alpine.js build cannot evaluate nested property paths in x-model directives.") }}
+
+
+
+ {# Implementation Index #}
+
+
+ 📍 {{ _("Implementations") }}
+
+
+
+
+
+ {{ _("File") }}
+ {{ _("Line") }}
+ {{ _("Usage") }}
+
+
+
+
+ templates/backoffice/service_docs.html
+ 150, 326, 453, 544
+ Service form dropdowns
+
+
+ templates/backoffice/assembly_data.html
+ 244
+ Team member selector
+
+
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {% endif %}
+
+ {# ===== FORM STATE PATTERN ===== #}
+ {% if active_tab == 'form' %}
+
+ {% call card(title="Form State Pattern", composed=true) %}
+ {% call card_body() %}
+
+ {{ _("Documentation for form state patterns coming soon...") }}
+
+ {% endcall %}
+ {% endcall %}
+
+ {% endif %}
+
+ {# ===== AJAX PATTERN ===== #}
+ {% if active_tab == 'ajax' %}
+
+ {% call card(title="AJAX with CSRF Pattern", composed=true) %}
+ {% call card_body() %}
+
+ {{ _("Documentation for AJAX patterns coming soon...") }}
+
+ {% endcall %}
+ {% endcall %}
+
+ {% endif %}
+
+ {# ===== FILE UPLOAD PATTERN ===== #}
+ {% if active_tab == 'file-upload' %}
+
+ {# File Upload Pattern #}
+ {% call card(title="CSV File Upload Pattern", composed=true) %}
+ {% call card_body() %}
+ {# Description #}
+
+ {{ _("Pattern for uploading CSV files with client-side validation and server-side processing. The file is read as text and passed to service layer functions.") }}
+
+
+ {# Architecture Overview #}
+
+
{{ _("Architecture") }}
+
+ {{ _("UI Component") }}: file_input {{ _("macro in") }} components/input.html
+ {{ _("Form submission") }}: enctype="multipart/form-data"
+ {{ _("Server reads file") }}: file.read().decode("utf-8-sig")
+ {{ _("Service layer") }}: {{ _("Receives CSV content as string, not file object") }}
+
+
+
+ {# Live Example #}
+
+
+ 🧪 {{ _("Live Example") }}
+
+
+
+
+ {# Code Example - Template #}
+
+
+
{{ _("Template Code") }}
+
+ 📋 {{ _("Copy") }}
+
+
+
{% raw %}{# Import the component #}
+{% from "backoffice/components/input.html" import file_input %}
+
+{# Form with multipart encoding #}
+<form method="post"
+ action="{{ url_for('...') }}"
+ enctype="multipart/form-data">
+ {{ form.hidden_tag() }}
+
+ {{ file_input(
+ "csv_file",
+ label="CSV File",
+ hint="Select a CSV file to upload",
+ accept=".csv",
+ required=true,
+ error=first_error(form.csv_file.errors)
+ ) }}
+
+ <button type="submit">Upload</button>
+</form>{% endraw %}
+
+
+ {# Code Example - Flask Route #}
+
+
+
{{ _("Flask Route Code") }}
+
+ 📋 {{ _("Copy") }}
+
+
+
# Flask-WTF Form
+class UploadCsvForm(FlaskForm):
+ csv_file = FileField(
+ _l("CSV File"),
+ validators=[
+ FileRequired(message=_l("Please select a file")),
+ FileAllowed(["csv"], message=_l("Only CSV files allowed")),
+ ],
+ )
+
+# Route handler
+@bp.route("/upload", methods=["POST"])
+def upload_csv():
+ form = UploadCsvForm()
+
+ if not form.validate_on_submit():
+ # Re-render form with errors
+ return render_template("...", form=form)
+
+ # Read file content as string
+ csv_file = form.csv_file.data
+ csv_content = csv_file.read().decode("utf-8-sig")
+ filename = csv_file.filename or "unknown.csv"
+
+ # Pass to service layer (content as string, not file)
+ result = import_from_csv(uow, csv_content=csv_content)
+
+ flash(_("Imported successfully"), "success")
+ return redirect(url_for("..."))
+
+
+ {# Key Points #}
+
+
⚠️ {{ _("Key Points") }}
+
+ enctype="multipart/form-data" — {{ _("Required for file uploads") }}
+ utf-8-sig — {{ _("Handles BOM (byte order mark) in CSV files from Excel") }}
+ {{ _("Service layer receives string") }} — {{ _("Not the file object, for testability") }}
+ {{ _("CSRF token") }} — {{ _("form.hidden_tag() includes it automatically for Flask-WTF forms") }}
+ {{ _("Plain HTML forms") }} — {{ _("Add manual CSRF input:") }} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
+
+
+
+ {# Implementation Index #}
+
+
+ 📍 {{ _("Implementations") }}
+
+
+
+
+
+ {{ _("File") }}
+ {{ _("Line") }}
+ {{ _("Usage") }}
+
+
+
+
+ templates/backoffice/components/input.html
+ 426
+ file_input macro
+
+
+ src/opendlp/entrypoints/forms.py
+ 552
+ UploadRespondentsCsvForm
+
+
+ src/opendlp/entrypoints/blueprints/respondents.py
+ 92
+ upload_respondents_csv route
+
+
+ templates/respondents/view_respondents.html
+ 27
+ File upload form (old design)
+
+
+ templates/backoffice/assembly_data.html
+ 370
+ Upload targets CSV form (plain HTML)
+
+
+ templates/backoffice/assembly_data.html
+ 412
+ Upload respondents CSV form (plain HTML)
+
+
+ src/opendlp/entrypoints/blueprints/backoffice.py
+ 325
+ upload_targets_csv route
+
+
+ src/opendlp/entrypoints/blueprints/backoffice.py
+ 414
+ upload_respondents_csv route
+
+
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {% endif %}
+
+ {# Toast Notification #}
+
+
+
+
+
+
+{% endblock %}
diff --git a/backend/templates/backoffice/service_docs.html b/backend/templates/backoffice/service_docs.html
new file mode 100644
index 00000000..6c75cdbe
--- /dev/null
+++ b/backend/templates/backoffice/service_docs.html
@@ -0,0 +1,1290 @@
+{#
+ABOUTME: Interactive service layer documentation page for CSV upload services
+ABOUTME: Provides testing console with code references, sample data, and execution
+#}
+{% extends "backoffice/base_page.html" %}
+
+{% from "backoffice/components/breadcrumbs.html" import breadcrumbs %}
+{% from "backoffice/components/button.html" import button %}
+{% from "backoffice/components/card.html" import card, card_body, card_footer %}
+{% from "backoffice/components/alert.html" import alert %}
+{% from "backoffice/components/tabs.html" import tabs %}
+
+{% block title %}{{ _("Service Layer Documentation") }}{% endblock %}
+
+{% block breadcrumb_section %}
+
+ {{ breadcrumbs([
+ {"label": _("Dashboard"), "href": url_for('backoffice.dashboard')},
+ {"label": _("Developer Tools"), "href": url_for('backoffice.dev_dashboard')},
+ {"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'},
+ {"label": _("Selection"), "href": url_for('backoffice.service_docs', tab='selection'), "active": active_tab == 'selection'}
+ ],
+ 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()
+
+
+ 📋 Copy
+
+
+
+
+ {# 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") }}
+
+
+
+
+ Name
+ Type
+ Required
+ Description
+
+
+
+
+ assembly_id
+ UUID
+ ✅
+ Target assembly ID
+
+
+ csv_content
+ str
+ ✅
+ CSV file contents (UTF-8)
+
+
+ replace_existing
+ bool
+ ❌
+ Delete existing respondents before import (default: false)
+
+
+ id_column
+ str | 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 #}
+
+ {{ _("Assembly") }}
+
+ {{ _("Select an assembly...") }}
+ {% for assembly in assemblies %}
+ {{ assembly.title }}
+ {% endfor %}
+
+
+
+ {# CSV Content #}
+
+
+ {{ _("CSV Content") }}
+
+ 📄 {{ _("Load Sample") }}
+
+
+
+
+
+ {# Options Row #}
+
+
+ {# Action Buttons #}
+
+
+ ▶️ {{ _("Execute") }}
+ ⏳ {{ _("Running...") }}
+
+
+ 🔄 {{ _("Reset") }}
+
+
+
+ {# Response Display #}
+
+
+
+
+
+
+
+ 📋 {{ _("Copy JSON") }}
+
+
+
+
+
+
+ {% 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()
+
+
+ 📋 Copy
+
+
+
+
+ {# Description #}
+
+ {{ _("Imports target categories from CSV using sortition-algorithms library. Parses and validates category definitions with min/max quotas.") }}
+
+
+ {# Parameters Table #}
+
+
{{ _("Parameters") }}
+
+
+
+
+ Name
+ Type
+ Required
+ Description
+
+
+
+
+ assembly_id
+ UUID
+ ✅
+ Target assembly ID
+
+
+ csv_content
+ str
+ ✅
+ CSV with columns: feature, value, min, max, [min_flex, max_flex]
+
+
+ replace_existing
+ bool
+ ❌
+ 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 #}
+
+ {{ _("Assembly") }}
+
+ {{ _("Select an assembly...") }}
+ {% for assembly in assemblies %}
+ {{ assembly.title }}
+ {% endfor %}
+
+
+
+ {# CSV Content #}
+
+
+ {{ _("CSV Content") }}
+
+ 📄 {{ _("Load Sample") }}
+
+
+
+
+
+ {# Warning about replace #}
+
+
+ ⚠️ {{ _("This operation always replaces existing targets for the selected assembly.") }}
+
+
+
+ {# Action Buttons #}
+
+
+ ▶️ {{ _("Execute") }}
+ ⏳ {{ _("Running...") }}
+
+
+ 🔄 {{ _("Reset") }}
+
+
+
+ {# Response Display #}
+
+
+
+
+
+
+
+ 📋 {{ _("Copy JSON") }}
+
+
+
+
+
+
+ {% 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_id
+ UUID
+ ✅
+ Target assembly ID
+
+
+
+
+
+ {# Returns #}
+
+
{{ _("Returns") }}
+
+ AssemblyCSV → Configuration object with all CSV settings
+
+
+
+ {# Try It Section #}
+
+
+ 🧪 {{ _("Try It") }}
+
+
+
+ {{ _("Assembly") }}
+
+ {{ _("Select an assembly...") }}
+ {% for assembly in assemblies %}
+ {{ assembly.title }}
+ {% endfor %}
+
+
+
+
+
+ ▶️ {{ _("Execute") }}
+ ⏳ {{ _("Running...") }}
+
+
+
+
+
+
+
+
+
+
+ 📋 {{ _("Copy JSON") }}
+
+
+
+
+
+
+ {% 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") }}
+
+
+
+ Name
+ Type
+ Description
+
+
+
+ assembly_id UUID Target assembly
+ id_column str Column name for respondent ID
+ check_same_address bool Enable address deduplication
+ check_same_address_cols list[str] Address columns to check
+ columns_to_keep list[str] Columns to preserve in output
+ selection_algorithm str Algorithm: "maximin", "random", etc.
+ settings_confirmed bool Mark settings as confirmed
+
+
+
+
+ {# Try It Section #}
+
+
+ 🧪 {{ _("Try It") }}
+
+
+
+
+ {{ _("Assembly") }}
+
+ {{ _("Select...") }}
+ {% for assembly in assemblies %}
+ {{ assembly.title }}
+ {% endfor %}
+
+
+
+ {{ _("ID Column") }}
+
+
+
+ {{ _("Selection Algorithm") }}
+
+ {{ _("Select...") }}
+ maximin
+ random
+ legacy
+
+
+
+
+
+ {{ _("Check same address") }}
+
+
+
+ {{ _("Settings confirmed") }}
+
+
+
+
+
+
+ ▶️ {{ _("Execute") }}
+ ⏳ {{ _("Running...") }}
+
+
+ 🔄 {{ _("Reset") }}
+
+
+
+
+
+
+
+
+
+
+ 📋 {{ _("Copy JSON") }}
+
+
+
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+
+ {% endif %}
+
+ {# ===== SELECTION TAB ===== #}
+ {% if active_tab == 'selection' %}
+
+ {# check_db_selection_data #}
+ {% call card(title="check_db_selection_data()", composed=true) %}
+ {% call card_body() %}
+ {# Code Reference #}
+
+
+ sortition.py:386
+
+
+
+ 🔒 can_manage_assembly()
+
+
+
+
+ {# Description #}
+
+ {{ _("Synchronously validates targets and respondents against selection settings. Loads features (targets) and people (respondents) from the database and validates them. Returns detailed error messages and HTML reports.") }}
+
+
+ {# Parameters #}
+
+
{{ _("Parameters") }}
+
+
+
+ assembly_id
+ UUID
+ ✅
+ Target assembly ID
+
+
+
+
+
+ {# Returns #}
+
+
{{ _("Returns") }}
+
+ CheckDataResult(success, errors, features_report_html, people_report_html, num_features, num_people)
+
+
+
+ {# Used By #}
+
+
{{ _("Used By") }}
+
+ db_selection.check_db_data — POST /assemblies/<id>/db_select/check
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {# start_db_select_task #}
+ {% call card(title="start_db_select_task()", composed=true) %}
+ {% call card_body() %}
+ {# Code Reference #}
+
+
+ sortition.py:444
+
+
+
+ 🔒 can_manage_assembly()
+
+
+
+
+ {# Description #}
+
+ {{ _("Starts a Celery task to run selection from DB-stored data. Creates a SelectionRunRecord to track the task, then submits the run_select_from_db Celery task.") }}
+
+
+ {# Parameters #}
+
+
{{ _("Parameters") }}
+
+
+
+ assembly_id
+ UUID
+ ✅
+ Target assembly ID
+
+
+ test_selection
+ bool
+ ❌
+ If True, runs a test selection (doesn't save results)
+
+
+
+
+
+ {# Returns #}
+
+
{{ _("Returns") }}
+
+ uuid.UUID → Task ID for tracking the selection run
+
+
+
+ {# Used By #}
+
+
{{ _("Used By") }}
+
+ db_selection.start_db_selection — POST /assemblies/<id>/db_select/run
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {# get_selection_run_status #}
+ {% call card(title="get_selection_run_status()", composed=true) %}
+ {% call card_body() %}
+ {# Code Reference #}
+
+
+ sortition.py:610
+
+
+
+ 👁️ No permission required
+
+
+
+
+ {# Description #}
+
+ {{ _("Gets the status of a selection run task. Checks both the database record and Celery for current status. Handles cases where Celery has forgotten old tasks (>24hrs).") }}
+
+
+ {# Parameters #}
+
+
{{ _("Parameters") }}
+
+
+
+ task_id
+ UUID
+ ✅
+ SelectionRunRecord task_id (not Celery task ID)
+
+
+
+
+
+ {# Returns #}
+
+
{{ _("Returns") }}
+
+ RunResult(run_record, run_report, log_messages, success)
+
+
+
+ {# Used By #}
+
+
{{ _("Used By") }}
+
+ db_selection.view_db_selection_with_run, db_selection.db_selection_progress
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {# generate_selection_csvs #}
+ {% call card(title="generate_selection_csvs()", composed=true) %}
+ {% call card_body() %}
+ {# Code Reference #}
+
+
+ sortition.py:501
+
+
+
+ 👁️ No permission required
+
+
+
+
+ {# Description #}
+
+ {{ _("Generates CSV exports for selected and remaining respondents. Reconstructs the settings from the SelectionRunRecord and generates CSV content for download.") }}
+
+
+ {# Parameters #}
+
+
{{ _("Parameters") }}
+
+
+
+ assembly_id
+ UUID
+ ✅
+ Target assembly ID
+
+
+ task_id
+ UUID
+ ✅
+ SelectionRunRecord task_id
+
+
+
+
+
+ {# Returns #}
+
+
{{ _("Returns") }}
+
+ tuple[str, str] → (selected_csv_content, remaining_csv_content)
+
+
+
+ {# Error Cases #}
+
+
{{ _("Error Cases") }}
+
+ SelectionRunRecordNotFoundError — Task not found
+ InvalidSelection — Selection has not completed (no results)
+
+
+
+ {# Used By #}
+
+
{{ _("Used By") }}
+
+ db_selection.download_selected_csv, db_selection.download_remaining_csv
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {# cancel_task #}
+ {% call card(title="cancel_task()", composed=true) %}
+ {% call card_body() %}
+ {# Code Reference #}
+
+
+ sortition.py:657
+
+
+
+ 🔒 can_manage_assembly()
+
+
+
+
+ {# Description #}
+
+ {{ _("Cancels a running or pending task. Revokes the Celery task and updates the SelectionRunRecord to CANCELLED status.") }}
+
+
+ {# Parameters #}
+
+
{{ _("Parameters") }}
+
+
+
+ assembly_id
+ UUID
+ ✅
+ For permission checking
+
+
+ task_id
+ UUID
+ ✅
+ Task to cancel
+
+
+
+
+
+ {# Error Cases #}
+
+
{{ _("Error Cases") }}
+
+ InvalidSelection — Task not found or already finished
+ InsufficientPermissions — User cannot manage assembly
+
+
+
+ {# Used By #}
+
+
{{ _("Used By") }}
+
+ db_selection.cancel_db_selection — POST /assemblies/<id>/db_select/<run_id>/cancel
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {# check_and_update_task_health #}
+ {% call card(title="check_and_update_task_health()", composed=true) %}
+ {% call card_body() %}
+ {# Code Reference #}
+
+
+ sortition.py:829
+
+
+
+ 👁️ No permission required
+
+
+
+
+ {# Description #}
+
+ {{ _("Checks if a task is still alive and updates its status if it has died. Called during progress polling to detect crashed workers, revoked tasks, or timed-out tasks. Only checks tasks in PENDING or RUNNING state.") }}
+
+
+ {# Parameters #}
+
+
{{ _("Parameters") }}
+
+
+
+ task_id
+ UUID
+ ✅
+ SelectionRunRecord task_id
+
+
+ timeout_hours
+ int | None
+ ❌
+ Overrides env config timeout
+
+
+
+
+
+ {# Used By #}
+
+
{{ _("Used By") }}
+
+ db_selection.db_selection_progress — Progress polling during selection run
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {# get_respondent_attribute_columns #}
+ {% call card(title="get_respondent_attribute_columns()", composed=true) %}
+ {% call card_body() %}
+ {# Code Reference #}
+
+
+ respondent_service.py:223
+
+
+
+ 👁️ No permission required
+
+
+
+
+ {# Description #}
+
+ {{ _("Gets a sorted list of available respondent attribute column names for an assembly. Used to populate dropdown options for check_same_address_cols and columns_to_keep settings.") }}
+
+
+ {# Parameters #}
+
+
{{ _("Parameters") }}
+
+
+
+ assembly_id
+ UUID
+ ✅
+ Target assembly ID
+
+
+
+
+
+ {# Returns #}
+
+
{{ _("Returns") }}
+
+ list[str] → Sorted list of attribute column names from respondent data
+
+
+
+ {# Used By #}
+
+
{{ _("Used By") }}
+
+ db_selection.view_db_selection_settings — Selection settings page
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {# translate_run_report_to_html #}
+ {% call card(title="translate_run_report_to_html()", composed=true) %}
+ {% call card_body() %}
+ {# Code Reference #}
+
+
+ report_translation.py
+
+
+
+ {# Description #}
+
+ {{ _("Converts a sortition-algorithms RunReport to translated HTML. Translates all messages in the report using the application's i18n system and formats them for display.") }}
+
+
+ {# Parameters #}
+
+
{{ _("Parameters") }}
+
+
+
+ run_report
+ RunReport | None
+ ✅
+ Report from sortition-algorithms library
+
+
+
+
+
+ {# Returns #}
+
+
{{ _("Returns") }}
+
+ str → Translated HTML string for display
+
+
+
+ {# Used By #}
+
+
{{ _("Used By") }}
+
+ All selection routes that display run progress/results
+
+
+ {% endcall %}
+ {% endcall %}
+
+ {% endif %}
+
+ {# Toast Notification #}
+
+
+
+
+
+
+{% endblock %}
diff --git a/backend/templates/backoffice/showcase/input_component.html b/backend/templates/backoffice/showcase/input_component.html
index b72666d7..45faa584 100644
--- a/backend/templates/backoffice/showcase/input_component.html
+++ b/backend/templates/backoffice/showcase/input_component.html
@@ -1,16 +1,16 @@
{#
ABOUTME: Showcase section for input component
-ABOUTME: Displays text input and textarea variants with usage examples
+ABOUTME: Displays text input, textarea, and file input variants with usage examples
#}
{% from "backoffice/components/showcase_section.html" import showcase_section, showcase_title, showcase_description, showcase_example, showcase_tokens %}
-{% from "backoffice/components/input.html" import input, textarea %}
+{% from "backoffice/components/input.html" import input, textarea, file_input %}
{% macro input_section() %}
{% call showcase_section() %}
{{ showcase_title("Input Component") }}
- {{ showcase_description("Text inputs and textareas with labels, hints, and error states.") }}
+ {{ showcase_description("Text inputs, textareas, and file inputs with labels, hints, and error states.") }}
- {% call showcase_example('{% from "backoffice/components/input.html" import input, textarea %}
+ {% call showcase_example('{% from "backoffice/components/input.html" import input, textarea, file_input %}
{# Basic text input #}
{{ input("title", label="Assembly Title", placeholder="Enter title...") }}
@@ -19,15 +19,13 @@
{{ input("email", label="Email", type="email", required=true,
hint="We will send confirmation to this address.") }}
- {# Input with error state #}
- {{ input("title", label="Title", value=form.title, error=errors.title) }}
-
{# Textarea #}
{{ textarea("question", label="Assembly Question", rows=6,
placeholder="What question will the assembly address?") }}
- {# Disabled input #}
- {{ input("id", label="Assembly ID", value=assembly.id, disabled=true) }}') %}
+ {# File input for CSV upload #}
+ {{ file_input("csv_file", label="CSV File", accept=".csv", required=true,
+ hint="Select a CSV file to upload") }}') %}
{# Basic Input #}
@@ -82,6 +80,35 @@
Text
{{ textarea("demo-textarea-value", label="Description", value="This is some pre-filled content that demonstrates how the textarea handles existing text.", rows=4) }}
+
+ {# File Input Section #}
+
File Input
+
+
+ {# Basic File Input #}
+
+ Basic File Input
+ {{ file_input("demo-file-basic", label="CSV File", accept=".csv") }}
+
+
+ {# Required File Input with Hint #}
+
+ Required with Hint
+ {{ file_input("demo-file-required", label="Import File", accept=".csv", required=true, hint="Select a CSV file containing respondent data") }}
+
+
+ {# File Input with Error #}
+
+ Error State
+ {{ file_input("demo-file-error", label="CSV File", accept=".csv", error="Please select a valid CSV file") }}
+
+
+ {# Disabled File Input #}
+
+ Disabled
+ {{ file_input("demo-file-disabled", label="CSV File", accept=".csv", disabled=true) }}
+
+
{% endcall %}
{{ showcase_tokens([
diff --git a/backend/templates/targets/view_targets.html b/backend/templates/targets/view_targets.html
index b93877fd..f3438d5b 100644
--- a/backend/templates/targets/view_targets.html
+++ b/backend/templates/targets/view_targets.html
@@ -2,29 +2,27 @@
{% block assembly_breadcrumbs %}
{{ _("Targets") }} {% endblock %}
{% block assembly_content %}
{{ _("Target Categories") }}
-
{% if target_categories %}
{{ _("%(count)s categories defined.", count=target_categories|length) }}
{% else %}
{{ _("No target categories defined yet.") }}
{% endif %}
-
{# ── Check Targets Button ────────────────────────────── #}
{% if target_categories %}
{% endif %}
-
{# ── Check Result Display ────────────────────────────── #}
{% if check_result is defined and check_result %}
{% if check_result.success %}
-
+
@@ -43,9 +41,7 @@
{{ _("All checks passed") }}
{{ _("Target check found problems") }}
- {% for error_msg in check_result.global_errors %}
- {{ error_msg }}
- {% endfor %}
+ {% for error_msg in check_result.global_errors %}{{ error_msg }} {% endfor %}
@@ -66,9 +62,7 @@
{{ _("Target check found problems") }}
{% if check_result.global_errors %}
- {% for error_msg in check_result.global_errors %}
- {{ error_msg }}
- {% endfor %}
+ {% for error_msg in check_result.global_errors %}{{ error_msg }} {% endfor %}
{% endif %}
{{ _("See details below next to the affected targets.") }}
@@ -78,7 +72,6 @@
{{ _("Target check found problems") }}
{% for category in target_categories %}
@@ -88,12 +81,10 @@ {{ _("Target check found problems") }}
-
{# ── Add Category Form ────────────────────────────────── #}
{% if can_manage %}
{% include "targets/components/add_category_form.html" %}
{% endif %}
-
{# ── Respondent Attribute Columns ─────────────────────── #}
{% if respondent_attribute_columns is defined and respondent_attribute_columns and can_manage %}
{% set existing_category_names_lower = target_categories | map(attribute='name') | map('lower') | list %}
@@ -103,7 +94,6 @@ {{ _("Target check found problems") }}= 20) #}
{% set recommended_columns = [] %}
{% set high_cardinality_columns = [] %}
@@ -116,18 +106,17 @@ {{ _("Target check found problems") }}
{{ _("Respondent data columns") }}
-
diff --git a/backend/tests/bdd/config.py b/backend/tests/bdd/config.py
index 0a2a2cf7..308ea277 100644
--- a/backend/tests/bdd/config.py
+++ b/backend/tests/bdd/config.py
@@ -39,6 +39,8 @@ class Urls:
backoffice_edit_assembly = "{base}/backoffice/assembly/{assembly_id}/edit"
backoffice_members_assembly = "{base}/backoffice/assembly/{assembly_id}/members"
backoffice_data_assembly = "{base}/backoffice/assembly/{assembly_id}/data"
+ backoffice_targets_assembly = "{base}/backoffice/assembly/{assembly_id}/targets"
+ backoffice_respondents_assembly = "{base}/backoffice/assembly/{assembly_id}/respondents"
backoffice_selection_assembly = "{base}/backoffice/assembly/{assembly_id}/selection"
assembly_urls: typing.ClassVar = {
@@ -79,6 +81,20 @@ def backoffice_data_assembly_url(cls, assembly_id: str, source: str = "", mode:
url += "?" + urllib.parse.urlencode(params)
return url
+ @classmethod
+ def backoffice_targets_assembly_url(cls, assembly_id: str, source: str = "") -> str:
+ url = cls.backoffice_targets_assembly.format(base=cls.base, assembly_id=assembly_id)
+ if source:
+ url += "?" + urllib.parse.urlencode({"source": source})
+ return url
+
+ @classmethod
+ def backoffice_respondents_assembly_url(cls, assembly_id: str, source: str = "") -> str:
+ url = cls.backoffice_respondents_assembly.format(base=cls.base, assembly_id=assembly_id)
+ if source:
+ url += "?" + urllib.parse.urlencode({"source": source})
+ return url
+
@classmethod
def backoffice_selection_assembly_url(cls, assembly_id: str) -> str:
return cls.backoffice_selection_assembly.format(base=cls.base, assembly_id=assembly_id)
diff --git a/backend/tests/bdd/conftest.py b/backend/tests/bdd/conftest.py
index 67c34c32..d81713e8 100644
--- a/backend/tests/bdd/conftest.py
+++ b/backend/tests/bdd/conftest.py
@@ -1,6 +1,8 @@
+import contextlib
import json
import os
import shutil
+import signal
import subprocess
import urllib.request
import uuid
@@ -14,7 +16,7 @@
from sqlalchemy.orm import Session, sessionmaker
from opendlp.adapters import database, orm
-from opendlp.config import PostgresCfg
+from opendlp.config import PostgresCfg, to_bool
from opendlp.domain.assembly import Assembly, AssemblyGSheet, SelectionRunRecord
from opendlp.domain.users import User, UserAssemblyRole
from opendlp.domain.value_objects import AssemblyRole, GlobalRole, SelectionRunStatus, SelectionTaskType
@@ -39,11 +41,45 @@
CSV_FIXTURES_DIR = BACKEND_PATH / "tests" / "csv_fixtures" / "selection_data"
-def _build_coverage_command(base_module_args: list[str]) -> list[str]:
- """Wrap a command with coverage run if BDD_COVERAGE is enabled."""
- if os.environ.get("BDD_COVERAGE", "").lower() in ("1", "true"):
- return ["uv", "run", "coverage", "run", "--parallel-mode", "-m", *base_module_args]
- return ["uv", "run", *base_module_args]
+def _add_coverage_env(env: dict[str, str], label: str) -> None:
+ """Configure environment variables for automatic subprocess coverage.
+
+ Instead of wrapping commands with ``coverage run``, we use the
+ COVERAGE_PROCESS_START mechanism: coverage's installed .pth file
+ (a1_coverage.pth) calls ``coverage.process_startup()`` on every Python
+ start. When COVERAGE_PROCESS_START is set, this auto-instruments the
+ process. Each subprocess gets its own COVERAGE_FILE so data can be
+ reliably combined after the test run.
+
+ See https://coverage.readthedocs.io/en/latest/subprocess.html
+ """
+ if to_bool(os.environ.get("BDD_COVERAGE", "")):
+ rcfile = str(BACKEND_PATH / "pyproject.toml")
+ data_file = str(BACKEND_PATH / f".coverage.bdd-{label}") if label else str(BACKEND_PATH / ".coverage.bdd")
+ env["COVERAGE_PROCESS_START"] = rcfile
+ env["COVERAGE_FILE"] = data_file
+
+
+def _terminate_subprocess(process: subprocess.Popen, label: str) -> None:
+ """Terminate a subprocess and wait for it to exit cleanly.
+
+ Sends SIGTERM to the process group so that all child processes also receive
+ the signal. Falls back to SIGKILL after 10s.
+ """
+ try:
+ pgid = os.getpgid(process.pid)
+ os.killpg(pgid, signal.SIGTERM)
+ except ProcessLookupError:
+ print(f"{label} already exited")
+ return
+ try:
+ process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ print(f"{label} did not exit within 10s, sending SIGKILL")
+ with contextlib.suppress(ProcessLookupError):
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
+ process.wait()
+ print(f"{label} stopped (exit code: {process.returncode})")
expect.set_options(timeout=PLAYWRIGHT_TIMEOUT)
@@ -173,11 +209,13 @@ def test_server(test_database, csv_test_data_dir):
# Use CSV data source for testing instead of Google Sheets
env["USE_CSV_DATA_SOURCE"] = "true"
env["CSV_TEST_DATA_DIR"] = str(csv_test_data_dir)
+ _add_coverage_env(env, label="flask")
process = subprocess.Popen( # noqa: S603
- _build_coverage_command(["flask", "run", f"--port={BDD_PORT}", "--host=127.0.0.1"]),
+ ["uv", "run", "flask", "run", f"--port={BDD_PORT}", "--host=127.0.0.1"],
cwd=BACKEND_PATH,
env=env,
+ start_new_session=True,
)
# Wait for server to start
@@ -186,14 +224,7 @@ def test_server(test_database, csv_test_data_dir):
yield
- # Cleanup
- process.terminate()
- try:
- process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- process.kill()
- process.wait()
- print("Test server stopped")
+ _terminate_subprocess(process, "Test server")
@pytest.fixture(scope="session")
@@ -218,11 +249,18 @@ def test_celery_worker(test_database, csv_test_data_dir):
# Use CSV data source for testing instead of Google Sheets
env["USE_CSV_DATA_SOURCE"] = "true"
env["CSV_TEST_DATA_DIR"] = str(csv_test_data_dir)
+ _add_coverage_env(env, label="celery")
+ celery_args = ["uv", "run", "celery", "--app", "opendlp.entrypoints.celery.tasks", "worker", "--loglevel=info"]
+ # When collecting coverage, use solo pool to avoid prefork spawning many child
+ # processes that each inherit coverage instrumentation and fight over the data file.
+ if to_bool(os.environ.get("BDD_COVERAGE", "")):
+ celery_args.extend(["--pool=solo"])
process = subprocess.Popen( # noqa: S603
- _build_coverage_command(["celery", "--app", "opendlp.entrypoints.celery.tasks", "worker", "--loglevel=info"]),
+ celery_args,
cwd=BACKEND_PATH,
env=env,
+ start_new_session=True,
)
# wait for celery worker
wait_for_celery_worker_to_come_up(celery_app)
@@ -230,14 +268,7 @@ def test_celery_worker(test_database, csv_test_data_dir):
yield
- # Cleanup
- process.terminate()
- try:
- process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- process.kill()
- process.wait()
- print("Test celery worker stopped")
+ _terminate_subprocess(process, "Test celery worker")
@pytest.fixture(scope="session")
diff --git a/backend/tests/bdd/test_backoffice.py b/backend/tests/bdd/test_backoffice.py
index 33c2b875..095cef33 100644
--- a/backend/tests/bdd/test_backoffice.py
+++ b/backend/tests/bdd/test_backoffice.py
@@ -2,12 +2,15 @@
ABOUTME: Tests the separate design system used for admin interfaces"""
import re
+import uuid
import pytest
from playwright.sync_api import Page, expect
from pytest_bdd import given, parsers, scenarios, then, when
from opendlp.domain.assembly import Assembly
+from opendlp.domain.respondents import Respondent
+from opendlp.domain.targets import TargetCategory, TargetValue
from opendlp.domain.value_objects import AssemblyRole
from opendlp.service_layer.assembly_service import add_assembly_gsheet, create_assembly
from opendlp.service_layer.unit_of_work import SqlAlchemyUnitOfWork
@@ -20,6 +23,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 +1116,199 @@ 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"))
+
+
+# Update Number to Select Tests
+
+
+@given(parsers.parse('there is an assembly called "{title}" with number to select "{number}"'))
+def create_test_assembly_with_number_to_select_quoted(title: str, number: str, admin_user, test_database):
+ """Create a test assembly with a specific number_to_select for the admin user."""
+ session_factory = test_database
+ uow = SqlAlchemyUnitOfWork(session_factory)
+ assembly = create_assembly(
+ uow=uow,
+ title=title,
+ created_by_user_id=admin_user.id,
+ number_to_select=int(number),
+ )
+ _assembly_name_id_cache.add_existing(title, assembly)
+
+
+@when(parsers.parse('I visit the selection page for "{title}"'))
+def visit_selection_page_for_assembly(page: Page, title: str, test_database):
+ """Navigate directly to the selection page for a specific assembly."""
+ assembly_id = _assembly_name_id_cache.find_title(title, test_database)
+ if assembly_id:
+ page.goto(Urls.backoffice_selection_assembly_url(assembly_id))
+ page.wait_for_load_state("networkidle")
+
+
+@when('I click the "Edit" link next to number to select')
+def click_edit_number_to_select(page: Page):
+ """Click the Edit link next to the number to select field on selection page."""
+ # The edit link is typically a query param that shows the edit form
+ edit_link = (
+ page
+ .locator("a[href*='edit_number=1'], a:has-text('Edit')")
+ .filter(has=page.locator(":scope").locator("xpath=./ancestor::*[contains(., 'Number to Select')]"))
+ .first
+ )
+ if edit_link.count() == 0:
+ # Fallback: try to find any edit link near the number to select section
+ edit_link = page.locator("a[href*='edit_number']").first
+ edit_link.click()
+ page.wait_for_load_state("networkidle")
+
+
+@when('I click the "Save" button')
+def click_save_button(page: Page):
+ """Click the Save button."""
+ save_button = page.locator("button:has-text('Save'), input[type='submit'][value*='Save']").first
+ save_button.click()
+ page.wait_for_load_state("networkidle")
+
+
+@then(parsers.parse('I should be on the selection page for "{title}"'))
+def should_be_on_selection_page(page: Page, title: str, test_database):
+ """Verify we're on the selection page for the specified assembly."""
+ assembly_id = _assembly_name_id_cache.find_title(title, test_database)
+ if assembly_id:
+ expect(page).to_have_url(re.compile(rf".*/backoffice/assembly/{assembly_id}/selection"))
+
+
+@then(parsers.parse('the number to select should be "{number}"'))
+def number_to_select_should_be(page: Page, number: str):
+ """Verify the number to select displays the expected value."""
+ # Look for the number in the summary section or the field value
+ page_content = page.content()
+ assert number in page_content, f"Expected number to select '{number}' not found in page"
+
+
+# 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."""
+ # Use the Assembly sections nav specifically to avoid matching breadcrumbs
+ tab = page.locator("nav[aria-label='Assembly sections'] a, nav[aria-label='Assembly sections'] 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='Assembly sections'] span[data-disabled='true']").filter(
+ has_text=tab_name
+ )
+ expect(disabled_tab).to_be_visible()
+
+
+@then(parsers.parse('the "{tab_name}" tab should be enabled'))
+def tab_should_be_enabled(page: Page, tab_name: str):
+ """Verify a specific tab is enabled (shown as a clickable link)."""
+ # Enabled tabs are rendered as anchor tags, not spans
+ enabled_tab = page.locator("nav[aria-label='Assembly sections'] a").filter(has_text=tab_name)
+ expect(enabled_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='Assembly sections'] a, nav[aria-label='Assembly sections'] span").filter(
+ has_text=tab_name
+ )
+ expect(tab).to_have_count(0)
+
+
+@when(parsers.parse('I visit the assembly targets page for "{title}"'))
+def visit_assembly_targets_page(page: Page, title: str, test_database):
+ """Navigate directly to the assembly targets page."""
+ assembly_id = _assembly_name_id_cache.find_title(title, test_database)
+ if assembly_id:
+ page.goto(Urls.backoffice_targets_assembly_url(assembly_id))
+
+
+@when(parsers.parse('I visit the assembly respondents page for "{title}"'))
+def visit_assembly_respondents_page(page: Page, title: str, test_database):
+ """Navigate directly to the assembly respondents page."""
+ assembly_id = _assembly_name_id_cache.find_title(title, test_database)
+ if assembly_id:
+ page.goto(Urls.backoffice_respondents_assembly_url(assembly_id))
+
+
+@given(parsers.parse('the assembly "{title}" has targets uploaded'))
+def assembly_has_targets_uploaded(title: str, test_database):
+ """Create target categories for an assembly to simulate CSV upload."""
+ assembly_id = _assembly_name_id_cache.find_title(title, test_database)
+ if not assembly_id:
+ return
+
+ session_factory = test_database
+ uow = SqlAlchemyUnitOfWork(session_factory)
+ with uow:
+ # Create sample target categories
+ category = TargetCategory(
+ assembly_id=uuid.UUID(assembly_id),
+ name="Gender",
+ sort_order=0,
+ )
+ category.values = [
+ TargetValue(value="Male", min=10, max=20),
+ TargetValue(value="Female", min=10, max=20),
+ ]
+ uow.target_categories.add(category)
+ uow.commit()
+
+
+@given(parsers.parse('the assembly "{title}" has respondents uploaded'))
+def assembly_has_respondents_uploaded(title: str, test_database):
+ """Create respondents for an assembly to simulate CSV upload."""
+ assembly_id = _assembly_name_id_cache.find_title(title, test_database)
+ if not assembly_id:
+ return
+
+ session_factory = test_database
+ uow = SqlAlchemyUnitOfWork(session_factory)
+ with uow:
+ # Create sample respondents
+ respondents = []
+ for i in range(5):
+ respondent = Respondent(
+ assembly_id=uuid.UUID(assembly_id),
+ external_id=f"test-{i}",
+ attributes={"name": f"Test Person {i}", "Gender": "Male" if i % 2 == 0 else "Female"},
+ )
+ respondents.append(respondent)
+ uow.respondents.bulk_add(respondents)
+ uow.commit()
+
+
+@then("the Target upload button should be enabled")
+def target_upload_button_enabled(page: Page):
+ """Verify the Target section has an enabled upload button."""
+ # Look for an enabled submit button in the Target section
+ target_section = page.locator("text=Target").locator("..").locator("..")
+ upload_button = target_section.locator("button[type='submit'], input[type='submit']").first
+ expect(upload_button).to_be_enabled()
+
+
+@then("the People upload button should be disabled")
+def people_upload_button_disabled(page: Page):
+ """Verify the People section has a disabled upload button."""
+ # Look for a disabled button in the People section
+ people_section = page.locator("text=People").locator("..").locator("..")
+ disabled_button = people_section.locator("button[disabled], span[data-disabled='true']").first
+ expect(disabled_button).to_be_visible()
+
+
+@then("the People upload button should be enabled")
+def people_upload_button_enabled(page: Page):
+ """Verify the People section has an enabled upload button."""
+ people_section = page.locator("text=People").locator("..").locator("..")
+ upload_button = people_section.locator("button[type='submit'], input[type='submit']").first
+ expect(upload_button).to_be_enabled()
diff --git a/backend/tests/e2e/test_backoffice_assembly.py b/backend/tests/e2e/test_backoffice_assembly.py
index 00404986..81d94acb 100644
--- a/backend/tests/e2e/test_backoffice_assembly.py
+++ b/backend/tests/e2e/test_backoffice_assembly.py
@@ -2,13 +2,17 @@
ABOUTME: Tests assembly creation, viewing, editing, and user management through the backoffice interface"""
from datetime import UTC, datetime, timedelta
+from io import BytesIO
import pytest
from flask.testing import FlaskClient
from opendlp.domain.assembly import Assembly
+from opendlp.domain.respondents import Respondent
+from opendlp.domain.targets import TargetCategory, TargetValue
from opendlp.domain.users import User
from opendlp.domain.value_objects import AssemblyRole, GlobalRole
+from opendlp.service_layer.assembly_service import create_assembly
from opendlp.service_layer.permissions import can_manage_assembly, can_view_assembly
from opendlp.service_layer.unit_of_work import SqlAlchemyUnitOfWork
from opendlp.service_layer.user_service import create_user, grant_user_assembly_role
@@ -754,3 +758,433 @@ def test_search_users_no_matches(self, client: FlaskClient, admin_user: User, ex
assert response.status_code == 200
data = response.get_json()
assert data == []
+
+
+class TestBackofficeCsvUpload:
+ """Test CSV upload functionality in backoffice."""
+
+ def test_upload_respondents_with_id_column(
+ self,
+ logged_in_admin,
+ existing_assembly: Assembly,
+ postgres_session_factory,
+ ):
+ """Test uploading respondents CSV with a specified id_column."""
+ csv_content = "name,person_id,age\nAlice,P001,30\nBob,P002,25"
+
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{existing_assembly.id}/data/upload-respondents",
+ data={
+ "file": (BytesIO(csv_content.encode()), "respondents.csv"),
+ "id_column": "person_id",
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{existing_assembly.id}/data?source=csv"
+ ),
+ },
+ content_type="multipart/form-data",
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 302
+ assert "source=csv" in response.location
+
+ # Verify respondents were created with correct external_id
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ respondents = uow.respondents.get_by_assembly_id(existing_assembly.id)
+ assert len(respondents) == 2
+ external_ids = {r.external_id for r in respondents}
+ assert external_ids == {"P001", "P002"}
+ # Verify other columns became attributes
+ for r in respondents:
+ assert "name" in r.attributes
+ assert "age" in r.attributes
+ assert "person_id" not in r.attributes # id_column should not be in attributes
+
+ def test_upload_respondents_without_id_column_uses_first_column(
+ self,
+ logged_in_admin,
+ existing_assembly: Assembly,
+ postgres_session_factory,
+ ):
+ """Test uploading respondents CSV without id_column uses first column as ID."""
+ csv_content = "participant_id,name,city\nID001,Charlie,London\nID002,Diana,Paris"
+
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{existing_assembly.id}/data/upload-respondents",
+ data={
+ "file": (BytesIO(csv_content.encode()), "respondents.csv"),
+ "id_column": "", # Empty means use first column
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{existing_assembly.id}/data?source=csv"
+ ),
+ },
+ content_type="multipart/form-data",
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 302
+
+ # Verify respondents were created using first column as external_id
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ respondents = uow.respondents.get_by_assembly_id(existing_assembly.id)
+ assert len(respondents) == 2
+ external_ids = {r.external_id for r in respondents}
+ assert external_ids == {"ID001", "ID002"}
+ # First column should not be in attributes
+ for r in respondents:
+ assert "participant_id" not in r.attributes
+ assert "name" in r.attributes
+ assert "city" in r.attributes
+
+ def test_upload_respondents_with_invalid_id_column_shows_error(
+ self,
+ logged_in_admin,
+ existing_assembly: Assembly,
+ ):
+ """Test uploading respondents CSV with non-existent id_column shows error."""
+ csv_content = "name,email,age\nAlice,alice@example.com,30"
+
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{existing_assembly.id}/data/upload-respondents",
+ data={
+ "file": (BytesIO(csv_content.encode()), "respondents.csv"),
+ "id_column": "nonexistent_column",
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{existing_assembly.id}/data?source=csv"
+ ),
+ },
+ content_type="multipart/form-data",
+ follow_redirects=True,
+ )
+
+ assert response.status_code == 200
+ # Should show error message about invalid column
+ assert b"nonexistent_column" in response.data or b"Invalid CSV" in response.data
+
+ def test_upload_respondents_shows_success_message(
+ self,
+ logged_in_admin,
+ existing_assembly: Assembly,
+ ):
+ """Test that successful upload shows success flash message."""
+ csv_content = "id,name\n1,Test User"
+
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{existing_assembly.id}/data/upload-respondents",
+ data={
+ "file": (BytesIO(csv_content.encode()), "respondents.csv"),
+ "id_column": "",
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{existing_assembly.id}/data?source=csv"
+ ),
+ },
+ content_type="multipart/form-data",
+ follow_redirects=True,
+ )
+
+ assert response.status_code == 200
+ assert b"success" in response.data.lower() or b"uploaded" in response.data.lower()
+
+ def test_upload_respondents_redirects_when_not_logged_in(
+ self,
+ client,
+ existing_assembly: Assembly,
+ ):
+ """Test that unauthenticated users are redirected to login."""
+ csv_content = "id,name\n1,Test"
+
+ response = client.post(
+ f"/backoffice/assembly/{existing_assembly.id}/data/upload-respondents",
+ data={
+ "file": (BytesIO(csv_content.encode()), "respondents.csv"),
+ },
+ content_type="multipart/form-data",
+ )
+
+ assert response.status_code == 302
+ assert "login" in response.location
+
+ def test_upload_targets_csv_success(
+ self,
+ logged_in_admin,
+ existing_assembly: Assembly,
+ postgres_session_factory,
+ ):
+ """Test successfully uploading a targets CSV file."""
+ # CSV format must use: feature,value,min,max (matching sortition-algorithms library)
+ csv_content = "feature,value,min,max\nGender,Male,10,20\nGender,Female,10,20\nAge,18-30,5,15\nAge,31-50,5,15"
+
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{existing_assembly.id}/data/upload-targets",
+ data={
+ "file": (BytesIO(csv_content.encode()), "targets.csv"),
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{existing_assembly.id}/data?source=csv"
+ ),
+ },
+ content_type="multipart/form-data",
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 302
+ assert "source=csv" in response.location
+
+ # Verify targets were created
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ categories = uow.target_categories.get_by_assembly_id(existing_assembly.id)
+ assert len(categories) == 2 # Gender and Age
+ category_names = {c.name for c in categories}
+ assert category_names == {"Gender", "Age"}
+
+ def test_upload_targets_csv_shows_success_message(
+ self,
+ logged_in_admin,
+ existing_assembly: Assembly,
+ ):
+ """Test that successful targets upload shows success flash message."""
+ # CSV format must use: feature,value,min,max (matching sortition-algorithms library)
+ csv_content = "feature,value,min,max\nRegion,North,5,10\nRegion,South,5,10"
+
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{existing_assembly.id}/data/upload-targets",
+ data={
+ "file": (BytesIO(csv_content.encode()), "targets.csv"),
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{existing_assembly.id}/data?source=csv"
+ ),
+ },
+ content_type="multipart/form-data",
+ follow_redirects=True,
+ )
+
+ assert response.status_code == 200
+ assert b"success" in response.data.lower() or b"uploaded" in response.data.lower()
+
+
+@pytest.fixture
+def assembly_with_targets(postgres_session_factory, admin_user):
+ """Create an assembly with target categories for testing."""
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ assembly = create_assembly(
+ uow=uow,
+ title="Assembly with Targets",
+ created_by_user_id=admin_user.id,
+ question="What is the question?",
+ first_assembly_date=(datetime.now(UTC).date() + timedelta(days=30)),
+ )
+ assembly_id = assembly.id
+
+ # Add target categories
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ category = TargetCategory(
+ assembly_id=assembly_id,
+ name="Gender",
+ sort_order=0,
+ )
+ category.values = [
+ TargetValue(value="Male", min=10, max=20),
+ TargetValue(value="Female", min=10, max=20),
+ ]
+ uow.target_categories.add(category)
+ uow.commit()
+
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ assembly = uow.assemblies.get(assembly_id)
+ return assembly.create_detached_copy()
+
+
+@pytest.fixture
+def assembly_with_respondents(postgres_session_factory, admin_user):
+ """Create an assembly with respondents for testing."""
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ assembly = create_assembly(
+ uow=uow,
+ title="Assembly with Respondents",
+ created_by_user_id=admin_user.id,
+ question="What is the question?",
+ first_assembly_date=(datetime.now(UTC).date() + timedelta(days=30)),
+ )
+ assembly_id = assembly.id
+
+ # Add target categories first (required for respondents to have a data source)
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ category = TargetCategory(
+ assembly_id=assembly_id,
+ name="Gender",
+ sort_order=0,
+ )
+ category.values = [
+ TargetValue(value="Male", min=10, max=20),
+ TargetValue(value="Female", min=10, max=20),
+ ]
+ uow.target_categories.add(category)
+ uow.commit()
+
+ # Add respondents
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ respondents = []
+ for i in range(5):
+ respondent = Respondent(
+ assembly_id=assembly_id,
+ external_id=f"test-{i}",
+ attributes={"name": f"Test Person {i}", "Gender": "Male" if i % 2 == 0 else "Female"},
+ )
+ respondents.append(respondent)
+ uow.respondents.bulk_add(respondents)
+ uow.commit()
+
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ assembly = uow.assemblies.get(assembly_id)
+ return assembly.create_detached_copy()
+
+
+class TestBackofficeCsvDelete:
+ """Test CSV delete functionality in backoffice."""
+
+ def test_delete_targets_success(
+ self,
+ logged_in_admin,
+ assembly_with_targets: Assembly,
+ postgres_session_factory,
+ ):
+ """Test successfully deleting targets for an assembly."""
+ # Verify targets exist before deletion
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ categories = uow.target_categories.get_by_assembly_id(assembly_with_targets.id)
+ assert len(categories) > 0
+
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{assembly_with_targets.id}/data/delete-targets",
+ data={
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{assembly_with_targets.id}/data?source=csv"
+ ),
+ },
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 302
+ assert "source=csv" in response.location
+
+ # Verify targets were deleted
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ categories = uow.target_categories.get_by_assembly_id(assembly_with_targets.id)
+ assert len(categories) == 0
+
+ def test_delete_targets_shows_success_message(
+ self,
+ logged_in_admin,
+ assembly_with_targets: Assembly,
+ ):
+ """Test that successful targets deletion shows success flash message."""
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{assembly_with_targets.id}/data/delete-targets",
+ data={
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{assembly_with_targets.id}/data?source=csv"
+ ),
+ },
+ follow_redirects=True,
+ )
+
+ assert response.status_code == 200
+ assert b"deleted" in response.data.lower() or b"success" in response.data.lower()
+
+ def test_delete_respondents_success(
+ self,
+ logged_in_admin,
+ assembly_with_respondents: Assembly,
+ postgres_session_factory,
+ ):
+ """Test successfully deleting respondents for an assembly."""
+ # Verify respondents exist before deletion
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ respondents = uow.respondents.get_by_assembly_id(assembly_with_respondents.id)
+ assert len(respondents) > 0
+
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{assembly_with_respondents.id}/data/delete-respondents",
+ data={
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{assembly_with_respondents.id}/data?source=csv"
+ ),
+ },
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 302
+ assert "source=csv" in response.location
+
+ # Verify respondents were deleted
+ with SqlAlchemyUnitOfWork(postgres_session_factory) as uow:
+ respondents = uow.respondents.get_by_assembly_id(assembly_with_respondents.id)
+ assert len(respondents) == 0
+
+ def test_delete_respondents_shows_success_message(
+ self,
+ logged_in_admin,
+ assembly_with_respondents: Assembly,
+ ):
+ """Test that successful respondents deletion shows success flash message."""
+ response = logged_in_admin.post(
+ f"/backoffice/assembly/{assembly_with_respondents.id}/data/delete-respondents",
+ data={
+ "csrf_token": get_csrf_token(
+ logged_in_admin, f"/backoffice/assembly/{assembly_with_respondents.id}/data?source=csv"
+ ),
+ },
+ follow_redirects=True,
+ )
+
+ assert response.status_code == 200
+ assert b"deleted" in response.data.lower() or b"success" in response.data.lower()
+
+
+class TestBackofficeCsvViewPages:
+ """Test targets and respondents view pages with CSV data source."""
+
+ def test_view_targets_page_with_csv_source(
+ self,
+ logged_in_admin,
+ assembly_with_targets: Assembly,
+ ):
+ """Test that targets page loads successfully for CSV data source."""
+ response = logged_in_admin.get(f"/backoffice/assembly/{assembly_with_targets.id}/targets")
+
+ assert response.status_code == 200
+ # Should see the page title containing "Targets"
+ assert b"Targets" in response.data
+
+ def test_view_respondents_page_with_csv_source(
+ self,
+ logged_in_admin,
+ assembly_with_respondents: Assembly,
+ ):
+ """Test that respondents page loads successfully for CSV data source."""
+ response = logged_in_admin.get(f"/backoffice/assembly/{assembly_with_respondents.id}/respondents")
+
+ assert response.status_code == 200
+ # Should see the page content
+ assert b"Respondents" in response.data or b"respondents" in response.data.lower()
+
+ def test_view_targets_page_redirects_when_not_logged_in(
+ self,
+ client,
+ assembly_with_targets: Assembly,
+ ):
+ """Test that unauthenticated users are redirected to login."""
+ response = client.get(f"/backoffice/assembly/{assembly_with_targets.id}/targets")
+
+ assert response.status_code == 302
+ assert "login" in response.location
+
+ def test_view_respondents_page_redirects_when_not_logged_in(
+ self,
+ client,
+ assembly_with_respondents: Assembly,
+ ):
+ """Test that unauthenticated users are redirected to login."""
+ response = client.get(f"/backoffice/assembly/{assembly_with_respondents.id}/respondents")
+
+ assert response.status_code == 302
+ assert "login" in response.location
diff --git a/backend/tests/fakes.py b/backend/tests/fakes.py
index 05e3e2e2..a18c0ca4 100644
--- a/backend/tests/fakes.py
+++ b/backend/tests/fakes.py
@@ -534,6 +534,11 @@ def delete(self, item: Respondent) -> None:
def bulk_add(self, items: list[Respondent]) -> None:
self._items.extend(items)
+ def delete_all_for_assembly(self, assembly_id: uuid.UUID) -> int:
+ before = len(self._items)
+ self._items = [r for r in self._items if r.assembly_id != assembly_id]
+ return before - len(self._items)
+
def bulk_mark_as_selected(
self,
assembly_id: uuid.UUID,
diff --git a/backend/tests/integration/test_assembly_service_targets.py b/backend/tests/integration/test_assembly_service_targets.py
index bbbf5d53..1bf7498a 100644
--- a/backend/tests/integration/test_assembly_service_targets.py
+++ b/backend/tests/integration/test_assembly_service_targets.py
@@ -9,12 +9,13 @@
from opendlp.domain.assembly import Assembly
from opendlp.domain.users import User
from opendlp.domain.value_objects import GlobalRole
-from opendlp.service_layer import assembly_service
+from opendlp.service_layer import assembly_service, respondent_service
from opendlp.service_layer.exceptions import (
AssemblyNotFoundError,
InsufficientPermissions,
InvalidSelection,
NotFoundError,
+ UserNotFoundError,
)
from opendlp.service_layer.unit_of_work import SqlAlchemyUnitOfWork
@@ -447,3 +448,143 @@ def test_delete_nonexistent_value_raises(self, admin_user: User, test_assembly:
uow2 = SqlAlchemyUnitOfWork(postgres_session_factory)
with pytest.raises(NotFoundError):
assembly_service.delete_target_value(uow2, admin_user.id, test_assembly.id, category.id, uuid.uuid4())
+
+
+class TestDeleteTargetsForAssembly:
+ def test_delete_all_targets(self, admin_user: User, test_assembly: Assembly, postgres_session_factory):
+ """Test deleting all target categories for an assembly."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ assembly_service.create_target_category(uow, admin_user.id, test_assembly.id, "Gender", sort_order=0)
+ uow2 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ assembly_service.create_target_category(uow2, admin_user.id, test_assembly.id, "Age", sort_order=1)
+
+ uow3 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ count = assembly_service.delete_targets_for_assembly(uow3, admin_user.id, test_assembly.id)
+ assert count == 2
+
+ # Verify they're gone
+ uow4 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ cats = assembly_service.get_targets_for_assembly(uow4, admin_user.id, test_assembly.id)
+ assert len(cats) == 0
+
+ def test_delete_targets_returns_zero_when_none_exist(
+ self, admin_user: User, test_assembly: Assembly, postgres_session_factory
+ ):
+ """Test that deleting targets when none exist returns 0."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ count = assembly_service.delete_targets_for_assembly(uow, admin_user.id, test_assembly.id)
+ assert count == 0
+
+ def test_delete_targets_insufficient_permissions(
+ self, regular_user: User, test_assembly: Assembly, postgres_session_factory
+ ):
+ """Test that a regular user cannot delete targets."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ with pytest.raises(InsufficientPermissions):
+ assembly_service.delete_targets_for_assembly(uow, regular_user.id, test_assembly.id)
+
+ def test_delete_targets_nonexistent_assembly(self, admin_user: User, postgres_session_factory):
+ """Test that deleting targets for a nonexistent assembly raises error."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ with pytest.raises(AssemblyNotFoundError):
+ assembly_service.delete_targets_for_assembly(uow, admin_user.id, uuid.uuid4())
+
+ def test_delete_targets_nonexistent_user(self, test_assembly: Assembly, postgres_session_factory):
+ """Test that deleting targets with a nonexistent user raises error."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ with pytest.raises(UserNotFoundError):
+ assembly_service.delete_targets_for_assembly(uow, uuid.uuid4(), test_assembly.id)
+
+ def test_delete_targets_does_not_affect_other_assembly(
+ self, admin_user: User, test_assembly: Assembly, other_assembly: Assembly, postgres_session_factory
+ ):
+ """Test that deleting targets for one assembly doesn't affect another."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ assembly_service.create_target_category(uow, admin_user.id, test_assembly.id, "Gender")
+ uow2 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ assembly_service.create_target_category(uow2, admin_user.id, other_assembly.id, "Age")
+
+ # Delete only from test_assembly
+ uow3 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ assembly_service.delete_targets_for_assembly(uow3, admin_user.id, test_assembly.id)
+
+ # other_assembly targets should still exist
+ uow4 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ other_cats = assembly_service.get_targets_for_assembly(uow4, admin_user.id, other_assembly.id)
+ assert len(other_cats) == 1
+ assert other_cats[0].name == "Age"
+
+
+class TestDeleteRespondentsForAssembly:
+ def test_delete_all_respondents(self, admin_user: User, test_assembly: Assembly, postgres_session_factory):
+ """Test deleting all respondents for an assembly."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ respondent_service.create_respondent(
+ uow, admin_user.id, test_assembly.id, external_id="NB001", attributes={"Gender": "Male"}
+ )
+ uow2 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ respondent_service.create_respondent(
+ uow2, admin_user.id, test_assembly.id, external_id="NB002", attributes={"Gender": "Female"}
+ )
+
+ uow3 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ count = assembly_service.delete_respondents_for_assembly(uow3, admin_user.id, test_assembly.id)
+ assert count == 2
+
+ # Verify they're gone
+ uow4 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ with uow4:
+ respondents = uow4.respondents.get_by_assembly_id(test_assembly.id)
+ assert len(respondents) == 0
+
+ def test_delete_respondents_returns_zero_when_none_exist(
+ self, admin_user: User, test_assembly: Assembly, postgres_session_factory
+ ):
+ """Test that deleting respondents when none exist returns 0."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ count = assembly_service.delete_respondents_for_assembly(uow, admin_user.id, test_assembly.id)
+ assert count == 0
+
+ def test_delete_respondents_insufficient_permissions(
+ self, regular_user: User, test_assembly: Assembly, postgres_session_factory
+ ):
+ """Test that a regular user cannot delete respondents."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ with pytest.raises(InsufficientPermissions):
+ assembly_service.delete_respondents_for_assembly(uow, regular_user.id, test_assembly.id)
+
+ def test_delete_respondents_nonexistent_assembly(self, admin_user: User, postgres_session_factory):
+ """Test that deleting respondents for a nonexistent assembly raises error."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ with pytest.raises(AssemblyNotFoundError):
+ assembly_service.delete_respondents_for_assembly(uow, admin_user.id, uuid.uuid4())
+
+ def test_delete_respondents_nonexistent_user(self, test_assembly: Assembly, postgres_session_factory):
+ """Test that deleting respondents with a nonexistent user raises error."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ with pytest.raises(UserNotFoundError):
+ assembly_service.delete_respondents_for_assembly(uow, uuid.uuid4(), test_assembly.id)
+
+ def test_delete_respondents_does_not_affect_other_assembly(
+ self, admin_user: User, test_assembly: Assembly, other_assembly: Assembly, postgres_session_factory
+ ):
+ """Test that deleting respondents for one assembly doesn't affect another."""
+ uow = SqlAlchemyUnitOfWork(postgres_session_factory)
+ respondent_service.create_respondent(
+ uow, admin_user.id, test_assembly.id, external_id="NB001", attributes={"Gender": "Male"}
+ )
+ uow2 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ respondent_service.create_respondent(
+ uow2, admin_user.id, other_assembly.id, external_id="NB002", attributes={"Gender": "Female"}
+ )
+
+ # Delete only from test_assembly
+ uow3 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ assembly_service.delete_respondents_for_assembly(uow3, admin_user.id, test_assembly.id)
+
+ # other_assembly respondents should still exist
+ uow4 = SqlAlchemyUnitOfWork(postgres_session_factory)
+ with uow4:
+ other_resps = uow4.respondents.get_by_assembly_id(other_assembly.id)
+ assert len(other_resps) == 1
+ assert other_resps[0].external_id == "NB002"
diff --git a/backend/tests/integration/test_respondent_repository.py b/backend/tests/integration/test_respondent_repository.py
index 9d1bb0f3..1543ee8b 100644
--- a/backend/tests/integration/test_respondent_repository.py
+++ b/backend/tests/integration/test_respondent_repository.py
@@ -326,3 +326,34 @@ def test_get_attribute_columns_empty_when_no_respondents(
"""Test that get_attribute_columns returns empty list when no respondents exist."""
columns = respondent_repo.get_attribute_columns(test_assembly.id)
assert columns == []
+
+ def test_delete_all_for_assembly(
+ self, respondent_repo: SqlAlchemyRespondentRepository, test_assembly: Assembly, postgres_session: Session
+ ):
+ """Test deleting all respondents for an assembly."""
+ resp1 = Respondent(assembly_id=test_assembly.id, external_id="NB001", attributes={"Gender": "Male"})
+ resp2 = Respondent(assembly_id=test_assembly.id, external_id="NB002", attributes={"Gender": "Female"})
+ resp3 = Respondent(assembly_id=test_assembly.id, external_id="NB003", attributes={"Gender": "Male"})
+
+ respondent_repo.add(resp1)
+ respondent_repo.add(resp2)
+ respondent_repo.add(resp3)
+ postgres_session.commit()
+
+ # Delete all respondents for assembly
+ count = respondent_repo.delete_all_for_assembly(test_assembly.id)
+ postgres_session.commit()
+
+ assert count == 3
+
+ # Verify they're gone
+ respondents = respondent_repo.get_by_assembly_id(test_assembly.id)
+ assert len(respondents) == 0
+
+ def test_delete_all_for_assembly_returns_zero_when_none_exist(
+ self, respondent_repo: SqlAlchemyRespondentRepository, test_assembly: Assembly, postgres_session: Session
+ ):
+ """Test that delete_all_for_assembly returns 0 when no respondents exist."""
+ count = respondent_repo.delete_all_for_assembly(test_assembly.id)
+ postgres_session.commit()
+ assert count == 0
diff --git a/codecov.yaml b/codecov.yaml
index 058cfb76..54d6d67e 100644
--- a/codecov.yaml
+++ b/codecov.yaml
@@ -5,5 +5,5 @@ coverage:
status:
project:
default:
- target: 90%
+ target: 80%
threshold: 0.5%