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.2.0
Python >=3.11 (3.11, 3.12, 3.13)
HTTP operations 18
Config axes 5
Usage surface 31
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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

stapel_forms-0.2.0.tar.gz (95.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

stapel_forms-0.2.0-py3-none-any.whl (92.4 kB view details)

Uploaded Python 3

File details

Details for the file stapel_forms-0.2.0.tar.gz.

File metadata

  • Download URL: stapel_forms-0.2.0.tar.gz
  • Upload date:
  • Size: 95.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for stapel_forms-0.2.0.tar.gz
Algorithm Hash digest
SHA256 b084064c5b5c412b01b1a43b3897968c41b7c4e1313fa2f75d5049c7cf9c653a
MD5 80cf745ff1fa56de391929804e8abcef
BLAKE2b-256 c63992409a4cbd590e2a9f50ad6acda0fb44303b3558ef76a6797dedad9fee58

See more details on using hashes here.

Provenance

The following attestation bundles were made for stapel_forms-0.2.0.tar.gz:

Publisher: publish.yml on usestapel/stapel-forms

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file stapel_forms-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: stapel_forms-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 92.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for stapel_forms-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3dfb3555d008521442ddae321fc1b1ae5e8eecf049c6c72e903da5301bc5df91
MD5 d0ba61d8e024c508f84dc958a05f4b30
BLAKE2b-256 1571348d872c0c19a1a0d6d61a1a7c656cc84a92fb4a63904ac5f7c2b61cabc6

See more details on using hashes here.

Provenance

The following attestation bundles were made for stapel_forms-0.2.0-py3-none-any.whl:

Publisher: publish.yml on usestapel/stapel-forms

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page