Forms¶
Status: Placeholder — to be developed. Last reviewed: — Reference structural sibling:
guidelines/ui/forms.md(close cousin — that file covers visual layout, this one covers Python construction).
Scope (when this guideline lands)¶
Django form / ModelForm conventions: Crispy Forms vs manual rendering (skeleton uses manual — document why), where validation lives (form clean_* vs model clean vs service), SearchSelect widget usage on form fields, file-upload form patterns, multi-step / multi-fieldset forms, how forms wire into views.
This complements guidelines/ui/forms.md (which covers visual layout); this file covers Python-side form construction.
Out of scope (cross-refs)¶
- Form template rendering (fieldsets, labels, validation feedback slot, character counters, JS hooks) →
guidelines/ui/forms.md. SearchSelectwidget contract (when to use, sort/filter behaviour, disabled-choices) →guidelines/ui/dropdowns.md(placeholder).- Model validation (model
clean(), field constraints) →guidelines/backend/models.md(placeholder). - i18n of form labels and errors →
guidelines/i18n/translation-rules.md.
Sources to mine when writing this¶
apps/*/forms.pyacross the project — survey the patterns in use.apps/register/forms.pyis rich (HolderForm with conditional fieldsets);apps/imports/forms.pycovers file upload.templates/skeletons/skeleton_form.html— the rendering convention forms must support.apps/core/widgets.py—SearchSelectwidget contract.- The "more contextual forms" rework (holder forms in commit history) — a reference for conditional fieldsets / dynamic field visibility.
Starter hard rules to investigate¶
ModelFormoverFormwhen wrapping a model. UseFormonly for non-model inputs (search bars, filter forms, login).- Validation lives in form
clean_FIELDfor field-level checks, modelclean()for cross-field constraints that must hold at the DB layer too. - Never override
save()to do business logic — keep save() as Django's default; put post-save side effects in the view (call a service) or a modelsave()signal. SearchSelectfor any choice field with > 8 options — seeguidelines/ui/dropdowns.md(placeholder).- Use
gettext_lazyforlabel,help_text,error_messages— these are evaluated at class definition (import time), so eagergettextis wrong.
Decision points to settle¶
- Where validation lives: form vs model vs service. The codebase has all three; pick a hierarchy with explicit boundaries.
- File upload patterns: max size, allowed types, server-side validation. Survey current importers + finance attachments.
- Multi-step / wizard forms: do any exist? (Django-formtools or hand-rolled?) If yes, document; if no, declare out-of-scope.
- Form field initial values:
initial=in form vsget_initial()in view — pick the canonical placement.
Known deviations to look for during writing¶
- Forms with
def save(self):doing more thansuper().save(). - Forms importing
gettext(eager) instead ofgettext_lazy. - Plain
Formsubclasses that wrap a model (should beModelForm). - Hand-rolled CSV parsing in form
clean()instead of using importers.
If found, file as roadmap/backlog/backend-forms-drift-2026-MM.md.