Form Templates and Builder
forms-leads/form-templates-and-builder
| Kind | backend |
|---|---|
| Domain | forms-leads |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, sqlalchemy-2.0-sync, postgres, pydantic-2.12, nh3-0.2, alembic-1.16 |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/error-envelope, _shared/alembic-migrations, _shared/test-layout |
| Frontends | forms-leads/ui/form-builder-page |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
A workspace builds a form as one FormTemplate row plus an ordered list of FormField rows (11 types, per-field validation rules, conditional show/hide/require logic, multi-page grouping via page_id, a style preset, one or more conditional thank-you pages, and an optional lead-magnet file referenced from settings.lead_magnet), and the builder UI reads and writes that shape through one authenticated CRUD surface. The invariant that makes it trustworthy is that the exact same two engines (ValidationEngine, ConditionalLogicEngine) run against the exact same field definitions on both POST /forms/validate (client-side live feedback) and POST /forms/templates/{id}/submit (the authenticated test-submit path) - there is one source of truth for "is this field required right now", not a copy of the rules re-implemented on each caller (forms_router.py 443-500 and 338-440 both build field_defs the same way and hand them to the same two engine classes).
When to use
Read it before touching TemplateService.create_template/update_template/duplicate_template, FormField.get_field_schema/get_validation_errors/is_visible, ValidationEngine.validate_field/validate_form, or ConditionalLogicEngine.apply_conditional_logic; before changing what counts as a valid field_type, validation rule key, or conditional-logic operator/action; and when someone asks "why is this field still required after I hid it", "why didn't my duplicate keep the thank-you pages", "where does the lead-magnet file actually live", or "why does the style preset not match the brand colors". Trigger phrases: "form builder", "field types", "conditional logic", "multi-page form", "style preset", "thank-you page", "lead magnet upload", "form template CRUD", page_id, answer_piping, field_states.
Not this recipe: the anonymous session flow (POST /forms/sessions/start/save/email/complete, services/forms/session_service.py) that the embedded widget uses for partial-submission autosave and abandonment recovery - a sibling recipe, forms-leads/form-sessions-and-abandonment-recovery; the actual public, no-auth submission and embed-code path client sites hit (api/routers/public_forms_router.py, mounted surface_exempt("public", ...) at app_fastapi.py 949) - forms-leads/public-submission-and-widget; CRM lead creation on submit (services/forms/lead_integration.py) and the {{field}} substitution engine thank-you pages and lead-magnet emails use (services/forms/answer_piping.py), both read for context, neither owned here; the two separate AI-assist routers mounted at /api/forms/templates/.../redirect-rules and /api/forms/templates/.../ai-edit (api/forms/redirect_ai_router.py, api/forms/form_editor_ai_router.py, own files, own routers); the CRM-adjacent /forms/leads* endpoints (forms_router.py 632-869) that read Lead/TrackingContact rows joined through FormSubmission.lead_id, not template/field CRUD; and the actual AI-generation execution behind a form's ai_prompt (/submit-ai, on the public router) - this recipe only owns the two settings endpoints that read/write settings.ai_prompt (forms_router.py 1507-1571).
Stack assumptions
At 62ba4fc4: fastapi==0.118.2, synchronous handlers on a sqlalchemy.orm.Session (SQLAlchemy==2.0.43), pydantic==2.12.0 with the legacy @validator decorator (not @field_validator; still functional, just the deprecated compat path - pydantic_core==2.41.1), psycopg2-binary==2.9.10, alembic==1.16.5, nh3>=0.2.15 for sanitize_rich_content (thank-you-page rich_content only; if nh3 fails to import the sanitizer is a no-op passthrough, template_service.py 15-18 and 41-55 - a hard dependency in prod, not a defensive fallback anyone should rely on). Foundations assumed: S4 (fastapi-project-layout) for mount_router/app_fastapi.py; S5 (auth-dependency-pattern) for get_current_user (JWT bearer, then httpOnly cookie, then X-API-Key, api/dependencies.py 187-230-ish); S6 (workspace-scoping) for the WorkspaceID dependency every authenticated route in this router declares as a required parameter (_get_workspace_id, api/dependencies.py 680-779); S7 (error-envelope) for the global HTTPException handler (app_fastapi.py 724-793) every raise HTTPException(...) in this router passes through; S8 (alembic-migrations) for the incremental migrations under Data model; S9 (test-layout) for the tests/v2/coverage/ conventions the Tests section runs.
Environment: no dedicated env var gates this recipe's behavior. The whole feature is import-guarded instead - app_fastapi.py 164-170 wraps from api.routers import forms_router in try/except ImportError, logs a warning, and sets FORMS_ROUTER_AVAILABLE = False on failure; the router is then only mounted if FORMS_ROUTER_AVAILABLE (927-931). A broken import in this file or its dependencies disables the entire forms feature silently at boot, not a 500 on first request. Fail behaviour otherwise is per-route, not centralized: create_form_template wraps TemplateService.create_template in a bare except Exception as e: raise HTTPException(400, f"Failed to create template: {str(e)}") (forms_router.py 134-138) - any failure, including a DB constraint violation or an unrelated outage, becomes a 400 with the raw exception string echoed to the caller (Security & tenancy).
Data model
Deployed shape (migrations/versions/baseline_prod_schema_001.py, a pg_dump-derived reconciliation of tables that predate Alembic tracking, taken 2026-08-02, plus the incremental migrations layered on top):
form_templates (
id uuid PRIMARY KEY,
name varchar(255) NOT NULL,
description text,
category varchar(100), -- 15 allowed values, enforced only in Pydantic (below)
created_by uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
is_public boolean,
settings jsonb, -- design, multi_page, pages[], progress_bar, lead_magnet, ai_prompt (all ad hoc, no schema)
metadata jsonb, -- Python attr form_metadata, DB column "metadata"
slug varchar(255), -- declared + unique-constrained, but see Gotchas: nothing ever writes it
attached_product_id uuid REFERENCES products(id) ON DELETE SET NULL,
created_at, updated_at timestamptz DEFAULT now(),
CHECK (length(trim(name)) > 0),
UNIQUE (workspace_id, slug) -- unique_workspace_form_slug, form_slug_001 (2026-02-28)
);
form_fields (
id uuid PRIMARY KEY,
template_id uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
field_type varchar(50) NOT NULL,
label varchar(255) NOT NULL,
name varchar(255) NOT NULL,
placeholder text, help_text text,
required boolean,
"order" integer,
validation_rules jsonb, conditional_logic jsonb, options jsonb,
default_value text,
metadata jsonb, -- Python attr field_metadata, DB column "metadata"
page_id varchar(50), -- multi-page grouping, add_multipage_fields (2025-12-05)
answer_piping jsonb NOT NULL DEFAULT '{}', -- {"format": "Hi {{name}}!"}, same migration
allow_voice_input boolean, voice_max_duration_seconds integer, -- default 300s in the model, unused by this recipe's surface
created_at, updated_at timestamptz DEFAULT now(),
CHECK (field_type = 'hidden' OR length(trim(label)) > 0),
CHECK (length(trim(name)) > 0),
CHECK (field_type IN ('text','textarea','number','email','phone','url','date','datetime',
'time','select','multiselect','radio','checkbox','file','password','hidden','slider')),
UNIQUE (template_id, name)
);
form_embeds ( -- add_form_embeds, 2025-12-05; one auto-created per template at create_template time
id uuid PRIMARY KEY, template_id uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
embed_code varchar(64) UNIQUE NOT NULL, name varchar(255) DEFAULT 'Default Embed',
embed_type varchar(50) NOT NULL DEFAULT 'inline' CHECK (embed_type IN ('inline','popup','slider','fullpage')),
is_active boolean NOT NULL DEFAULT true,
allowed_domains jsonb DEFAULT '[]', style_config jsonb DEFAULT '{}', trigger_config jsonb DEFAULT '{}',
custom_css text, width varchar(50) DEFAULT '100%', max_width varchar(50) DEFAULT '600px', height varchar(50) DEFAULT 'auto',
view_count integer DEFAULT 0, submission_count integer DEFAULT 0, -- the only place a per-form submission count is tallied
created_at, updated_at timestamptz
);
thank_you_pages ( -- same migration as form_embeds
id uuid PRIMARY KEY, template_id uuid NOT NULL REFERENCES form_templates(id) ON DELETE CASCADE,
title varchar(500) DEFAULT 'Thank you!', message text DEFAULT 'Your submission has been received.',
rich_content text, -- sanitized on write via nh3 (template_service.sanitize_rich_content)
redirect_url text, redirect_delay_seconds integer DEFAULT 0,
conditions jsonb DEFAULT '[]', -- [{field, operator, value}], checked in FormTemplate priority order
is_default boolean DEFAULT false, priority integer DEFAULT 0, -- higher priority checked first
style_config jsonb DEFAULT '{}',
cta_enabled boolean DEFAULT false, cta_text varchar(255), cta_url text,
social_sharing_enabled boolean DEFAULT false, social_message text,
created_at, updated_at timestamptz
);
form_submissions exists in the same baseline (data jsonb NOT NULL, metadata jsonb, email, lead_id, contact_id, tracking_data jsonb DEFAULT '{}') and is written by this recipe's submit_form endpoint, but its read/analytics surface (get_form_submissions, lead linking) belongs to the sibling public-submission recipe; it is cited here only because submit_form inserts into it.
What is NOT stored: a per-field interaction history beyond FormAnalytics rows (event_data jsonb, no dedicated aggregation table - get_template_analytics computes nothing from these, see Gotchas); the lead-magnet file itself (only settings.lead_magnet.file_id/file_name/file_size - the bytes live in RenderStorageService, local disk or Infomaniak Swift object storage depending on STORAGE_BACKEND, services/storage/render_storage.py 1-20); the analytics views/function the module's own README.md documents under "Query Analytics" (form_responses, form_analytics_summary, field_analytics, get_field_completion_rate()) - none exist in any migration (measured 2026-09-12: zero matches for any of those four names across all 101 files in migrations/versions/), so that section of the README describes a feature that was never built.
API surface
All routes below require get_current_user and the WorkspaceID dependency (workspace resolved from X-Workspace-ID or single-workspace inference, S6) unless noted.
| Route | Codes | Notes |
|---|---|---|
GET /forms/style-presets | 200 | Brand-derived (via BrandEssence) or Alchemy-default 4-preset bundle; has_brand: bool in the body (forms_router.py 43-93) |
POST /forms/templates | 201, 400 | TemplateService.create_template; auto-creates one FormEmbed styled from the brand or an explicit design_override; writes a template_created FormAnalytics row; catches every exception into 400 (96-138) |
GET /forms/templates | 200 | Filtered/paginated list; submission_count per template is computed with one grouped SUM(FormEmbed.submission_count) query, not per-row (was N+1, fixed - 141-190) |
GET /forms/templates/{id} | 200, 404 | Fields ordered by order; writes a template_viewed event on every read (193-231) |
PUT /forms/templates/{id} | 200, 404 | Full-replace on fields if the key is present and non-empty (see Gotchas for the empty-list edge case); merges settings rather than overwriting; syncs design into the auto-created embed's style_config (234-273, TemplateService.update_template 379-473) |
DELETE /forms/templates/{id} | 204, 404 | Non-admin: creator-owned only; admin (current_user.is_admin): any template in any workspace, workspace_id filter is skipped entirely (276-303, delete_template 494-497) |
POST /forms/templates/{id}/duplicate | 200, 404 | Copies template + fields only - does NOT copy FormEmbed or ThankYouPage rows (517-578); starts private (is_public=False) regardless of the original |
POST /forms/templates/{id}/submit | 200, 404, 422 | Authenticated test-submit: runs ConditionalLogicEngine then ValidationEngine over the live field defs, inserts FormSubmission + per-field field_interaction events + one form_submitted event (338-440) |
POST /forms/validate | 200, 404 | Same two-engine pipeline as submit, no persistence; returns {valid, errors, field_states} (443-500) |
GET /forms/templates/{id}/analytics | 200, 404 | get_template_analytics - see Gotchas, three of five fields are hardcoded placeholders (553-583) |
POST /forms/templates/{id}/fields/{name}/track | 200, 404 | Writes one FormAnalytics row per call, no validation of event_type against the model's own CHECK constraint's allowed list (586-626) |
POST /forms/templates/{id}/lead-magnet/upload | 200, 400, 404, 500 | PDF/ZIP, 25MB cap, content-type OR extension match (LEAD_MAGNET_ALLOWED_TYPES/ext in ('pdf','zip')); stores via RenderStorageService, writes nothing back to settings.lead_magnet itself - the caller (builder UI) is expected to PUT the template with the returned file_id (1327-1372) |
DELETE /forms/templates/{id}/lead-magnet/file | 200, 404 | Deletes the stored file only; does not clear settings.lead_magnet on the template (1375-1400) |
GET /forms/templates/{id}/lead-magnet/preview | 200, 404 | Renders sample-variable email preview; the fields_rel attribute it checks via hasattr does not exist on FormTemplate (see Gotchas - dead branch, field-name sample vars never populate) (1403-1453) |
POST /forms/templates/{id}/lead-magnet/test | 200, 400, 404, 500 | Sends a real test email through email_service.send_lead_magnet_email (1456-1500) |
GET/PUT /forms/templates/{id}/ai-prompt | 200, 400, 404 | Reads/writes settings.ai_prompt; empty/whitespace string on PUT clears the key rather than storing "" (1507-1571) |
Application logic
Create (TemplateService.create_template, 64-145): insert FormTemplate, flush for its id, insert one FormField per item in fields with order = enumerate(...) index (client-supplied order is discarded, index order wins); mint an embed code (secrets.token_urlsafe(16)) and insert one FormEmbed, styled from design_override when the caller passed one (_normalize_design_to_style_config, snake_case AI-design schema to camelCase style_config, 147-172) or else from the creating user's most recent BrandEssence row via generate_brand_style_presets(...)['default'] (_get_brand_style_for_user, 174-219, silently returns None - and therefore an unstyled {} embed - on any exception, including no brand at all); optionally replace thank-you pages if thank_you_pages was supplied; one commit.
Update (update_template, 379-473): re-fetch scoped by workspace_id (or created_by if no workspace passed - the service layer supports both, the router always passes workspace); update_data.dict(exclude_unset=True) so only fields the caller actually sent are touched; thank_you_pages, if present, fully replaces the set (_replace_thank_you_pages, delete-all-then-insert, 228-240, sanitizing rich_content on the way in); settings is merged ({**existing, **incoming}), not overwritten, specifically so a partial settings payload from one UI panel does not erase what another panel wrote; design, if present, is written into settings['design'] AND separately pushed into the auto-created embed's style_config (_sync_design_to_embed, 242-277, maps primaryColor→also inputFocusBorderColor, secondaryColor→also textSecondaryColor, builds fontFamily/headingFontFamily as quoted CSS font-family lists); fields, if present and non-empty (falsy check, not is not None), deletes every existing FormField row for the template and re-inserts from the payload in list order - an explicit fields: [] on the wire is indistinguishable from "field not sent" and silently leaves the old fields in place (Gotchas).
Delete (delete_template, 475-515): admin bypasses the workspace_id/created_by filter entirely and can delete any template in any workspace; cascade (ondelete='CASCADE' on every child FK) removes fields, embeds, thank-you pages, submissions and analytics in one statement.
Duplicate (duplicate_template, 517-578): copies FormTemplate columns and every FormField (new ids, same order); does not touch FormEmbed or ThankYouPage - a duplicated form has no embed code and no thank-you page until the builder UI creates them, which the UI does implicitly by never calling GET .../submit against a duplicate until the operator opens and re-saves it in the builder.
Submit / Validate (both forms_router.py, 338-500), identical field-definition build:
- Fetch the template scoped to the workspace; 404 if missing.
- Build
field_defs:{name, field_type, required, validation_rules, conditional_logic}per field (note:options- choice lists - is NOT included, soValidationEngine._validate_choice'sallowed_valuescheck can only ever fire from an explicitvalidation_rules.allowed_values, never from the field's ownoptions.choices). ConditionalLogicEngine.apply_conditional_logic(field_defs, data)computesfield_states(visible/enabled/required/value per field,conditional_logic.py128-211).- Overlay states onto
field_defs: a field whosevisibleisFalsehasrequiredforced toFalse; otherwiserequiredis taken from the state. Visibility does not exempt a field from type/pattern/length validation - only from the required check - so a hidden field with a stray value in the payload (e.g. a client bug, or a field the UI hid after the user had already typed in it) still gets validated against every other rule. ValidationEngine.validate_form(data, field_defs)(319-356): per-fieldvalidate_field, short-circuits onrequiredfirst, then dispatches byfield_typeto a type-specific validator (email/phone/number/url/text/date/select-radio/checkbox/file, else a no-op generic validator that only checks acustom_validatorKEY exists without ever calling it, 308-316); returns(is_valid, {field_name: message}).submit: on invalid,422with{"errors": {...}}(no persistence); on valid, insertFormSubmission, onefield_interactionFormAnalyticsrow per submitted key, oneform_submittedevent, commit, return the ORM row serialized throughFormSubmissionResponse.validate: always200, body is{valid, errors, field_states}regardless of validity.
ConditionalLogicEngine internals (conditional_logic.py): 15 operators declared in the ConditionOperator enum (9-25) but only 14 are wired into self.operators (47-62) - MATCHES_PATTERN is declared, documented nowhere as unsupported, and silently falls through evaluate_condition's op_func = self.operators.get(operator); if not op_func: return False (89-93), i.e. a rule using it always evaluates to False. apply_conditional_logic's inverse_actions branch (198-207) only implements SHOW/HIDE - the same eight actions the if conditions_met branch supports (ENABLE/DISABLE/REQUIRE/UNREQUIRE/SET_VALUE/CLEAR_VALUE) have no inverse counterpart; the code comment at line 207 (# ... (same as above)) documents the gap without closing it.
FormField.is_visible/_evaluate_condition (models/forms.py 300-351) is a second, independent conditional-logic evaluator with its own eight operators (no starts_with/ends_with/in_list/matches_pattern) - used by the model's own to_dict()/preview helpers, not by the request pipeline above, which uses ConditionalLogicEngine instead. The two evaluators can disagree on the same conditional_logic payload for any operator only one of them implements.
Invariants
- Every authenticated route in this file takes
WorkspaceIDas a required parameter; every service-layer read/write filters byworkspace_id(or, in the twoTemplateServicemethods that accept a bareuser_idwith no workspace, bycreated_by) - no route in this recipe returns or mutates a row without one of those two scopes applied (forms_router.py, every handler;template_service.py305-308, 351-354, 398-402, 499-503). submitandvalidatebuildfield_defsand run both engines identically - divergent behavior between "what the builder shows as a live error" and "what a real submission is checked against" is a regression in one of these two handlers, not expected drift (338-500).- A field's required-ness can be overridden by conditional logic; a field's other validation rules cannot be bypassed by hiding it (step 4 above) - this is deliberate (a required-but-hidden field must not block submission) and distinct from "any rule can be skipped by hiding the field" (it cannot).
FormTemplate.settingsis merge-updated, never replace-updated, onPUT(update_template420-424) - a payload that sends only{settings: {foo: 1}}never erasesdesign,lead_magnet, orai_promptsitting in the same JSONB blob.- Admin delete (
is_admin=True) is the one path in this recipe that intentionally skips workspace scoping; every other read/write is scoped (Security & tenancy).
Frontend
forms-leads/ui/form-builder-page the builder canvas this whole CRUD surface exists to serve: field
palette, per-field validation/conditional-logic/answer-piping panels,
multi-page layout, style-preset picker, thank-you-page editor, and the
lead-magnet upload widget. At 62ba4fc4 there is no dedicated backend
"preview" endpoint for the canvas itself - grep for "preview" in
forms_router.py matches only the lead-magnet email preview route
(1403-1453); the canvas preview is presumably rendered client-side from
the template it already fetched via GET /templates/{id}, not a separate
backend round trip, but that rendering path lives in the frontend recipe,
not measured here.
Minimum admin/debug surface: no dedicated route exists beyond what's in the API table. SELECT id, name, workspace_id, slug FROM form_templates WHERE slug IS NOT NULL is expected to return zero rows at this commit (Gotchas - nothing ever sets slug); SELECT event_type, count(*) FROM form_analytics WHERE template_id = :id GROUP BY event_type is the only way to see interaction volume today, since get_template_analytics does not aggregate it.
Security & tenancy
Auth dependency: get_current_user (JWT bearer → httpOnly cookie → X-API-Key, S5), fails closed with 401 (credentials_exception, api/dependencies.py 195-199). Workspace derivation: WorkspaceID (_get_workspace_id, S6) - header-supplied workspace is re-validated against _user_has_workspace_access (403 on mismatch), no header falls back to single-workspace inference and 400/409 on zero or multiple workspaces; every handler in this router declares workspace_id: WorkspaceID as a required parameter, so there is no route in this file reachable without a resolved workspace.
Fail direction: mostly closed (404 on cross-tenant reads via the workspace_id filter - proven directly by test_get_template_cross_tenant_returns_none, test_duplicate_cross_tenant_returns_none, test_submit_form_cross_tenant_404), with two deliberate exceptions worth calling out rather than treating as bugs: admin DELETE skips the workspace filter entirely by design (is_admin=True, 494-497); the public catch-all GET /forms/{template_id} (873-896, no WorkspaceID dependency at all, include_in_schema=False) returns any template with is_public=True regardless of workspace - this is the intended public-onboarding-form path, not a leak, but it means is_public is the only tenancy boundary on that one route.
Secrets/PII at rest: none of this recipe's own tables hold a credential. create_form_template's catch-all (134-138) puts str(e) - the raw exception message - into the 400 response body; SQLAlchemy/psycopg2 exception strings can include column values (e.g. a CHECK-constraint violation message echoes the offending value), so a caller who can trigger a DB-level failure (a name that's all whitespace, an invalid field_type that slips past Pydantic) can get more detail back than a generic "bad request" should give; nothing observed in this file redacts it. Rate limits: none dedicated to this router (inherits whatever the global rate-limit middleware in S4 applies). Attacker-controlled inputs: template_data.category is restricted to 15 literal values by a Pydantic validator (schemas/forms.py 104-117) but FormField.field_type on update goes through FormFieldCreate inside FormTemplateUpdate.fields, which does carry its own @validator('field_type') (30-39) - so the 17 allowed field types are enforced consistently on create and full-field-replace update; validation_rules/conditional_logic/options are arbitrary caller-supplied JSONB with no schema validation at all beyond what the two engines tolerate at read time (an unknown key is silently ignored, not rejected).
Tests
Run 2026-09-12 against the deap-postgres-test scratch Postgres (port 5434, docker-compose.test.yml's postgres-test service), using alchemy_labs/backend/.venv on main at 62ba4fc4:
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db \
python -m pytest tests/v2/coverage/test_forms_g6_templateservice_crud_design_normalization_service_layer_db_backed_.py \
tests/v2/coverage/test_forms_g8_authenticated_forms_router_template_crud_submit_validate_endpoints_testclient_auth_tenant_.py \
tests/v2/coverage/test_forms_g1_pure_validation_engines_no_db_validationengine_conditionallogicengine.py \
tests/v2/coverage/test_forms_g2_conditionallogic_operators_progress_visibility_helpers.py \
tests/v2/coverage/test_forms_g4_style_presets_color_math_preset_generation.py \
tests/v2/coverage/test_forms_g12_forms_py_model_behavior_methods_formfield_formtemplate_formsession_formembed_thankyoupage_.py \
-q --no-cov -o addopts="" -p no:cacheprovider
printed 314 passed, 637 warnings, 7 errors in 40.89s. The 7 errors were all setup-phase psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "pg_type_typname_nsp_index" on the session-scoped test_engine fixture's CREATE TYPE ... AS ENUM calls - this is the exact failure mode the coverage suite's own conftest.py docstring warns about ("intended to be run against a freshly reset public schema... does not always round-trip cleanly between separate pytest invocations"): this session's scratch database already carried ENUM types from an earlier, unrelated test run in the same environment. None of the 7 errored tests are specific to this recipe's code (they span both test_forms_g6 and test_forms_g8, at setup, before any test body ran) and no assertion inside a collected test failed. 315 def test_ functions across the six files (grep -c "^def test_": 34, 31, 83, 44, 40, 83) plus 2 @pytest.mark.parametrize decorators (test_forms_g2, adding a handful of extra parametrized cases) account for the 314 passed + 7 errored = 321 collected total.
Hermetic vs live: test_forms_g1/g2/g4(color math)/g12 are pure, no DB, no auth (ValidationEngine, ConditionalLogicEngine, style_presets helpers, models.forms instance methods built in memory); test_forms_g4's brand-preset-endpoint tests and test_forms_g6/g8 are DB-backed against the real Postgres via the shared db_session/test_engine fixtures (test-layout, S9), g8 additionally spins a real TestClient(app, raise_server_exceptions=False) with get_current_user/_get_workspace_id overridden per-tenant so cross-tenant 404s are asserted through the actual HTTP stack, not mocked. Known-answer coverage: style_presets' lighten('#000000', 0.5)/darken(...)/hex_to_rgba blend math and _find_brand_white_black's luminance-threshold branching (0.3/0.85 cutoffs) are asserted with real hex values in test_forms_g4; ValidationEngine's 6 built-in PATTERNS and every type-specific validator's boundary (min/max, min_length/max_length, require_https, allowed_domains/blocked_domains) are asserted in test_forms_g1.
What a rebuild adds and this suite does not cover: no test asserts on ConditionOperator.MATCHES_PATTERN's silent-False fallthrough or the inverse_actions action gap (Application logic) - both are read from the source, not pinned by a failing-then-fixed test; no test exercises the settings: {} empty-object vs fields: [] empty-list update edge case (Gotchas) end to end through the router.
Deploy & ops
Config keys: none dedicated (Stack assumptions) beyond the import-availability guard. Migrations, in the order that touches this recipe's tables: baseline_prod_schema_001 (down_revision None, reconciliation of pre-Alembic tables including form_templates/form_fields/form_submissions/form_analytics in their 2026-08-02 live-staging shape) → add_multipage_fields (down-revision add_form_sessions, adds page_id/answer_piping to form_fields) → add_form_embeds (down-revision add_multipage_fields, creates form_embeds AND thank_you_pages) → form_slug_001 (down-revision fix_hidden_label_001, adds form_templates.slug + the unique_workspace_form_slug constraint, both via add_column_if_missing/create_unique_constraint_if_missing - idempotent because form_templates is a phantom/baselined table) → form_analytics_event_types_002 (down-revision booking_slot_lock_001, extends the form_analytics CHECK constraint's allowed event_type list). At 62ba4fc4, alembic heads against the scratch database resolves to a single head, prima_device_codes_001 - no branch/merge conflict in the chain.
Scheduler/background jobs: none owned by this recipe (no cron, no APScheduler job touches form_templates/form_fields). Argus-style checks: POST /api/forms/validate with a known template id and an intentionally-incomplete data body returns 200 {"valid": false, ...} with a non-empty errors dict - proves the two-engine pipeline is reachable without risking a write. Rollback: every table here cascades from form_templates, so a code rollback needs no data unwind; the four incremental migrations are each individually reversible (downgrade() defined in all of them) but baseline_prod_schema_001 has no meaningful downgrade (it documents a snapshot, not a change).
Gotchas
- 2026-09-12 (measured at 62ba4fc4,
services/forms/README.md): the module's own README lists "Multi-page forms with progress tracking" under Future Enhancements (line 336) even though multi-page is fully shipped -FormTemplate.is_multi_page/get_pages/get_fields_for_page/get_page_count/get_progress_bar_settings(models/forms.py112-135) and thepage_id/answer_pipingcolumns (migrationadd_multipage_fields, 2025-12-05) predate this README's "future" framing by about ten months. The same README's "Query Analytics" section documents aform_responsesview, aform_analytics_summaryview, afield_analyticsview, and aget_field_completion_rate()function - none of the four exist in any of the 101 files undermigrations/versions/(measured directly). Treat this file as stale, not authoritative. - 2026-09-12 (measured at 62ba4fc4,
template_service.py580-639):get_template_analytics- the function behindGET /forms/templates/{id}/analytics- returnstotal_views: 0andcompletion_rate: 0.0as literal hardcoded values (the code comments say# Placeholder), and its per-fieldfield_analyticsdict always reportsinteractions: 0, completion_rate: 0.0for every field regardless of how manyFormAnalyticsrows actually exist for that template. Onlytotal_submissions(a realCOUNT(*)onform_submissions) andcreated_atare real. This is whymaturity: prodabove carries a caveat rather than an unqualified claim: the CRUD/validate/submit/lead-magnet surface is live and tested, the analytics surface is not functional. - 2026-09-12 (measured at 62ba4fc4,
schemas/forms.py138-148 vs 125-135):FormTemplateCreate.fieldshas a@validator('fields')that rejects duplicate field names and an empty list;FormTemplateUpdate.fieldshas no such validator. APUTthat fully replaces a template's fields with two fields sharing anamewill pass Pydantic validation and then fail at the database'sform_fields_unique_name_per_templateconstraint instead, surfacing as an unhandledIntegrityError(500, not a clean 400/422) rather than the same-shape validation errorPOST /templateswould have given for the identical payload. - 2026-09-12 (measured at 62ba4fc4,
template_service.py441 andforms_router.py- the router passes the rawFormTemplateUpdate): the field-replace branch on update is guarded byif 'fields' in update_dict and update_dict['fields']:- a truthy check, notis not None. A caller that explicitly sends{"fields": []}intending "delete all fields" has that intent silently ignored; the existing fields are left untouched with no error and no indication anything was skipped. - 2026-09-12 (measured at 62ba4fc4,
services/forms/conditional_logic.py9-62 and 128-211):ConditionOperator.MATCHES_PATTERNis declared in the enum and nowhere else - it is not inself.operators, so any conditional-logic rule using it always evaluatesFalsewith no error, warning, or log line. Separately,apply_conditional_logic'sinverse_actionsbranch only implementsSHOW/HIDE; a rule that setsENABLE/DISABLE/REQUIRE/UNREQUIRE/SET_VALUE/CLEAR_VALUEas an inverse action is silently a no-op on that branch. The module's own# ... (same as above)comment at line 207 documents the gap without closing it. - 2026-09-12 (measured at 62ba4fc4,
models/forms.py300-351 vsservices/forms/conditional_logic.py): there are two independent conditional-logic evaluators in this recipe's scope -FormField._evaluate_condition(8 operators, nostarts_with/ends_with/in_list/not_in_list/matches_pattern) andConditionalLogicEngine(14 wired operators). The request pipeline (submit/validate) uses only the second; the first is reachable fromFormField.is_visible, called nowhere in this recipe's router at this commit (not verified against the frontend, which is out of scope) but present as live model surface a caller could invoke and get a different answer from than the API would give for the sameconditional_logicpayload on an operator only one of the two implements. - 2026-09-12 (measured at 62ba4fc4,
services/forms/validation_engine.py251-258 andmodels/forms.py271-272):ValidationEngine._validate_choicechecksvalueagainstvalidation_rules['allowed_values']only - it never reads the field's ownoptions.choices(the actual dropdown/radio options the builder UI stores perFormField.get_field_schema, 270-272). A select/radio field with choices defined only inoptions(the normal way the builder stores them) and no separatevalidation_rules.allowed_valuesaccepts ANY string value at submit time, not just one of its declared choices. - 2026-09-12 (measured at 62ba4fc4,
forms_router.py1432-1436):preview_lead_magnet_email's per-field sample-variable loop checkshasattr(fields, 'fields_rel')-FormTemplatehas no attribute namedfields_rel(the relationship isfields,models/forms.py37) - so this branch is permanently dead; the lead-magnet preview only ever substitutes the four hardcoded sample vars (first_name,last_name,email,company), never a real field name, regardless of what the form actually collects. - 2026-09-12 (measured at 62ba4fc4,
backend/create_default_form.py, whole file): this standalone maintenance script constructsFormTemplate(...)with noworkspace_idat all, against a schema whereform_templates.workspace_idisNOT NULL(Data model). Run as-is against this commit's schema it fails with anIntegrityErrorondb.commit(), not the friendlyERROR:message its ownexcept Exceptionbranch prints for other failure modes (that branch does catch and roll back, so it fails cleanly, just not for a reason its own error message describes). The script is referenced only from.claude/FORM_BUILDER_IMPLEMENTATION.md, a docs file, and from no deploy/init script found in the repo - it reads as dead/unmaintained rather than a real bootstrap path. - 2026-09-12 (measured at 62ba4fc4,
schemas/forms.py, whole file):FormFieldUpdateis defined (56-70) and imported nowhere else in the repo (grepped every.pyfile outsideschemas/forms.py) - the router always replaces the full field list viaFormFieldCreateon update, never patches one field in place. TreatFormFieldUpdateas unused surface, not a documented-but-hidden per-field PATCH capability. - 2026-09-12 (measured at 62ba4fc4,
app_fastapi.py724-793 andforms_router.py390-394): the global error-envelope handler's dict-detail branch readsdetail.get("error") or detail.get("message") or detailto build the client-facingmessage.submit_form's 422 sendsdetail={"errors": errors}(plural key, no singularerror/message) - so the envelope'smessagefield ends up as the Python-repr string of the whole dict ("{'errors': {'email': 'email is required'}}") rather than a clean sentence. The field-level detail is still present (indetails, viajsonable_encoder(detail)), just not through themessagefield the way every{"error": ...}-shaped 4xx in the rest of the codebase reads. - 2026-09-12 (measured at 62ba4fc4,
models/forms.py,TemplateService,forms_router.py):FormTemplate.slugis a real, migrated, unique-constrained column, but no code path in this recipe's surface ever assigns it - greppedtemplate_service.pyandforms_router.pyforslug=/.slug =, zero hits. The column and itsunique_workspace_form_slugconstraint exist for a feature (slug-addressable public forms) with no write path here; Postgres's standard NULL-is-distinct behavior means the constraint does not even stop multiple templates in one workspace from all havingslug IS NULL.
Reference implementation
Paths relative to the repo root; all at alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
alchemy_labs/backend/models/forms.py(958 lines):FormTemplate(18-136,to_dict55-78, multi-page helpers 112-135);FormField(138-440,get_field_schema212-294,is_visible/_evaluate_condition296-351,get_validation_errors353-440);FormSubmission(443-519);FormAnalytics(522-558,event_typeCHECK 542-548);FormSession(561-702, out of this recipe's scope, cited for the FKform_submissions.session_idpoints at);FormEmbed(704-853, default style config 763-813);ThankYouPage(856-958,matches_conditions934-958).alchemy_labs/backend/schemas/forms.py(709 lines, whole file in scope): field schemas 14-83; template schemas 89-176; submission schemas 182-207; validation schemas 214-224; embed/style schemas 434-559; thank-you-page schemas 565-624; AI-prompt schemas 701-709.alchemy_labs/backend/api/routers/forms_router.py(1571 lines): style presets 43-93; template CRUD 96-335; submit/validate 338-500; submissions/analytics/tracking 503-626; leads (out of scope, cited for the catch-all-route ordering comment at 630-631) 632-869; public catch-all 873-896; sessions (out of scope) 903-1313; lead magnet 1316-1500; AI prompt 1503-1571.alchemy_labs/backend/services/forms/template_service.py(639 lines, whole file in scope): sanitize helper 15-55;TemplateService58-639.alchemy_labs/backend/services/forms/validation_engine.py(356 lines, whole file in scope).alchemy_labs/backend/services/forms/conditional_logic.py(375 lines, whole file in scope).alchemy_labs/backend/services/forms/style_presets.py(229 lines, whole file in scope).alchemy_labs/backend/create_default_form.py(220 lines, whole file in scope - see Gotchas).alchemy_labs/backend/migrations/versions/baseline_prod_schema_001.py(form tables 1043-1127),20251205_add_multipage_fields.py,20251205_add_form_embeds.py,20260228_add_form_slug.py,20260612_0001_form_analytics_event_types.py.- Tests as listed under Tests, plus
tests/v2/coverage/conftest.pyfor the sharedtest_engine/db_sessionfixtures this recipe's Acceptance checks depend on.
Deviations of the reference from an ideal implementation, all dated 2026-09-12: get_template_analytics is two-thirds placeholder; ConditionOperator.MATCHES_PATTERN and half of inverse_actions are declared but not implemented; the model-level and engine-level conditional-logic evaluators can disagree; select/radio validation does not check the field's own options.choices; slug has no write path; create_default_form.py is broken against the live schema and appears unmaintained; the lead-magnet email preview's per-field sample-variable substitution is dead code (wrong attribute name).
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc4 with the deap-postgres-test scratch Postgres up on port 5434.
TEST_DATABASE_URL=postgresql://test_user:test_password@localhost:5434/test_db python -m pytest tests/v2/coverage/test_forms_g6_templateservice_crud_design_normalization_service_layer_db_backed_.py tests/v2/coverage/test_forms_g8_authenticated_forms_router_template_crud_submit_validate_endpoints_testclient_auth_tenant_.py tests/v2/coverage/test_forms_g1_pure_validation_engines_no_db_validationengine_conditionallogicengine.py tests/v2/coverage/test_forms_g2_conditionallogic_operators_progress_visibility_helpers.py tests/v2/coverage/test_forms_g4_style_presets_color_math_preset_generation.py tests/v2/coverage/test_forms_g12_forms_py_model_behavior_methods_formfield_formtemplate_formsession_formembed_thankyoupage_.py -q --no-cov -o addopts="" -p no:cacheproviderprints314 passed(see Tests for the 7 setup-phase errors and why they are environmental, not a code failure).grep -c "^def test_" tests/v2/coverage/test_forms_g6_templateservice_crud_design_normalization_service_layer_db_backed_.py tests/v2/coverage/test_forms_g8_authenticated_forms_router_template_crud_submit_validate_endpoints_testclient_auth_tenant_.py tests/v2/coverage/test_forms_g1_pure_validation_engines_no_db_validationengine_conditionallogicengine.py tests/v2/coverage/test_forms_g2_conditionallogic_operators_progress_visibility_helpers.py tests/v2/coverage/test_forms_g4_style_presets_color_math_preset_generation.py tests/v2/coverage/test_forms_g12_forms_py_model_behavior_methods_formfield_formtemplate_formsession_formembed_thankyoupage_.pyprints34,31,83,44,40,83(315 total).python -c "from models.forms import FormSubmission; print(FormSubmission().metadata)"(run inside.venvwithapp_fastapiimported first for model registration) printsMetaData(), not{}- confirmsmetadatais a reserved SQLAlchemy declarative attribute name shadowing thesubmission_metadatacolumn (Column('metadata', JSONB, ...)) on any instance that was not constructed with an explicitmetadata=keyword argument.grep -n "ConditionOperator.MATCHES_PATTERN" services/forms/conditional_logic.pyshows it declared in the enum (line 25) andgrep -c "MATCHES_PATTERN" services/forms/conditional_logic.pyprints1- the single declaration, confirming it is never referenced again in the file (not wired intoself.operators).python3 -c "import os; hits=[f for f in os.listdir('migrations/versions') if 'form_responses' in open('migrations/versions/'+f,encoding='utf-8',errors='ignore').read() or 'get_field_completion_rate' in open('migrations/versions/'+f,encoding='utf-8',errors='ignore').read()]; print(hits)"prints[], confirming the README's documented analytics views/function do not exist.grep -n "slug=" services/forms/template_service.py api/routers/forms_router.pyprints nothing, confirming no write path setsFormTemplate.slug.alembic heads(withDATABASE_URL/SYNC_DATABASE_URLpointed at the scratch database andalembic upgrade headalready run) prints a single head,prima_device_codes_001 (head).
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.