From c21b4bcd34b049647ee39e15724fd1f75acf6c74 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Thu, 6 Aug 2026 15:48:51 +0200 Subject: [PATCH 1/3] Expose conference forms over GraphQL conference.form(purpose) returns the form configured for the given purpose (null when unconfigured) with its active questions in order: label, description, type, required, maxLength and select options. Query-side only; submitting answers lands with the grants integration. --- backend/api/conferences/types.py | 6 + backend/api/generic_forms/__init__.py | 0 backend/api/generic_forms/tests/__init__.py | 0 .../generic_forms/tests/test_form_query.py | 131 ++++++++++++++++++ backend/api/generic_forms/types.py | 43 ++++++ 5 files changed, 180 insertions(+) create mode 100644 backend/api/generic_forms/__init__.py create mode 100644 backend/api/generic_forms/tests/__init__.py create mode 100644 backend/api/generic_forms/tests/test_form_query.py create mode 100644 backend/api/generic_forms/types.py diff --git a/backend/api/conferences/types.py b/backend/api/conferences/types.py index c75d451ae6..e5a7238c8f 100644 --- a/backend/api/conferences/types.py +++ b/backend/api/conferences/types.py @@ -12,6 +12,8 @@ from strawberry import ID from api.cms.types import FAQ, Menu from api.events.types import Event +from api.generic_forms.types import Form as GenericForm +from api.generic_forms.types import FormPurpose from api.languages.types import Language from api.pretix.query import get_conference_tickets, get_voucher from api.pretix.types import TicketItem, Voucher @@ -197,6 +199,10 @@ def is_voting_closed(self, info: Info) -> bool: def deadline(self, info: Info, type: str) -> Deadline | None: return self.deadlines.filter(type=type).first() + @strawberry.field + def form(self, info: Info, purpose: FormPurpose) -> GenericForm | None: + return self.forms.filter(purpose=purpose).first() + @strawberry.field def audience_levels(self, info: Info) -> list[AudienceLevel]: return self.audience_levels.all() diff --git a/backend/api/generic_forms/__init__.py b/backend/api/generic_forms/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/api/generic_forms/tests/__init__.py b/backend/api/generic_forms/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/api/generic_forms/tests/test_form_query.py b/backend/api/generic_forms/tests/test_form_query.py new file mode 100644 index 0000000000..83a7099f7b --- /dev/null +++ b/backend/api/generic_forms/tests/test_form_query.py @@ -0,0 +1,131 @@ +import pytest + +from conferences.tests.factories import ConferenceFactory +from generic_forms.models import Form, FormQuestion +from generic_forms.tests.factories import FormFactory, FormQuestionFactory + +pytestmark = pytest.mark.django_db + + +def _query_form(graphql_client, conference, purpose="GRANT"): + query = """query($code: String!, $purpose: FormPurpose!) { + conference(code: $code) { + form(purpose: $purpose) { + id + name + questions { + id + label + description + questionType + required + maxLength + options { + id + label + } + } + } + } + }""" + + return graphql_client.query( + query, variables={"code": conference.code, "purpose": purpose} + ) + + +def test_form_is_none_when_not_configured(graphql_client): + conference = ConferenceFactory() + + result = _query_form(graphql_client, conference) + + assert result["data"]["conference"]["form"] is None + + +def test_form_is_none_when_only_another_purpose_is_configured(graphql_client): + form = FormFactory(purpose=Form.Purpose.GENERIC) + + result = _query_form(graphql_client, form.conference, purpose="GRANT") + + assert result["data"]["conference"]["form"] is None + + +def test_form_belongs_to_the_requested_conference(graphql_client): + FormFactory(purpose=Form.Purpose.GRANT, name="Other conference form") + conference = ConferenceFactory() + + result = _query_form(graphql_client, conference) + + assert result["data"]["conference"]["form"] is None + + +def test_form_with_questions(graphql_client): + form = FormFactory(purpose=Form.Purpose.GRANT, name="Grant form") + question = FormQuestionFactory( + form=form, + label="Why do you want to attend?", + description="Tell us more", + question_type=FormQuestion.QuestionType.TEXTAREA, + required=True, + max_length=500, + order=0, + ) + + result = _query_form(graphql_client, form.conference) + + data = result["data"]["conference"]["form"] + assert data["id"] == str(form.id) + assert data["name"] == "Grant form" + assert data["questions"] == [ + { + "id": str(question.id), + "label": "Why do you want to attend?", + "description": "Tell us more", + "questionType": "TEXTAREA", + "required": True, + "maxLength": 500, + "options": [], + } + ] + + +def test_select_question_options_are_exposed(graphql_client): + form = FormFactory(purpose=Form.Purpose.GRANT) + FormQuestionFactory( + form=form, + question_type=FormQuestion.QuestionType.SELECT, + options=[ + {"id": "vegan", "label": "Vegan"}, + {"id": "veggie", "label": "Veggie"}, + ], + ) + + result = _query_form(graphql_client, form.conference) + + assert result["data"]["conference"]["form"]["questions"][0]["options"] == [ + {"id": "vegan", "label": "Vegan"}, + {"id": "veggie", "label": "Veggie"}, + ] + + +def test_questions_follow_the_configured_order(graphql_client): + form = FormFactory(purpose=Form.Purpose.GRANT) + second = FormQuestionFactory(form=form, order=1) + first = FormQuestionFactory(form=form, order=0) + third = FormQuestionFactory(form=form, order=2) + + result = _query_form(graphql_client, form.conference) + + ids = [q["id"] for q in result["data"]["conference"]["form"]["questions"]] + assert ids == [str(first.id), str(second.id), str(third.id)] + + +def test_inactive_questions_are_hidden(graphql_client): + form = FormFactory(purpose=Form.Purpose.GRANT) + active = FormQuestionFactory(form=form, active=True) + FormQuestionFactory(form=form, active=False) + + result = _query_form(graphql_client, form.conference) + + ids = [q["id"] for q in result["data"]["conference"]["form"]["questions"]] + assert ids == [str(active.id)] diff --git a/backend/api/generic_forms/types.py b/backend/api/generic_forms/types.py new file mode 100644 index 0000000000..37c9af9430 --- /dev/null +++ b/backend/api/generic_forms/types.py @@ -0,0 +1,43 @@ +import strawberry + +from api.context import Info +from generic_forms.models import Form as FormModel +from generic_forms.models import FormQuestion as FormQuestionModel + +FormPurpose = strawberry.enum(FormModel.Purpose, name="FormPurpose") +FormQuestionType = strawberry.enum( + FormQuestionModel.QuestionType, name="FormQuestionType" +) + + +@strawberry.type +class FormQuestionOption: + id: str + label: str + + +@strawberry.type +class FormQuestion: + id: strawberry.ID + label: str + description: str + question_type: FormQuestionType + required: bool + max_length: int | None + + @strawberry.field + def options(self, info: Info) -> list[FormQuestionOption]: + return [ + FormQuestionOption(id=option["id"], label=option["label"]) + for option in self.options + ] + + +@strawberry.type +class Form: + id: strawberry.ID + name: str + + @strawberry.field + def questions(self, info: Info) -> list[FormQuestion]: + return self.questions.filter(active=True) From 1c9fb3490c5d4b8f372ae53759479c1ca534a785 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Thu, 6 Aug 2026 15:49:22 +0200 Subject: [PATCH 2/3] Mark PR2 tasks done in generic-forms todo --- tasks/generic-forms/todo.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tasks/generic-forms/todo.md b/tasks/generic-forms/todo.md index f48a6eb7f6..39bb62d554 100644 --- a/tasks/generic-forms/todo.md +++ b/tasks/generic-forms/todo.md @@ -10,9 +10,9 @@ Stack: PR1 → PR2 → PR3 → (PR4 ∥ PR5) · PR5 needs PR3 **deployed to stag - [x] **T1.4** Admin: FormAdmin + FormQuestionInline (freeze via model validation errors — readonly deviation documented in plan), read-only FormAnswerAdmin incl. delete block. (f9c2a273d) - [x] **▣ CHECKPOINT 1** — 48 app tests, full suite 1191 green, ruff clean; adversarial review (3 lenses) applied (455525748); PR #4705 open. **Human review pending. Manual admin eyeball pending.** -## PR2 — GraphQL query (`generic-forms/02-graphql-query`) -- [ ] **T2.1** `api/generic_forms/types.py` (FormType, FormQuestionType, enums, options) + `Conference.form(purpose)` (mirror `deadline()`); active-only ordered; null when unconfigured. Verify: `pytest api/generic_forms`. -- [ ] **▣ CHECKPOINT 2** — suite green; schema diff additive; PR2 opened. +## PR2 — GraphQL query (`generic-forms/02-graphql-query`) — **PR #4707** +- [x] **T2.1** `api/generic_forms/types.py` (Form, FormQuestion, FormQuestionOption, FormPurpose/FormQuestionType enums) + `Conference.form(purpose)`; active-only ordered; null when unconfigured. 7 tests. +- [x] **▣ CHECKPOINT 2** — full suite 1197 green; additive-only schema change; PR #4707 open (stacked on #4705). ## PR3 — grants backend (`generic-forms/03-grants-backend`) - [ ] **T3.1** `Grant.form_answer` OneToOne (SET_NULL) + `blank=True` on the 4 required soft columns (`why`, `python_usage`, `been_to_other_events`, `occupation`); one migration. Columns stay NOT NULL — mutations must never pass `None`. From a787d85e8504a84505e27a8b0063337fe9afb612 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Fri, 7 Aug 2026 04:38:33 +0200 Subject: [PATCH 3/3] Remove spec and task docs from the PR --- specs/generic-form-system.md | 303 --------------------------------- tasks/generic-forms/plan.md | 316 ----------------------------------- tasks/generic-forms/todo.md | 32 ---- 3 files changed, 651 deletions(-) delete mode 100644 specs/generic-form-system.md delete mode 100644 tasks/generic-forms/plan.md delete mode 100644 tasks/generic-forms/todo.md diff --git a/specs/generic-form-system.md b/specs/generic-form-system.md deleted file mode 100644 index da980eba3e..0000000000 --- a/specs/generic-form-system.md +++ /dev/null @@ -1,303 +0,0 @@ -# Spec: Generic Form System - -Status: Approved — ready for planning -Source: Notion draft "Generic Form system" (exported HTML in repo root) + clarifying Q&A -Author: generated via spec-driven-development - ---- - -## 1. Objective - -Build a generic, per-conference configurable form system so organizers can change the questions asked in recurring flows (grants, CFP, visa, feedback) **without backend or frontend code changes**. Today every question is a hardcoded model column (`Grant`, `Submission`) or an external Google Form; changing questions for a new conference edition requires coordinated BE + FE work and migrations. - -**First consumer (this spec's scope): the grant application form.** The engine is built generically; grants is the first flow wired to it. CFP, visa, and feedback forms are explicitly future slices. - -**Target users:** -- *Organizers* — author/edit form questions per conference in Django admin. -- *Attendees/applicants* — fill forms on the Next.js frontend. -- *Maintainers* — stop writing migrations + form components for every question change. - -**Success looks like:** an organizer can add, reword, reorder, or deactivate a grant-form question for the next conference entirely from Django admin, and the frontend renders and validates it with zero code changes. - -### Decisions already made (via Q&A) - -1. **MVP integration target: grants** (biggest pain; `Grant` has ~20 hardcoded answer columns). -2. **Data model: hybrid** — `Form`/`FormQuestion` as normal models (admin-authorable), answers stored as a single `FormAnswer` row per submission with a `JSONField` mapping `question_id → value`. No per-question answer rows. -3. **Versioning: freeze-on-answer** — a question's semantic fields (type, options, required) become immutable once any answer exists for its form. Changes happen by deactivating questions and adding new ones (or cloning the form for a new conference). No snapshot or version-row machinery. -4. **Authoring UI: Django admin** — inline `FormQuestion` editing under `Form`. No custom-admin/Astro builder in this slice. -5. **Load-bearing grant fields stay as `Grant` columns** (confirmed). Fields that drive business logic — `grant_type` (reimbursement categories), `departure_country`/`nationality` (`country_type` derivation, visa), `departure_city`, `needs_funds_for_travel`, `need_visa`, `need_accommodation` — remain structured columns on `Grant`, as do `full_name`/`name`. The *soft* questions moving into the generic form are exactly: `why`, `python_usage`, `been_to_other_events`, `community_contribution`, `age_group`, `occupation`, `gender`, `notes`. (Corrected during planning: socials/website do NOT move — the grant form's social inputs are `participant_*` fields handled via `PublicProfileCard`/`Participant` upsert, not Grant columns; Grant's own social columns are already unused by the current flow.) This avoids a question→field mapping layer in the MVP. -6. **English only** — no multi-lingual labels/options (confirmed). -7. **Options-as-JSON admin UX**: raw JSON widget is acceptable — no custom widget (confirmed). -8. **Grant admin export includes dynamic answers in this slice** (confirmed). The existing `GrantResource` (django-import-export, `grants/admin.py`) exports several soft-question columns today; those move to dynamic-answer columns — one column per question of the conference's grant form (the export is already single-conference via `before_export`). -9. **`purpose` enum values for cfp/visa/feedback are added when those slices land**, not preemptively (confirmed). - -### Assumptions I'm making (correct before approval if wrong) - -1. **No data migration of historical grants.** Old `Grant` columns stay populated and readable for past conferences; new conferences write soft answers to `FormAnswer` only. Legacy columns become nullable/blank-able but are **not dropped** in this slice. -2. **One `FormAnswer` per (form, user).** Matches the existing one-grant-per-user-per-conference constraint. Multi-response generic forms (e.g. anonymous feedback) are future work. -3. **Question labels/descriptions are editable even after answers exist** (typo fixes); only `question_type`, `options`, and `required` freeze. Deletion is blocked once answered — deactivate instead. -4. **New Django app named `generic_forms`** (avoids collision/confusion with `django.forms` and `wagtail.contrib.forms`, which is installed but unused). -5. **Select options live in a `JSONField` on `FormQuestion`** (list of `{id, label}`), not a third model — Django admin can't nest inlines two levels deep, and options-as-JSON keeps authoring on one page. -6. **No file-upload question type in MVP** — it requires extending `files_upload.File.Type`, size limits, and upload permissions. Listed as future work. -7. **No conditional/branching questions in MVP.** - ---- - -## 2. Scope - -### In scope - -- New `generic_forms` Django app: `Form`, `FormQuestion`, `FormAnswer` models + migrations + admin. -- Question types: `text` (single line), `textarea`, `select`, `multi_select`, `boolean`, `url`. -- Server-side answer validation (required, type, option membership, max length, URL format) following the existing `BaseErrorType` pattern. -- GraphQL: query a conference's form by purpose (id, name, ordered active questions with labels/options); mutation to submit/update answers is folded into the existing grant mutations (see §5). -- Grants integration: `sendGrant`/`updateGrant` accept an `answers` input, validate against the conference's grant form, persist a `FormAnswer` linked from `Grant`. -- Frontend: a reusable `DynamicForm` component (styleguide inputs, `react-use-form-state`) rendering questions by type; grant form page renders its soft-question sections dynamically. -- Django admin: grant admin displays the applicant's dynamic answers read-only alongside the structured fields. -- Grant admin export: `GrantResource` gains one column per question of the conference's grant form, populated from the linked `FormAnswer`; legacy soft-question columns stay for historical exports. -- Freeze-on-answer enforcement at the model layer (not just admin). - -### Out of scope (explicitly NOT in this slice) - -- CFP/Submission, visa, and feedback form integrations (engine supports `purpose` values for them, but no product wiring). -- Migrating historical `Grant` answer data into `FormAnswer`; dropping legacy `Grant` columns. -- Custom-admin (Astro) form-builder UI; Wagtail integration. -- File-upload, date, number, or conditional question types. -- Anonymous / multi-response forms. -- Generic "form submitted" confirmation email plumbing (draft's idea — good future win, not now; grants keeps its existing notification path). -- Changes to Pretix, Stripe, or the reimbursement flow. -- Profile-based prefill of dynamic answers (today `ageGroup` prefills from `user.dateBirth` and `gender` from `user.gender`; the generic engine has no per-question semantics, so these prefills are dropped — small accepted UX regression). - ---- - -## 3. Tech stack - -- **Backend:** Django 5.x (existing), PostgreSQL, Strawberry GraphQL. No new Python dependencies expected. -- **Language:** English only — plain `CharField`/`TextField` for labels, descriptions, option labels. No `I18nCharField`/`I18nTextField`. -- **Frontend:** Next.js (existing), TypeScript, Apollo Client with codegen (`pnpm codegen`), `react-use-form-state` (corrected during planning: `react-hook-form` is in package.json but has zero usages in the codebase — every existing form, including the modern invitation-letter form, uses `react-use-form-state`; the new component follows the actual in-repo pattern), `@python-italia/pycon-styleguide` inputs. -- **Admin:** stock Django admin with `TabularInline`/`StackedInline`. - ---- - -## 4. Data model - -```python -# backend/generic_forms/models.py -class Form(TimeStampedModel): - class Purpose(models.TextChoices): - GRANT = "grant", _("Grant") - GENERIC = "generic", _("Generic") # cfp/visa/feedback added in later slices - - conference = models.ForeignKey("conferences.Conference", on_delete=models.CASCADE, - related_name="forms") - purpose = models.CharField(max_length=32, choices=Purpose.choices) - name = models.CharField(max_length=200) - # constraint: at most one form per (conference, purpose) when purpose != GENERIC - - -class FormQuestion(TimeStampedModel): - class QuestionType(models.TextChoices): - TEXT = "text" - TEXTAREA = "textarea" - SELECT = "select" - MULTI_SELECT = "multi_select" - BOOLEAN = "boolean" - URL = "url" - - form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name="questions") - label = models.CharField(max_length=300) - description = models.TextField(blank=True) - question_type = models.CharField(max_length=32, choices=QuestionType.choices) - options = models.JSONField(blank=True, default=list) - # options item shape: {"id": "vegan", "label": "Vegan"} - required = models.BooleanField(default=False) - max_length = models.PositiveIntegerField(null=True, blank=True) - order = models.PositiveIntegerField(default=0) - active = models.BooleanField(default=True) # deactivate instead of delete once answered - - -class FormAnswer(TimeStampedModel): - form = models.ForeignKey(Form, on_delete=models.PROTECT, related_name="answers") - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - answers = models.JSONField(default=dict) - # Versioned envelope so the structure can evolve without guessing: - # {"version": 1, "answers": {"": value}} - # version 1 value types by question_type: - # text/textarea/url → str, select → option id (str), - # multi_select → list[str] of option ids, boolean → bool - # Readers dispatch on "version"; writers always write the current version. - # (GraphQL input stays the flat {question_id: value} map — the envelope is - # a storage concern; the mutation wraps it on persist.) - - class Meta: - constraints = [models.UniqueConstraint(fields=["form", "user"], - name="unique_form_answer_per_user")] -``` - -**Grant link:** `Grant.form_answer = models.OneToOneField("generic_forms.FormAnswer", null=True, blank=True, on_delete=models.SET_NULL)`. Soft-question columns on `Grant` become `blank=True` (kept for historical data). - -**Freeze-on-answer rule (model layer):** `FormQuestion.clean()`/`save()` raise if `question_type`, `options`, or `required` change while `self.form.answers.exists()`; deletion is blocked via a `pre_delete` signal (covers queryset deletes too). `label`/`description`/`order`/`active` stay editable. `Form.conference`/`purpose` freeze the same way. In admin the rule surfaces as validation errors on the inline (not readonly fields — inline-level readonly would also freeze NEW rows, and adding questions to answered forms must stay possible); the model is the enforcement point. Question `options` are shape-validated at authoring (list of `{id, label}` string pairs, unique ids, required for select types, forbidden otherwise). - -**Answer validation (single source of truth):** a `validate_answers(form, answers: dict) -> dict[str, list[str]]` service in `generic_forms/` used by the GraphQL layer: unknown/inactive question ids rejected, required enforced, per-type checks (option membership incl. every item of multi_select, `URLValidator` for url, `max_length` for text types, bool type check). - ---- - -## 5. API design (GraphQL) - -Follows the newer one-mutation-per-file pattern and the `api/visa/mutations/request_invitation_letter.py` validation style. - -**Query** — extend the existing `Conference` type: - -```graphql -conference(code: "pycon2026") { - form(purpose: GRANT) { # null if no form configured - id - name - questions { # active only, ordered - id - label - description - questionType - required - maxLength - options { id label } - } - } -} -``` - -**Mutations** — no standalone `submitFormAnswers` in this slice. `sendGrant` / `updateGrant` inputs gain an optional `answers: JSON` (map of question id → value). The mutation: -1. Keeps its existing deadline gating unchanged (`non_field_errors: "The grants form is not open!"` via `Conference.is_grants_open`) — no `FormNotAvailable` union member (changing the deadline-closed response shape would break the deployed frontend; decided during planning). -2. If `answers` is provided but the conference has no `GRANT` form, rejects with a clear error. If the form exists, runs `validate_answers`; failures are returned in a dedicated `answersErrors: JSON` field on `GrantErrors` mapping `question_id → [messages]`. (Dotted dynamic paths like `answers.` cannot serialize through the statically-typed error classes — verified during planning; the in-repo dotted-path precedent, `materials.0.url` in `api/submissions`, works only because its container field is statically declared.) -3. Persists `FormAnswer` (create or update), wrapping the input map into the versioned envelope (`{"version": 1, "answers": {...}}`), and links it to the `Grant` in the same transaction. -4. The 8 legacy soft input fields become optional; legacy-shape submissions (soft fields, no `answers`) keep working unchanged until the frontend cutover, then get removed in a post-deploy follow-up. - -**Grant type** — exposes `formAnswers: JSON | null` (the unwrapped flat map) so the frontend edit flow can prefill the dynamic questions. - -Privacy policy acceptance, Slack notification, and email template lookups keep their current grant-specific wiring — unchanged. - ---- - -## 6. Commands - -All backend commands run inside Docker (per CLAUDE.md). - -| Purpose | Command | -|---|---| -| Run backend tests (new app) | `docker exec pycon-backend-1 uv run pytest generic_forms/tests api/generic_forms -l -s -vvv` | -| Grants integration tests | `docker exec pycon-backend-1 uv run pytest api/grants grants -l -s -vvv` | -| Full suite | `docker exec pycon-backend-1 uv run pytest` | -| Make migrations | `docker exec pycon-backend-1 uv run python manage.py makemigrations generic_forms grants` | -| Migrate | `docker exec pycon-backend-1 uv run python manage.py migrate` | -| Lint / format | `docker exec pycon-backend-1 uv run ruff check` / `uv run ruff format` | -| Type check | `docker exec pycon-backend-1 uv run mypy .` | -| Frontend codegen (after schema change) | `cd frontend && pnpm codegen` | -| Frontend tests / build | `cd frontend && pnpm test` / `pnpm build` | - ---- - -## 7. Project structure - -``` -backend/ - generic_forms/ # NEW app - models.py # Form, FormQuestion, FormAnswer - services.py # validate_answers() - admin.py # Form admin + FormQuestion inline (freeze-aware) - migrations/ - tests/ # model + validation tests, factories - api/ - generic_forms/ # NEW: FormType, FormQuestionType (query side) - types.py - grants/mutations.py # extend sendGrant/updateGrant with answers - grants/ - models.py # + form_answer FK; soft columns → blank=True - admin.py # + read-only answers display - pycon/settings/base.py # + generic_forms in INSTALLED_APPS - -frontend/src/ - components/dynamic-form/ # NEW: renders FormQuestion[] via styleguide inputs - index.tsx - form.graphql # fragment for form + questions - components/grant-form/ # integrate DynamicForm for soft questions -``` - ---- - -## 8. Code style - -Backend follows existing conventions — Ruff (lint + format), mypy clean. Mutation validation mirrors the in-repo pattern: - -```python -@strawberry.input -class SendGrantInput: - conference: strawberry.ID - answers: JSON - ... - - def validate(self, conference: Conference, form: Form) -> GrantErrors | None: - errors = GrantErrors() - if answer_errors := validate_answers(form, self.answers): - # dedicated JSON field: {question_id: [messages]} — dynamic keys - # cannot serialize through the statically-typed error fields - errors.answers_errors = answer_errors - return errors.if_has_errors -``` - -Frontend: `react-use-form-state` + `@python-italia/pycon-styleguide` primitives (mirror `invitation-letter-form.tsx`: `InputWrapper` around each field, `MultiplePartsCard` sections); GraphQL documents co-located with components; **never hand-edit generated files** (`src/types.tsx`, `src/generated/`). - ---- - -## 9. Testing strategy - -- **Framework:** pytest + factory-based fixtures, in-app `tests/` dirs (existing convention). Frontend: existing `pnpm test` setup for the `DynamicForm` component's rendering/validation mapping. -- **Model tests** (`generic_forms/tests/`): freeze-on-answer (type/options/required change and delete blocked once an answer exists; label/order/active edits allowed); unique (form, user) constraint; one-form-per-(conference, purpose) constraint. -- **Validation tests:** each question type's accept/reject cases — required missing, wrong value type, unknown question id, inactive question id, non-member option, multi_select with one bad item, invalid URL, over max_length. -- **API tests** (`api/` tests): query returns only active questions in order; `sendGrant` with valid answers creates `Grant` + linked `FormAnswer` atomically; invalid answers return per-question errors in `answersErrors` and persist nothing; answers-with-no-form-configured is rejected; grants-deadline-closed behavior unchanged from today; an answers-only payload omitting all 8 legacy soft fields succeeds end-to-end (this is the exact post-cutover frontend payload). -- **Export test:** `GrantResource` export of a grant with a linked `FormAnswer` produces one column per form question with the answer values (option ids resolved to labels); grants without `FormAnswer` (historical) still export cleanly. -- **Regression:** full existing grants test suite stays green — legacy columns still accepted for old data paths. -- Every slice lands with its tests; `pytest`, `ruff check`, `mypy .` green before any commit. - ---- - -## 10. Boundaries - -### Always do -- Run backend commands via `docker exec pycon-backend-1 ...` (local venv doesn't work). -- Run `pytest` + `ruff check` + `mypy .` (and `pnpm codegen` after schema changes) before committing. -- Enforce freeze-on-answer in the model, not only in admin. -- Validate answers server-side via `validate_answers` — frontend validation is UX only. -- Keep legacy `Grant` columns readable (admin, exports) for historical conferences. - -### Ask first -- Adding any new dependency (backend or frontend). -- Changing which `Grant` fields count as load-bearing (decision #5) — i.e. moving `grant_type`, country, or `need_*` fields into the form. -- Any data migration touching existing `Grant` rows beyond `blank=True` loosening. -- Adding new values to `files_upload.File.Type` (file-upload question type). -- Schema changes to `Submission`, visa, or notification models. -- Dropping or renaming any existing column. - -### Never -- Drop legacy `Grant` answer columns in this slice. -- Hand-edit generated GraphQL types (`frontend/src/types.tsx`, `*.generated.ts`). -- Store answers as per-question rows (decision: JSON) or bypass `validate_answers` in any mutation. -- Commit secrets; weaken rate-limit/permission classes on mutations. -- Delete or skip failing tests to get green. - ---- - -## 11. Success criteria - -1. Organizer creates a `GRANT` form with questions of every supported type in Django admin, reorders and deactivates questions — no code change needed. -2. Once one answer exists, changing a question's type/options/required or deleting it fails with a clear error in both admin and direct model save; label typo fix still succeeds. -3. `conference.form(purpose: GRANT)` returns the ordered active questions; returns `null` when unconfigured. -4. `sendGrant` with valid `answers` creates `Grant` + linked `FormAnswer` in one transaction; a second submit by the same user for the same conference updates rather than duplicates (existing update path). -5. `sendGrant` with an invalid answer (missing required, bad option, invalid URL) returns per-question errors (`answersErrors` map) and writes nothing; an answers-only payload with no legacy soft fields succeeds. -6. Grant form page on the frontend renders the soft-question sections from the API (verify: add a question in admin → it appears on the page after reload, no deploy of new code). -7. Grant admin shows the applicant's dynamic answers read-only next to structured fields. -8. Grant admin export includes a column per form question with the applicant's answers; exports of historical grants (no `FormAnswer`) still work. -9. Full backend test suite, `ruff check`, `mypy .`, frontend `pnpm build` + `pnpm test` all green. - -## 12. Open questions - -None — all resolved into decisions #7–#9. diff --git a/tasks/generic-forms/plan.md b/tasks/generic-forms/plan.md deleted file mode 100644 index add0215ed1..0000000000 --- a/tasks/generic-forms/plan.md +++ /dev/null @@ -1,316 +0,0 @@ -# Implementation Plan: Generic Form System - -Source spec: [specs/generic-form-system.md](../../specs/generic-form-system.md) · Mode: plan (read-only, no code changed) -Structure: **5 stacked PRs** — each PR is independently mergeable and deployable, stacked in order. -Reviewed: adversarial verify pass (3 independent critics) applied — see "Verified constraints" below. - -## Overview - -Build the `generic_forms` engine (Form / FormQuestion / FormAnswer, freeze-on-answer, JSON answers with versioned envelope), expose it over GraphQL, wire grants as the first consumer (8 soft questions move from hardcoded `Grant` columns to dynamic form answers), surface answers in grant admin + export, and render the form dynamically on the frontend. - -## Resolved since spec (verified in codebase) - -- `react-hook-form` has **zero** usages despite being in package.json; every form (incl. the modern `invitation-letter-form.tsx`) uses `react-use-form-state`. New `DynamicForm` uses `react-use-form-state`. (Spec §3/§8 corrected.) -- Grant's social columns (`website`, `twitter_handle`, …) are **already dead** — not in the GraphQL `Grant` type, not written by the form (socials go through `Participant` via `PublicProfileCard`). They do NOT become form questions. Soft-question set is exactly: `why`, `python_usage`, `been_to_other_events`, `community_contribution`, `age_group`, `occupation`, `gender`, `notes`. (Spec decision #5 corrected.) -- `send_grant`/`update_grant` are `@transaction.atomic` ([api/grants/mutations.py:226,297](../../backend/api/grants/mutations.py)) — FormAnswer persistence slots into the existing transaction. -- `BaseGrantInput.validate()` (mutations.py:74-111) **mixes** structured-field checks (`full_name`, `grant_type`, departure fields — these STAY) with soft-field checks (max lengths why:1000, python_usage:700, been_to_other_events:500, community_contribution:900, notes:350; required: why, python_usage, been_to_other_events). Only the soft-field portion is superseded by `validate_answers` — structured-field validation must remain untouched. -- Of the 8 soft columns, exactly **4** lack `blank=True` today: `why`, `python_usage`, `been_to_other_events`, `occupation`. The other 4 (`age_group`, `community_contribution`, `gender`, `notes`) are already `blank=True`. All 8 are NOT NULL at the DB level (`blank=True` is Python-only) — `None` must never reach `Grant.objects.create`. -- django-import-export is **3.3.9**; dynamic per-export fields are supported: `Resource.__init__` deep-copies `self.fields` (sanctioned mutation point), and `GrantAdmin.get_export_resource_kwargs(request, ...)` passes context into `GrantResource.__init__`. Extra instance fields auto-append to export order. -- Conference GraphQL pattern to mirror: `deadline(self, info, type: str)` at [api/conferences/types.py:196](../../backend/api/conferences/types.py#L196). Enum pattern: `strawberry.enum(Model.TextChoices)`. -- Tests: model tests in `generic_forms/tests/`, API tests in `api/generic_forms/tests/` + `api/grants/tests/`; `graphql_client` fixture, factory_boy, `pytest.mark.django_db`. -- No read-only-JSON admin precedent exists — the answers display in GrantAdmin is net-new (simple `format_html` list, no new deps). - -## Verified constraints (from the adversarial review — these shape the tasks) - -1. **Dotted `answers.` error paths are impossible.** `BaseErrorType.add_error` getattr-traverses statically-typed error classes (api/types.py:33-74); dynamic keys raise `AttributeError`, and strawberry cannot serialize dynamic field names regardless. The in-repo dotted precedent (`materials.0.url`) lives in **api/submissions** (not visa) and works only because `materials: list[ProposalMaterialErrors]` is statically declared. **Decision (resolved, not a risk): `answers_errors: JSON` field on `_GrantErrors`, set by direct assignment.** Spec §5/§8/§11 updated. Frontend consumes `answersErrors` only. -2. **PR3 must survive the exact PR5 payload.** An answers-only submission (all 8 soft fields omitted) must pass: (a) legacy soft-field required/max-length checks run ONLY on the legacy path (answers not provided); (b) soft input `None` values coalesce to `""` before `Grant.objects.create` / the update setattr loop (DB columns are NOT NULL). A named PR3 test sends answers and omits all 8 soft fields. -3. **Frontend codegen needs a deployed backend schema.** `codegen.yml` fetches the schema from a live endpoint; PR CI (`frontend-lint.yml`) codegens against the staging backend (pastaporto), which deploys only via manual `workflow_dispatch`. **PR5 therefore build-depends on PR3 being deployed to staging**, not merely merged. Release step added before PR5. (Optional improvement, needs approval per spec boundaries — CI change: check in a schema snapshot via `strawberry export-schema` and point codegen at the file.) -4. **Deadline-closed behavior stays as-is** (`non_field_errors: "The grants form is not open!"`). No `FormNotAvailable` union member — changing the response shape breaks the deployed frontend. Spec §5 amended accordingly. `answers` with no GRANT form configured → clear field error. -5. **Production data dependency:** the GRANT form must exist (with the 8 questions) in production admin BEFORE PR5 deploys, or the live form loses its soft questions. Seeding command was explicitly cut from scope → this is a manual ops step in Checkpoint 5, on both staging and production. Frontend must also handle `form == null` by blocking submission with a "form not available" state (never submit without answers). -6. **Legacy-field removal follow-up must be two PRs**, not one: (1) frontend-only — strip legacy `GrantErrors` validation selections (submit-grant.graphql:12-34, pages/grants/edit/update-grant.graphql:25-52) and legacy soft-field selections (my-grant.graphql, update-grant.graphql) — deployable against the unchanged backend; (2) after deploy + soak (stale browser tabs still send old payloads), backend-only — remove the legacy input fields. PR5 already stops *sending* soft fields; it also strips whatever legacy selections it can without breaking its own build. - -## Architecture decisions - -- **Stacked-PR back-compat rule:** every PR leaves `main` deployable (backend deploys before frontend, per deploy.yml ordering). PR3 is strictly additive on the wire: soft fields optional, `answers` optional, legacy shape untouched. -- **Answers storage:** versioned envelope `{"version": 1, "answers": {"": value}}`; GraphQL wire format is the flat map (`strawberry.scalars.JSON`). -- **Question ids as answer keys:** `FormQuestion.pk` stringified; frontend treats them as opaque. -- **Prefill regression accepted and specced** (spec §2 out-of-scope): dateBirth→ageGroup and user.gender prefills drop. -- **Mid-cycle cutover caveat:** grants submitted pre-PR5 (legacy path) have soft answers in columns, not FormAnswer — post-cutover their edit view shows empty dynamic questions. Mitigation: deploy the cutover before grants open for the next conference (ops note in Checkpoint 5); a data backfill is explicitly out of scope. - -## Dependency graph - -``` -PR1 generic_forms app (models + freeze + validate_answers + admin) - ├── PR2 GraphQL query side (Conference.form(purpose)) - └── PR3 grants backend (Grant.form_answer, mutations, Grant.formAnswers) - ├── PR4 grant admin display + export (needs PR3 merged) - └── PR5 frontend DynamicForm + grant form (needs PR2 + PR3 DEPLOYED to staging for codegen/CI) -``` - -Linear stack order: PR1 → PR2 → PR3 → PR4 → PR5. PR4 can start once PR3 merges; PR5 once PR3 reaches staging. - ---- - -## PR1 — `generic_forms` app core (backend only, no consumers) - -Suggested branch: `generic-forms/01-app` - -### Task 1.1: App skeleton + models + migration - -**Description:** Create the `generic_forms` Django app with `Form`, `FormQuestion`, `FormAnswer` models per spec §4 (plain `CharField`/`TextField`, English only), DB constraints, and initial migration. Register in `INSTALLED_APPS` (dotted AppConfig path, `default_auto_field = BigAutoField` like `visa/apps.py`). - -**Acceptance criteria:** -- [ ] Models match spec §4: `Form(conference, purpose, name)`, `FormQuestion(form, label, description, question_type, options, required, max_length, order, active)`, `FormAnswer(form PROTECT, user, answers JSON)`. -- [ ] Constraints enforced at DB level: unique `(form, user)` on FormAnswer; at most one form per `(conference, purpose)` when purpose != `generic` (conditional UniqueConstraint). -- [ ] Migration is plain `makemigrations` output; applies cleanly. - -**Verification:** `docker exec pycon-backend-1 uv run pytest generic_forms -l` green; `uv run python manage.py makemigrations --check --dry-run` clean afterward. - -**Dependencies:** None. -**Files:** `backend/generic_forms/{__init__,apps,models}.py`, `backend/generic_forms/migrations/0001_initial.py`, `backend/pycon/settings/base.py`, `backend/generic_forms/tests/{__init__,factories,test_models}.py` -**Scope:** M - -### Task 1.2: Freeze-on-answer enforcement - -**Description:** Once `form.answers.exists()`: changing `question_type`/`options`/`required` on a `FormQuestion`, or deleting it, raises `ValidationError`; `label`/`description`/`order`/`active` stay editable. Enforced in the model (`clean()` + `save()` guard + `delete()` override). - -**Acceptance criteria:** -- [ ] Semantic-field change on an answered form raises; same change on an unanswered form succeeds. -- [ ] Delete blocked on answered form; `active=False` allowed. -- [ ] Label/description/order edits always allowed. - -**Verification:** `docker exec pycon-backend-1 uv run pytest generic_forms/tests/test_models.py -l` green. - -**Dependencies:** 1.1. -**Files:** `backend/generic_forms/models.py`, `backend/generic_forms/tests/test_models.py` -**Scope:** S - -### Task 1.3: `validate_answers` service + envelope helpers - -**Description:** `validate_answers(form, answers: dict) -> dict[str, list[str]]` per spec §4 (unknown/inactive ids, required, per-type checks, option membership incl. every multi_select item, `URLValidator`, `max_length`), plus `wrap_answers` / `unwrap_answers` envelope helpers dispatching on `version`. - -**Acceptance criteria:** -- [ ] Every question type has accept + reject cases covered by tests (spec §9 list). -- [ ] Valid input returns `{}`; errors keyed by question id (this dict is exactly what `answers_errors` carries on the wire in PR3). -- [ ] Envelope round-trip: `unwrap(wrap(x)) == x`; unwrap raises on unknown version. - -**Verification:** `docker exec pycon-backend-1 uv run pytest generic_forms/tests/test_services.py -l` green. - -**Dependencies:** 1.1. -**Files:** `backend/generic_forms/services.py`, `backend/generic_forms/tests/test_services.py` -**Scope:** M - -### Task 1.4: Django admin for form authoring - -**Description:** `FormAdmin` with `FormQuestionInline` (TabularInline, mirror `SponsorLevelBenefitInline` simplicity; ordered by `order`), raw JSON widget for `options` (decision #7). Freeze rule surfaces as model validation errors in the inline (deviation applied during build: inline-level readonly would also freeze NEW rows, and adding questions to answered forms must stay possible); inline deletion blocked once answered; `Form.conference`/`purpose` readonly once answered. `FormAnswerAdmin` fully read-only (no add/change/delete — deleting answers would unfreeze questions and destroy submissions). - -**Acceptance criteria:** -- [ ] Organizer can create a form + questions of every type entirely in admin (success criterion 1). -- [ ] Inline shows semantic fields readonly once the form has answers. -- [ ] FormAnswer visible but not editable in admin. - -**Verification:** `docker exec pycon-backend-1 uv run pytest generic_forms -l` green; manual: create form with all 6 question types in local admin. - -**Dependencies:** 1.2. -**Files:** `backend/generic_forms/admin.py`, `backend/generic_forms/tests/test_admin.py` -**Scope:** S - -### ▣ CHECKPOINT 1 (end of PR1) -- [ ] `pytest generic_forms`, full `pytest`, `ruff check`, `ruff format --check`, `mypy .` all green. -- [ ] PR1 opened; human review before stacking further. - ---- - -## PR2 — GraphQL query side - -Suggested branch: `generic-forms/02-graphql-query` (stacked on PR1) - -### Task 2.1: Form types + `Conference.form(purpose)` field - -**Description:** `api/generic_forms/types.py`: `FormType`, `FormQuestionType` (id, label, description, questionType, required, maxLength, options as `list[FormQuestionOption(id, label)]`), `FormPurpose = strawberry.enum(Form.Purpose)`, `QuestionType = strawberry.enum(FormQuestion.QuestionType)`. Add `form(self, info, purpose: FormPurpose) -> FormType | None` to the Conference type, mirroring `deadline()`. Questions resolver returns active-only, ordered by `order`. - -**Acceptance criteria:** -- [ ] Query in spec §5 works verbatim. -- [ ] Returns `null` when no form configured; inactive questions excluded; order respected. - -**Verification:** `docker exec pycon-backend-1 uv run pytest api/generic_forms -l` green; `ruff`/`mypy` clean. - -**Dependencies:** PR1. -**Files:** `backend/api/generic_forms/{__init__,types}.py`, `backend/api/conferences/types.py`, `backend/api/generic_forms/tests/{__init__,test_form_query}.py` -**Scope:** S - -### ▣ CHECKPOINT 2 (end of PR2) -- [ ] Full backend suite + lint + types green. GraphQL schema diff reviewed (additive only). PR2 opened. - ---- - -## PR3 — grants backend integration - -Suggested branch: `generic-forms/03-grants-backend` (stacked on PR2) - -### Task 3.1: `Grant.form_answer` link + soft-column loosening - -**Description:** Add `Grant.form_answer = OneToOneField(generic_forms.FormAnswer, null=True, blank=True, SET_NULL)`. Loosen the **4** currently-required soft columns (`why`, `python_usage`, `been_to_other_events`, `occupation`) to `blank=True` (the other 4 already are). One migration, no data changes. Note: columns remain NOT NULL — the mutation layer must never pass `None` (handled in 3.2). - -**Acceptance criteria:** -- [ ] Migration applies; no other schema changes; historical rows untouched. -- [ ] Existing grants test suite green. - -**Verification:** `docker exec pycon-backend-1 uv run pytest grants api/grants -l` green. - -**Dependencies:** PR1. -**Files:** `backend/grants/models.py`, `backend/grants/migrations/00XX_*.py` -**Scope:** S - -### Task 3.2: Mutations accept `answers` (with tests, TDD) - -**Description:** `SendGrantInput`/`UpdateGrantInput`: the 8 soft fields become optional; new optional `answers: JSON`. Validation split: -- Legacy soft-field checks (required + max-length subset of `BaseGrantInput.validate`) run **only** when the legacy path is used (`answers` not provided). Structured-field validation (`full_name`, `grant_type`, departure fields, deadline gating) is **unchanged on both paths**. -- Answers path: reject if no GRANT form configured; else `validate_answers`; failures go into new `answers_errors: JSON` field on `_GrantErrors` by direct assignment (NOT `add_error` — dynamic keys can't traverse the typed class; see Verified constraint 1). -Mutation body: inside the existing `@transaction.atomic`, wrap answers into the envelope, `update_or_create` the FormAnswer, link `grant.form_answer`. Soft input `None` values coalesce to `""` before `Grant.objects.create`; `update_grant`'s `asdict(input)` setattr loop skips `answers` and never writes `None` into soft columns. Tests land in this task (failing-first): answers happy path, invalid answers → `answersErrors` + atomic rollback (no Grant, no FormAnswer), **answers-only payload omitting all 8 soft fields end-to-end (the exact PR5 payload)**, legacy-shape regression (today's payload byte-identical behavior), update-no-duplicate (unique constraint), answers-with-no-form rejected, deadline-closed unchanged, structured-field validation unchanged. - -**Acceptance criteria:** -- [ ] All paths above covered by tests in `api/grants/tests/`; whole grants suite green. -- [ ] Answers-only payload (no soft fields) succeeds — named test. -- [ ] Legacy payload behavior unchanged — named test. -- [ ] No `None` ever written to a NOT NULL soft column (create or update path). - -**Verification:** `docker exec pycon-backend-1 uv run pytest api/grants grants generic_forms -l` green. - -**Dependencies:** 3.1. -**Files:** `backend/api/grants/mutations.py`, `backend/api/grants/tests/test_send_grant.py`, `backend/api/grants/tests/test_update_grant.py` -**Scope:** M - -### Task 3.3: Expose `Grant.formAnswers` (read side) - -**Description:** `formAnswers: JSON | None` on the `Grant` GraphQL type (api/grants/types.py) returning the unwrapped flat map from the linked FormAnswer, `None` when absent. Used by the edit-flow prefill in PR5. Own query test (via `me.grant`). - -**Acceptance criteria:** -- [ ] `me.grant.formAnswers` returns the flat map for a grant with FormAnswer; `null` for a legacy grant. - -**Verification:** `docker exec pycon-backend-1 uv run pytest api/grants -l` green. - -**Dependencies:** 3.2. -**Files:** `backend/api/grants/types.py`, `backend/api/grants/tests/test_grant_type.py` (or existing query test file) -**Scope:** XS - -### ▣ CHECKPOINT 3 (end of PR3) -- [ ] Full suite + lint + mypy green. Schema diff additive. -- [ ] Back-compat verified: legacy payload tests green (old frontend deployable against this backend); answers-only payload test green (new frontend's contract already proven). -- [ ] PR3 opened — **key review gate: back-compat story**. -- [ ] After merge: **deploy to staging (pastaporto) via `workflow_dispatch`** — PR5's CI codegen needs this schema live. - ---- - -## PR4 — grant admin display + export - -Suggested branch: `generic-forms/04-admin` (stacked on PR3; can start once PR3 merges) - -### Task 4.1: Read-only answers display in GrantAdmin - -**Description:** New readonly pseudo-field on `GrantAdmin` (in "The Grant" fieldset) rendering the linked FormAnswer as a question-label → answer list via `format_html` (option ids resolved to labels; multi_select joined). Empty state for historical grants. `select_related`/prefetch on the admin queryset (no N+1). - -**Acceptance criteria:** -- [ ] Grant with FormAnswer shows Q/A pairs readonly; grant without shows an empty note; changelist/change view query counts stay flat. - -**Verification:** `docker exec pycon-backend-1 uv run pytest grants/tests/test_admin.py -l` green; manual admin check. - -**Dependencies:** PR3. -**Files:** `backend/grants/admin.py`, `backend/grants/tests/test_admin.py` -**Scope:** S - -### Task 4.2: Dynamic export columns - -**Description:** `GrantResource.__init__` accepts export context via `GrantAdmin.get_export_resource_kwargs` (import-export 3.3.9 sanctioned path, verified incl. the export-form preview instantiating with the same kwargs), resolves the conference's GRANT form, appends one `Field` per question (column name = question label, `dehydrate_method` reading the FormAnswer). Historical grants export empty cells; legacy soft columns stay in `EXPORT_GRANTS_FIELDS`. - -**Acceptance criteria:** -- [ ] Export of grants with FormAnswers yields one column per question, values resolved (labels for options). -- [ ] Export of a historical conference (no form/answers) unchanged vs today. - -**Verification:** `docker exec pycon-backend-1 uv run pytest grants/tests/test_admin.py -l` green (resource-level tests). - -**Dependencies:** 4.1 (same files). -**Files:** `backend/grants/admin.py`, `backend/grants/tests/test_admin.py` -**Scope:** M - -### ▣ CHECKPOINT 4 (end of PR4) -- [ ] Full suite + lint + mypy green. Manual: export CSV from local admin with a seeded form. PR4 opened. - ---- - -## PR5 — frontend DynamicForm + grant form integration - -Suggested branch: `generic-forms/05-frontend` (stacked on PR3; **prerequisite: PR3 deployed to staging** so `frontend-lint` codegen sees the new schema) - -### Task 5.1: `DynamicForm` component + fragment - -**Description:** `frontend/src/components/dynamic-form/`: `form.graphql` fragment (form + questions incl. options), `pnpm codegen`, and `index.tsx` rendering each question by `questionType` via styleguide primitives inside `InputWrapper` (mirror `invitation-letter-form.tsx`): text→`Input`, textarea→`Textarea` (+maxLength), select→`Select`, multi_select→`Checkbox` group, boolean→`Checkbox`, url→`Input`. State via the parent's `react-use-form-state` (answers keyed by question id); errors prop consumes the `answersErrors` map (`question_id → string[]`). - -**Acceptance criteria:** -- [ ] Renders all 6 question types from a fragment-typed prop; required marking + maxLength client-side; per-question errors render under fields. -- [ ] No hand edits to generated files. - -**Verification:** `cd frontend && pnpm codegen && pnpm test && pnpm build` green (component test for render-by-type). - -**Dependencies:** PR2 + PR3 deployed to staging (codegen). -**Files:** `frontend/src/components/dynamic-form/{index.tsx,form.graphql,dynamic-form.test.tsx}` (+ regenerated `src/types.tsx`) -**Scope:** M - -### Task 5.2: Grant form integration — new submission flow - -**Description:** `grant-form/index.tsx`: fetch `conference.form(purpose: GRANT)`; replace the 8 hardcoded soft inputs with `DynamicForm`; build the `answers` map on submit and **stop sending the 8 legacy input fields**; map `answersErrors` to the component. **Null-form guard:** if `form` is `null`, block submission and show a "form not available" state — never submit without answers (Verified constraint 5). Strip the legacy `GrantErrors` validation selections for the 8 soft fields from `submit-grant.graphql`. Structured fields (fullName, nationality, grantType, travel/visa/accommodation, PublicProfileCard, privacy checkbox) untouched. Prune dead `options.ts` constants (`GENDER_OPTIONS`, `AGE_GROUPS_OPTIONS`, `OCCUPATION_OPTIONS`) only if nothing else imports them; `GRANT_TYPE_OPTIONS` stays. Accepted regression (specced): dateBirth/gender prefills drop. - -**Acceptance criteria:** -- [ ] New submission works E2E against local backend with a seeded form (success criterion 6: add question in admin → appears on page, no code change). -- [ ] `form == null` → submission blocked with visible message. -- [ ] Per-question server errors display under the right inputs; no legacy soft fields in the mutation payload. - -**Verification:** `cd frontend && pnpm test && pnpm build`; manual: docker-compose, create form in admin, submit a grant. - -**Dependencies:** 5.1. -**Files:** `frontend/src/components/grant-form/index.tsx`, `frontend/src/components/grant-form/submit-grant.graphql`, `frontend/src/components/grant-form/options.ts` -**Scope:** M - -### Task 5.3: Grant form integration — edit flow - -**Description:** Edit-flow prefill from `me.grant.formAnswers`: add `formAnswers` to `pages/grants/edit/my-grant.graphql`, feed into `DynamicForm` initial state; update `pages/grants/edit/update-grant.graphql` (strip legacy soft-field + validation selections, keep structured ones); `pages/grants/edit/index.tsx` passes the form + answers through. Legacy grants (`formAnswers == null`) show empty dynamic questions — accepted mid-cycle caveat (plan decision; cutover deploys before grants open). - -**Acceptance criteria:** -- [ ] Edit flow prefills dynamic answers and saves changes (update path, no duplicate FormAnswer). -- [ ] `pnpm build` green; edit page documents carry no legacy soft-field selections. - -**Verification:** `cd frontend && pnpm test && pnpm build`; manual: edit a grant submitted via the new flow. - -**Dependencies:** 5.2. -**Files:** `frontend/src/pages/grants/edit/{index.tsx,my-grant.graphql,update-grant.graphql}` -**Scope:** S - -### ▣ CHECKPOINT 5 — FINAL -- [ ] All spec §11 success criteria pass (walk the list one by one). -- [ ] Full backend suite, `ruff`, `mypy`, `pnpm test`, `pnpm build` green. -- [ ] Manual E2E on docker-compose: author form → submit grant → edit grant → view in admin → export CSV. -- [ ] **Ops before merging PR5:** GRANT form with the 8 current questions created and verified in **staging AND production** admin (manual — seeding command was cut from scope). Cutover timed **before grants open** for the next conference (pre-existing legacy applications would show empty dynamic questions in edit). -- [ ] Follow-up ticketed as **two** PRs (not in stack): (1) frontend-only — remove remaining legacy `GrantErrors`/`Grant` selections; (2) after deploy + soak, backend-only — remove legacy soft input fields. - ---- - -## Risks and mitigations - -| Risk | Impact | Mitigation | -|---|---|---| -| PR3 rejects/500s on the future PR5 payload | High | Verified constraint 2 baked into T3.2: conditional legacy validation, None→"" coalescing, named answers-only test | -| PR5 CI codegen can't see PR3 schema | Med | Explicit staging deploy step in Checkpoint 3; optional schema-snapshot improvement (needs approval — CI change) | -| Production GRANT form missing at PR5 deploy → silent soft-answer loss | High | Null-form guard blocks submission (T5.2); manual ops step in Checkpoint 5 for staging + production | -| Mid-cycle cutover: legacy grants' edit view shows empty questions | Med | Deploy before grants open (Checkpoint 5 ops note); backfill explicitly out of scope | -| Legacy-field removal breaks live clients | Med | Follow-up split into frontend-first + soak + backend PRs (Verified constraint 6) | -| Export preview instantiates resource with same kwargs | Low | Known from source read; resource tests cover it | -| `useFormState` dynamic keys awkward for answers record | Low | Single `answers` object in state; component test proves it before integration | - -## Parallelization - -- PR1 tasks sequential (same files). PR2 once PR1 models stable. -- After PR3 **merges**: PR4 can start. After PR3 **reaches staging**: PR5 can start. PR4 ∥ PR5. - -## Open questions - -None. All decisions resolved (error wire format committed: `answersErrors`; deadline behavior unchanged; ops steps explicit). diff --git a/tasks/generic-forms/todo.md b/tasks/generic-forms/todo.md deleted file mode 100644 index 39bb62d554..0000000000 --- a/tasks/generic-forms/todo.md +++ /dev/null @@ -1,32 +0,0 @@ -# TODO: Generic Form System - -Spec: [specs/generic-form-system.md](../../specs/generic-form-system.md) · Plan: [plan.md](plan.md) -Stack: PR1 → PR2 → PR3 → (PR4 ∥ PR5) · PR5 needs PR3 **deployed to staging** (codegen), not just merged. - -## PR1 — `generic_forms` app core (`generic-forms/01-app`) — **PR #4705** -- [x] **T1.1** App skeleton + `Form`/`FormQuestion`/`FormAnswer` + DB constraints + migration + INSTALLED_APPS. (b885f5d0e) -- [x] **T1.2** Freeze-on-answer in model: type/options/required/form + delete blocked once answered (pre_delete signal); label/order/active free. (3dfe2ee76) -- [x] **T1.3** `validate_answers()` + envelope `wrap/unwrap`. (43bb587d5) -- [x] **T1.4** Admin: FormAdmin + FormQuestionInline (freeze via model validation errors — readonly deviation documented in plan), read-only FormAnswerAdmin incl. delete block. (f9c2a273d) -- [x] **▣ CHECKPOINT 1** — 48 app tests, full suite 1191 green, ruff clean; adversarial review (3 lenses) applied (455525748); PR #4705 open. **Human review pending. Manual admin eyeball pending.** - -## PR2 — GraphQL query (`generic-forms/02-graphql-query`) — **PR #4707** -- [x] **T2.1** `api/generic_forms/types.py` (Form, FormQuestion, FormQuestionOption, FormPurpose/FormQuestionType enums) + `Conference.form(purpose)`; active-only ordered; null when unconfigured. 7 tests. -- [x] **▣ CHECKPOINT 2** — full suite 1197 green; additive-only schema change; PR #4707 open (stacked on #4705). - -## PR3 — grants backend (`generic-forms/03-grants-backend`) -- [ ] **T3.1** `Grant.form_answer` OneToOne (SET_NULL) + `blank=True` on the 4 required soft columns (`why`, `python_usage`, `been_to_other_events`, `occupation`); one migration. Columns stay NOT NULL — mutations must never pass `None`. -- [ ] **T3.2** Mutations + tests (TDD): 8 soft fields optional + optional `answers: JSON`; legacy soft-field checks run only when `answers` absent (structured-field validation unchanged on both paths); errors via `answers_errors: JSON` **direct assignment** (dotted paths impossible — verified); FormAnswer `update_or_create` in existing transaction; `None`→`""` coalescing on create; setattr skip-list on update. Named tests: **answers-only payload (exact PR5 shape)**, legacy-shape regression, invalid→atomic rollback, no-form-configured rejected, deadline-closed unchanged, update-no-duplicate. -- [ ] **T3.3** `Grant.formAnswers: JSON|null` on GraphQL type + query test (`me.grant.formAnswers`). -- [ ] **▣ CHECKPOINT 3** — suite green; back-compat verified both directions; PR3 opened; **after merge: deploy to staging (workflow_dispatch) for PR5 codegen**. - -## PR4 — admin display + export (`generic-forms/04-admin`) -- [ ] **T4.1** GrantAdmin readonly Q/A display (`format_html`), empty state, no N+1. Verify: `pytest grants/tests/test_admin.py` + manual. -- [ ] **T4.2** `GrantResource` dynamic columns via `get_export_resource_kwargs` → `__init__` fields append (3.3.9 verified path, incl. export-form preview); historical export unchanged. Verify: resource tests. -- [ ] **▣ CHECKPOINT 4** — suite green; manual CSV export; PR4 opened. - -## PR5 — frontend (`generic-forms/05-frontend`) — start only after PR3 on staging -- [ ] **T5.1** `dynamic-form/` component + fragment + codegen; 6 types via styleguide + InputWrapper (mirror invitation-letter-form); errors from `answersErrors` map; component test. Verify: `pnpm codegen && pnpm test && pnpm build`. -- [ ] **T5.2** New-submission integration: fetch `form(GRANT)`, swap 8 hardcoded inputs for DynamicForm, `answers` in payload (drop legacy 8), **null-form guard blocks submission**, strip legacy validation selections from submit-grant.graphql, prune dead options.ts constants. Verify: pnpm test/build + manual submit. -- [ ] **T5.3** Edit flow: `formAnswers` into my-grant.graphql, prefill DynamicForm, strip legacy selections from edit documents. Verify: pnpm build + manual edit. -- [ ] **▣ CHECKPOINT 5 — FINAL** — spec §11 walked one-by-one; manual E2E (author → submit → edit → admin → export); **ops: GRANT form created in staging + production admin BEFORE merge; cutover before grants open**; follow-up ticketed as TWO PRs (frontend strip → soak → backend input removal).