Skip to content

Generic form system: grants backend integration (PR 3/5) - #4709

Open
marcoacierno wants to merge 8 commits into
generic-forms/02-graphql-queryfrom
generic-forms/03-grants-backend
Open

Generic form system: grants backend integration (PR 3/5)#4709
marcoacierno wants to merge 8 commits into
generic-forms/02-graphql-queryfrom
generic-forms/03-grants-backend

Conversation

@marcoacierno

@marcoacierno marcoacierno commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

Third PR of the stack (stacked on #4707#4705). Wires grants to the generic form engine — fully backward compatible on the wire: the deployed frontend keeps working unchanged.

  • Grant.form_answer: nullable OneToOne to generic_forms.FormAnswer (SET_NULL); the 4 still-required soft columns (why, python_usage, been_to_other_events, occupation) become blank=True. Columns stay NOT NULL — the mutation layer never writes None.
  • sendGrant/updateGrant: the soft-question fields become optional; new optional answers: JSON map (question_id → value).
    • Dynamic question set = 6: why, python_usage, been_to_other_events, community_contribution, age_group, notes. gender and occupation stay structuredgrants/summary.py aggregates both columns for the grant summary reporting (caught in review by @marcoacierno); they remain optional inputs that coalesce to "" when omitted.
    • Legacy path (no answers): behavior byte-identical — soft-field required/max-length checks unchanged, regression-tested.
    • Answers path: validated via generic_forms.validate_answers against the conference's GRANT form; rejected with a clear error when no form is configured; per-question failures returned in a new answersErrors: JSON map on GrantErrors (dynamic question ids can't be static fields; add_error only appends to typed lists, so the map is assigned directly).
    • FormAnswer persisted (versioned envelope) via update_or_create inside the existing @transaction.atomic, linked from the grant.
  • Grant.formAnswers: JSON | null exposes the flat answers map for the edit-flow prefill (from_model attaches the relation — me.grant returns a detached instance).

Test plan

  • Named test sending the exact post-cutover frontend payload (answers map, dynamic fields omitted) end-to-end → Grant + linked FormAnswer, empty dynamic columns, gender/occupation persisted
  • Defensive tests: answers payload omitting gender/occupation stores "" (never None into NOT NULL columns), on both create and update
  • Invalid answers → answersErrors per question, nothing persisted; answers-without-form rejected; update path reuses the existing FormAnswer (no duplicates); legacy soft columns untouched by answers updates
  • All pre-existing grants tests green unmodified (legacy-shape regression proof)
  • Full suite 1208 passed; ruff + format clean

Deploy note

After this merges to main: deploy to staging (pastaporto) via workflow_dispatch — PR5's frontend CI codegens against the staging schema and needs answers/answersErrors/formAnswers live there.

Stack: #4705#4707PR3 (this) → PR4 admin display/export → PR5 frontend.

Grant.form_answer (nullable OneToOne, SET_NULL) points at the
generic_forms.FormAnswer holding the dynamic soft-question answers.
The four still-required soft columns (why, python_usage,
been_to_other_events, occupation) become blank=True: conferences using
the generic form no longer populate them. Columns stay NOT NULL —
the mutation layer never writes None.
sendGrant/updateGrant gain an optional answers map (question_id ->
value). The eight legacy soft-question fields become optional; their
required/max-length checks now run only on the legacy path, while the
answers path validates through generic_forms.validate_answers against
the conference's GRANT form (rejected when none is configured).
Per-question failures are returned in a new answersErrors JSON map on
GrantErrors, assigned directly because add_error() only appends to the
statically-typed list fields.

Answers persist as a FormAnswer (versioned envelope, update_or_create
inside the existing atomic block) linked from Grant.form_answer. Soft
columns are NOT NULL, so omitted legacy fields coalesce to empty
strings on create and are skipped on update. Legacy payloads behave
exactly as before.
formAnswers returns the unwrapped {question_id: value} map from the
linked FormAnswer (null for legacy grants), so the frontend edit flow
can prefill the dynamic questions. from_model() attaches the relation
since me.grant returns a detached instance, not the Django model.
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pycon Ready Ready Preview Aug 7, 2026 2:44am

@claude

claude Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Wires Grant mutations to the generic form engine: adds Grant.form_answer (nullable OneToOne to generic_forms.FormAnswer), makes the legacy soft-question inputs optional, and adds an answers/answersErrors path validated against the conference's GRANT form.

Validation regression for the legacy (no-generic-form) path

age_group and occupation used to be non-nullable enum fields on SendGrantInput/UpdateGrantInput (backend/api/grants/mutations.py), so the GraphQL schema itself guaranteed a valid, non-empty value. They're now AgeGroup | None = None / Occupation | None = None to support the answers path, but BaseGrantInput.validate() only backfills why, python_usage, been_to_other_events into non_empty_fields when not uses_answers (mutations.py:133-135). age_group/occupation/gender were left out, so a conference that has not configured a generic form (uses_answers is False) can now submit/update a grant with age_group/occupation omitted entirely, silently persisted as "". Previously this was impossible to express over the API. Since grants/summary.py aggregates these columns for reporting, blank values there will now be silently possible for legacy conferences. Worth adding these three back to the required-field check when not uses_answers, and a regression test for it (existing test suite doesn't cover this since it was previously enforced by the schema, not app logic).

Minor: redundant Form query

BaseGrantInput.validate() already fetches Form.objects.filter(conference=..., purpose=Form.Purpose.GRANT).first() to validate answers, then _persist_form_answer() re-fetches the same row with Form.objects.get(...) inside the mutation. Not a correctness issue, just an avoidable extra query per submission.

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.64865% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 92.49%. Comparing base (321f30f) to head (4528916).

Additional details and impacted files
@@                        Coverage Diff                         @@
##           generic-forms/02-graphql-query    #4709      +/-   ##
==================================================================
+ Coverage                           92.47%   92.49%   +0.02%     
==================================================================
  Files                                 359      359              
  Lines                               10921    10970      +49     
  Branches                              852      863      +11     
==================================================================
+ Hits                                10099    10147      +48     
  Misses                                706      706              
- Partials                              116      117       +1     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The helper only reads input.answers; passing the map directly drops
the input-union annotation and decouples it from the input classes.
grants/summary.py aggregates both columns for the grant summary
reporting, so they are load-bearing, not soft questions: moving them
into the dynamic form would blank the gender/occupation breakdowns for
every new application. The dynamic question set shrinks to six (why,
python_usage, been_to_other_events, community_contribution, age_group,
notes). Both inputs stay optional and coalesce to empty strings when
omitted, since their columns are NOT NULL.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant