Skip to main content

stapel-forms

CI coverage pypi downloads python license llms.txt

Admin-defined forms with anonymous responses: a workspace-keyed form whose schema is a list of stapel-attributes FeatureDefs, immutable published versions (every response records which schema it answered), two anonymous endpoints (fetch schema by non-enumerable public handle, submit answers) hardened by module-namespaced throttles, netintel-tiered captcha, size and volume caps and a recipient-side notify cooldown, capability-gated response review with streamed injection-safe CSV export, admin-initiated resend, ids-only form.* events, a GDPR provider and a finite retention purge.

Part of the Stapel framework — composable Django apps that deploy as a monolith or as microservices without changing module code.

Install

pip install stapel-forms

At a glance

Fact Value
Version 0.4.0
Python >=3.11 (3.11, 3.12, 3.13)
HTTP operations 18
Config axes 5
Usage surface 32
Extension points 4
Error codes 75
Fleet dependencies stapel-attributes · stapel-core · stapel-gdpr (optional) · stapel-notifications (optional) · stapel-workspaces (optional)

Documentation

OpenAPI · capabilities.json · llms.txt (for agents)

What this is

Admin-defined forms with anonymous responses. A workspace admin defines a form's schema, the form gets a non-enumerable public handle, anyone holding that handle can fetch the schema and answer it without an account, and the admin reviews the answers.

Two decisions carry the whole design.

The schema is a list of stapel-attributes FeatureDefs. There is no FormField class here, no field-type enum, no validation of its own: the fleet already has exactly one field-type vocabulary — the same one that types a marketplace listing — and forms is its third consumer. A host that registers a custom attribute kind gets it in forms for free.

A published version is immutable, and every response records which version it answered. Editing a live form publishes the next version; it never rewrites a published one. A response is only interpretable against the schema it answered — delete a field or narrow a choice list under a mutable schema and budget=3 becomes an orphan nobody can read. Snapshotting the schema into every response would preserve meaning but destroy identity: "the 240 responses to version 3" would become a JSON scan, and an export would have no stable column set. A version row buys both, at one row per publish instead of one snapshot per response.

Quick start

pip install stapel-forms
INSTALLED_APPS = [
    # ...
    "stapel_forms",
]

# urls.py
path("forms/", include("stapel_forms.urls"))   # -> /forms/api/v1/...

The admin surface asks the workspaces.check_capability comm Function (fail-closed, deny-by-default) — install stapel-workspaces or provide that Function, and grant forms.* to the roles that should have it. The two respondent endpoints need none of that.

The public surface is exactly two routes

GET  /forms/api/v1/public/<public_id>/               -> the active schema
POST /forms/api/v1/public/<public_id>/submissions/   -> 201 {accepted, confirmation}

public_id is a 22-character random token, not the row's UUID: rotating it after a leak must not re-key the row, and the primary key never travels to an anonymous caller. What the GET returns is a dedicated envelope — no workspace id, no internal id, no author, no counts, no sibling forms — built by its own presenter rather than the admin one with fields removed, because dropping fields is how leaks happen the day somebody adds one.

Unknown handle, soft-deleted form and unpublished draft all answer one byte-identical 404. A closed form answers 410, because its handle was public by definition and the renderer needs the difference between "this form is closed" and "broken link".

What a hostile submitter can and cannot do

Stated, because a security posture nobody wrote down is a security posture nobody can check.

Can: burn its own IP budget; fill a form with garbage that passes typed validation; consume rows up to the form's cap.

Cannot: enumerate forms (uniform 404 behind a throttled GET); learn the workspace or any other tenant fact; store bytes outside typed answer values (no file fields, and nothing reaches disk except what the version's own configs produced through normalize_to_dao); make the module send mail at attack rate (the notify cooldown is keyed on the form, and folds the interim count into the next letter); read anything back (there is no public read of responses — the POST returns confirmation text, not even a submission id); or poison a reviewer's spreadsheet (CSV export escapes formula leads server-side, so every consumer inherits the guard).

The ladder behind that: module-namespaced throttles, a Content-Length gate before the JSON parse, netintel-tiered captcha (@captcha_protected), a per-form submission cap, a per-workspace open-form cap, and the recipient-side cooldown. Every one ships on; the confession switch is ALLOW_UNCAPTCHAED_PUBLIC.

Privacy, said plainly

Form answers are respondent PII. This module guarantees typed storage, finite retention, erasure for authenticated respondents, and admin deletion.

It does not give anonymous respondents a self-service erasure channel. stapel-gdpr keys every subject on user_id, and an email a stranger typed into a field is opaque answer content, not a subject key — an email-keyed subject is upstream platform work, not something a form module should fake. What v1 answers with instead is a finite default retention (365 days, per-form overrides may only shorten it), a purge job that actually runs, and DELETE /submissions/<id> for the request that arrives by email.

Hosts collecting sensitive categories should say so in the form's own description.

Reacting to a response

# The module's own reaction: tell the form's destinations, cooldown-gated.
# Each key maps to the request_notification keyword that addresses it, so a
# telegram chat needs no account behind it.
form.settings = {
    "notify_emails": ["sales@example.com"],
    "notify_telegram_chat_ids": ["-1001234567890"],
}

# Anything else subscribes to the fact:
@on_action("form.submission.received")
def route_it(event):
    ...  # {form_id, form_version, submission_id, workspace_id}

Events carry ids only. The outbox has no retention and a durable bus fans out to every subscriber, so a respondent's answers never ride it; a consumer that needs content reads it under forms.responses.view.

Not in v1, deliberately

File-upload fields (the platform CDN cannot take custody of an anonymous stranger's bytes and cannot gate reads — a résumé served world-readable by URL is the open switch, not the feature); conditional logic and branching (the schema reserves meta.logic); multi-language form content; a realtime response feed; honeypot fields; quotas or billing on submission volume; draft respondent saves, response editing and quiz scoring.

License

MIT — see LICENSE.


This page is assembled by stapel-readme from docs/readme.md plus the contract artifacts in docs/. Edit the prose in docs/readme.md; the badges, facts and links above and below it are generated — do not hand-edit README.md.

Metadata

Release files for stapel-forms 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for stapel-forms 0.4.0
File Size Uploaded
stapel_forms-0.4.0.tar.gz 107.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for stapel-forms 0.4.0
File Interpreter ABI Platform
stapel_forms-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 206.2 kB

Release files / stapel_forms-0.4.0.tar.gz

Download URL stapel_forms-0.4.0.tar.gz
Size 107.2 kB
Tags Source
SHA-256 checksum
How to use checksums
52b6b5599ca81f4540403f044220ea6e71253e4273b675c89265ceab32b3bb20
BLAKE2b-256 checksum
How to use checksums
13acad436c0067cccc5d41fe223b35bbec414fef0d7acc65b07d765a78b9fc64
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 27, 2026.

Transparency log

Release files / stapel_forms-0.4.0-py3-none-any.whl

Download URL stapel_forms-0.4.0-py3-none-any.whl
Size 99.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9832f4ebf79f801cc419f5347dfbcd770ed68201b1877dd12c6d9bb5816a75bc
BLAKE2b-256 checksum
How to use checksums
bfce4a6312960ab363b63bb2faf0e308a941e65d524a89a73417fce4f42b8aca
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 27, 2026.

Transparency log

Release history Release notifications | RSS feed

0.6.3

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page