stapel-forms 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. stapel_forms-0.2.0/CONFIG.MD +81 -0
  2. stapel_forms-0.2.0/LICENSE +21 -0
  3. stapel_forms-0.2.0/PKG-INFO +211 -0
  4. stapel_forms-0.2.0/README.md +182 -0
  5. stapel_forms-0.2.0/__init__.py +31 -0
  6. stapel_forms-0.2.0/_capabilities.py +48 -0
  7. stapel_forms-0.2.0/_codegen.py +117 -0
  8. stapel_forms-0.2.0/_codegen_settings.py +108 -0
  9. stapel_forms-0.2.0/actions.py +51 -0
  10. stapel_forms-0.2.0/admin.py +44 -0
  11. stapel_forms-0.2.0/apps.py +31 -0
  12. stapel_forms-0.2.0/authz.py +110 -0
  13. stapel_forms-0.2.0/checks.py +144 -0
  14. stapel_forms-0.2.0/codegen_urls.py +15 -0
  15. stapel_forms-0.2.0/conf.py +107 -0
  16. stapel_forms-0.2.0/conftest.py +141 -0
  17. stapel_forms-0.2.0/docs/capabilities.json +424 -0
  18. stapel_forms-0.2.0/docs/errors.json +658 -0
  19. stapel_forms-0.2.0/docs/flows.json +1 -0
  20. stapel_forms-0.2.0/docs/llms.txt +237 -0
  21. stapel_forms-0.2.0/docs/schema.json +1466 -0
  22. stapel_forms-0.2.0/dto.py +110 -0
  23. stapel_forms-0.2.0/errors.py +136 -0
  24. stapel_forms-0.2.0/events.py +59 -0
  25. stapel_forms-0.2.0/export.py +122 -0
  26. stapel_forms-0.2.0/functions.py +13 -0
  27. stapel_forms-0.2.0/gdpr.py +82 -0
  28. stapel_forms-0.2.0/management/__init__.py +0 -0
  29. stapel_forms-0.2.0/management/commands/__init__.py +0 -0
  30. stapel_forms-0.2.0/management/commands/forms_purge_expired.py +22 -0
  31. stapel_forms-0.2.0/migrations/0001_initial.py +88 -0
  32. stapel_forms-0.2.0/migrations/__init__.py +0 -0
  33. stapel_forms-0.2.0/models.py +157 -0
  34. stapel_forms-0.2.0/notifications.py +178 -0
  35. stapel_forms-0.2.0/presenters.py +317 -0
  36. stapel_forms-0.2.0/py.typed +0 -0
  37. stapel_forms-0.2.0/pyproject.toml +105 -0
  38. stapel_forms-0.2.0/schema.py +254 -0
  39. stapel_forms-0.2.0/schemas/emits/form.closed.json +12 -0
  40. stapel_forms-0.2.0/schemas/emits/form.published.json +13 -0
  41. stapel_forms-0.2.0/schemas/emits/form.submission.received.json +14 -0
  42. stapel_forms-0.2.0/serializers.py +153 -0
  43. stapel_forms-0.2.0/services.py +463 -0
  44. stapel_forms-0.2.0/setup.cfg +4 -0
  45. stapel_forms-0.2.0/stapel_forms.egg-info/PKG-INFO +211 -0
  46. stapel_forms-0.2.0/stapel_forms.egg-info/SOURCES.txt +111 -0
  47. stapel_forms-0.2.0/stapel_forms.egg-info/dependency_links.txt +1 -0
  48. stapel_forms-0.2.0/stapel_forms.egg-info/requires.txt +4 -0
  49. stapel_forms-0.2.0/stapel_forms.egg-info/top_level.txt +1 -0
  50. stapel_forms-0.2.0/tasks.py +78 -0
  51. stapel_forms-0.2.0/tests/test_admin_api.py +242 -0
  52. stapel_forms-0.2.0/tests/test_checks.py +76 -0
  53. stapel_forms-0.2.0/tests/test_comm.py +117 -0
  54. stapel_forms-0.2.0/tests/test_contract.py +124 -0
  55. stapel_forms-0.2.0/tests/test_export.py +118 -0
  56. stapel_forms-0.2.0/tests/test_field_kinds.py +210 -0
  57. stapel_forms-0.2.0/tests/test_lifecycle.py +126 -0
  58. stapel_forms-0.2.0/tests/test_notifications.py +147 -0
  59. stapel_forms-0.2.0/tests/test_public_api.py +211 -0
  60. stapel_forms-0.2.0/tests/test_retention.py +137 -0
  61. stapel_forms-0.2.0/tests/test_schema.py +181 -0
  62. stapel_forms-0.2.0/translations/.state.json +174 -0
  63. stapel_forms-0.2.0/translations/errors.es.json +23 -0
  64. stapel_forms-0.2.0/translations/errors.ru.json +23 -0
  65. stapel_forms-0.2.0/urls.py +18 -0
  66. stapel_forms-0.2.0/urls_v1.py +93 -0
  67. stapel_forms-0.2.0/views.py +720 -0
@@ -0,0 +1,81 @@
1
+ # CONFIG.MD — stapel-forms
2
+
3
+ Config registry for **stapel-forms** (`static-scaffold-and-config.md` §2).
4
+ One row per configuration key the module reads, its **source** (`env` = the
5
+ process environment / the `STAPEL_FORMS` settings dict), what it is for,
6
+ whether it is required, and its default.
7
+
8
+ All keys are read through `forms_settings`
9
+ (`stapel_forms.conf.AppSettings`, namespace `STAPEL_FORMS`).
10
+ Resolution order per key: `settings.STAPEL_FORMS` dict → a flat Django
11
+ setting of the same name → environment variable → the default below.
12
+ There are no `import_strings` keys in this module.
13
+
14
+ ## stapel-forms
15
+
16
+ ### Schema surface (source: env)
17
+
18
+ | Key | Source | Purpose | Required | Default |
19
+ |-----|--------|---------|----------|---------|
20
+ | FIELD_KINDS | env | allowlist of stapel-attributes kinds a form schema may use. A kind registered in the attributes registry but absent here cannot be asked on a public URL. Kinds implying binary payloads are absent by construction — there is no anonymous attachment path (check `stapel_forms.E001` refuses kinds nothing answers for; `E002` refuses an empty list). | no | the ten builtins: string, int, float, bool, select, date, header, hex_color, hierarchical_select, convertible_unit |
21
+ | MAX_FIELDS_PER_FORM | env | ceiling on fields in a published version; also bounds the CSV column count. | no | 64 |
22
+
23
+ ### Abuse ladder (source: env)
24
+
25
+ | Key | Source | Purpose | Required | Default |
26
+ |-----|--------|---------|----------|---------|
27
+ | MAX_SUBMISSION_BYTES | env | `Content-Length` gate applied BEFORE the JSON parse; over it is 413 `error.413.forms_body_too_large`. 0 disables. | no | 65536 |
28
+ | MAX_SUBMISSIONS_PER_FORM | env | per-form row cap bounding what one leaked public link can cost; past it the submit answers 409 `error.409.forms_submission_cap`. 0 disables. | no | 10000 |
29
+ | MAX_OPEN_FORMS_PER_WORKSPACE | env | ceiling on simultaneously open forms in one workspace. 0 disables. | no | 100 |
30
+ | SUBMIT_THROTTLE | env | DRF scoped rate for the anonymous submit, per client IP (netintel-resolved, never raw XFF). A library cannot own `DEFAULT_THROTTLE_RATES`, so the rate lives here. `None` disables — a conscious act. | no | 20/h |
31
+ | PUBLIC_SCHEMA_THROTTLE | env | same, for the anonymous schema fetch — the enumeration backstop. `None` disables. | no | 120/h |
32
+ | ALLOW_UNCAPTCHAED_PUBLIC | env | the confession switch. Off: a deployment with an open public form and no `STAPEL_CAPTCHA["SECRET"]` gets `stapel_forms.W001`. On: the warning is silenced because the host says the refusal is deliberate. | no | False |
33
+
34
+ ### Respondent privacy (source: env)
35
+
36
+ | Key | Source | Purpose | Required | Default |
37
+ |-----|--------|---------|----------|---------|
38
+ | STORE_CLIENT_META | env | persist the respondent's IP and user-agent on each submission. Off: nothing needs them on disk — throttling and netintel classification work off the live request and the cache. On: that data falls under the same retention clock as the answers. | no | False |
39
+ | RETENTION_DAYS | env | how long a response lives before the purge destroys it. A per-form `settings.retention_days` may only SHORTEN this; lengthening past the ceiling requires raising it here, so one form cannot rewrite the deployment's promise. `None` keeps answers forever — an explicit host decision. | no | 365 |
40
+ | PURGE_SCHEDULE | env | crontab kwargs for the retention beat entry (`get_forms_beat_schedule()`). Cadence is configuration, not a literal. | no | {"hour": 4, "minute": 40} |
41
+
42
+ ### Notifications (source: env)
43
+
44
+ | Key | Source | Purpose | Required | Default |
45
+ |-----|--------|---------|----------|---------|
46
+ | NOTIFY_COOLDOWN_SECONDS | env | minimum gap between auto-notify letters for one form; submissions inside the window fold into the next letter as a count. Keyed on the form, not the caller — the protected resource is the owner's inbox. Admin-initiated resend is deliberately NOT subject to it. 0 notifies every time. | no | 600 |
47
+
48
+ ### Versioning (source: env)
49
+
50
+ | Key | Source | Purpose | Required | Default |
51
+ |-----|--------|---------|----------|---------|
52
+ | ACCEPT_PREVIOUS_VERSION_SECONDS | env | grace window during which a submit naming the previous version is still accepted after a publish. 0 (strict) refuses it wholesale with 409 `error.409.forms_version_superseded`, so the renderer refetches and tells the respondent rather than half-validating against a schema they never saw. | no | 0 |
53
+
54
+ ### Response review (source: env)
55
+
56
+ | Key | Source | Purpose | Required | Default |
57
+ |-----|--------|---------|----------|---------|
58
+ | MAX_PAGE_SIZE | env | service-side cap on `?limit=` for the keyset response listing. | no | 100 |
59
+ | EXPORT_PAGE_SIZE | env | rows per CSV export request; the continuation cursor comes back in the `X-Forms-Next-Before` header rather than materializing the whole table. | no | 1000 |
60
+
61
+ ## Keys this module READS from other namespaces
62
+
63
+ | Key | Owner | Why it matters here |
64
+ |-----|-------|---------------------|
65
+ | STAPEL_CAPTCHA["SECRET"] | stapel-core | with no backend configured the captcha verifier is a no-op and every bot that finds a public link is accepted; `stapel_forms.W001` says so. |
66
+ | STAPEL_WORKSPACES["ROLES"] | stapel-workspaces | grants `forms.*` to roles. Without a grant every admin request denies; only `owner` has `*` by default. An entry REPLACES a whole role — there is no deep merge. |
67
+ | STAPEL_GDPR["DATA_OWNERS"] | stapel-gdpr | must include `"forms"`, or the erasure closure never completes (`gdpr.E002`; `stapel_forms.W003` warns from this side). |
68
+ | STAPEL_NOTIFICATIONS["TYPES"] | stapel-notifications | the host-side bridge for `forms.submission_received` / `forms.submission_resend` until the upstream `NOTIFICATION_ROUTING` entries land. See MODULE.md §9. |
69
+ | CELERY_BEAT_SCHEDULE | the host | must contain an entry whose `task` is `stapel_forms.tasks.purge_expired_submissions`, or retention never runs (`stapel_forms.W002`). |
70
+ | DATA_PLANE_APPS | the host | isolation-tier hosts list `forms`. No schema impact. |
71
+
72
+ ## Per-form settings (not deployment config)
73
+
74
+ `Form.settings` is a JSON blob on the row, edited over the API, not a Django
75
+ setting. Recognized keys:
76
+
77
+ | Key | Purpose |
78
+ |-----|---------|
79
+ | notify_emails | `[str]` — email destinations for the auto-notify and resend. |
80
+ | notify_telegram_chat_ids | `[str]` — telegram chat destinations (no account behind them; stapel-core 0.31 `telegram_chat_id`). |
81
+ | retention_days | `int` — shortens this form's retention below `RETENTION_DAYS`. Lengthening past the module ceiling is refused with `error.400.forms_invalid_retention`. |
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stapel contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-forms
3
+ Version: 0.2.0
4
+ Summary: Admin-defined forms, anonymous submissions and response review for the Stapel framework
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/usestapel/stapel-forms
7
+ Project-URL: Repository, https://github.com/usestapel/stapel-forms
8
+ Project-URL: Documentation, https://github.com/usestapel/stapel-forms#readme
9
+ Project-URL: Changelog, https://github.com/usestapel/stapel-forms/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/usestapel/stapel-forms/issues
11
+ Keywords: django,stapel,forms,surveys
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Django
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: stapel-core<1.0,>=0.31.0
26
+ Requires-Dist: stapel-attributes<1.0,>=0.4.6
27
+ Provides-Extra: all
28
+ Dynamic: license-file
29
+
30
+ <!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
31
+
32
+ # stapel-forms
33
+
34
+ [![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-forms/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-forms/actions/workflows/ci.yml?query=branch%3Amain)
35
+ [![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-forms?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-forms)
36
+ [![pypi](https://img.shields.io/pypi/v/stapel-forms?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-forms/)
37
+ [![downloads](https://static.pepy.tech/badge/stapel-forms/month)](https://pepy.tech/project/stapel-forms)
38
+ [![python](https://img.shields.io/pypi/pyversions/stapel-forms?logo=python&logoColor=white)](https://pypi.org/project/stapel-forms/)
39
+ [![license](https://img.shields.io/github/license/usestapel/stapel-forms)](https://github.com/usestapel/stapel-forms/blob/main/LICENSE)
40
+ [![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-forms/blob/main/docs/llms.txt)
41
+
42
+ > 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.
43
+
44
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install stapel-forms
50
+ ```
51
+
52
+ ## At a glance
53
+
54
+ | Fact | Value |
55
+ |---|---|
56
+ | Version | `0.2.0` |
57
+ | Python | `>=3.11` (3.11, 3.12, 3.13) |
58
+ | HTTP operations | 18 |
59
+ | Config axes | 5 |
60
+ | Usage surface | 31 |
61
+ | Extension points | 4 |
62
+ | Error codes | 75 |
63
+ | Fleet dependencies | [`stapel-attributes`](https://github.com/usestapel/stapel-attributes) · [`stapel-core`](https://github.com/usestapel/stapel-core) · [`stapel-gdpr`](https://github.com/usestapel/stapel-gdpr) (optional) · [`stapel-notifications`](https://github.com/usestapel/stapel-notifications) (optional) · [`stapel-workspaces`](https://github.com/usestapel/stapel-workspaces) (optional) |
64
+
65
+ ## Documentation
66
+
67
+ [OpenAPI](https://github.com/usestapel/stapel-forms/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-forms/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-forms/blob/main/docs/llms.txt)
68
+
69
+ ## What this is
70
+
71
+ Admin-defined **forms** with **anonymous responses**. A workspace admin
72
+ defines a form's schema, the form gets a non-enumerable public handle, anyone
73
+ holding that handle can fetch the schema and answer it without an account, and
74
+ the admin reviews the answers.
75
+
76
+ Two decisions carry the whole design.
77
+
78
+ **The schema is a list of [stapel-attributes](https://github.com/usestapel/stapel-attributes)
79
+ `FeatureDef`s.** There is no `FormField` class here, no field-type enum, no
80
+ validation of its own: the fleet already has exactly one field-type vocabulary
81
+ — the same one that types a marketplace listing — and forms is its third
82
+ consumer. A host that registers a custom attribute kind gets it in forms for
83
+ free.
84
+
85
+ **A published version is immutable, and every response records which version
86
+ it answered.** Editing a live form publishes the next version; it never
87
+ rewrites a published one. A response is only interpretable against the schema
88
+ it answered — delete a field or narrow a choice list under a mutable schema
89
+ and `budget=3` becomes an orphan nobody can read. Snapshotting the schema into
90
+ every response would preserve meaning but destroy identity: "the 240 responses
91
+ to version 3" would become a JSON scan, and an export would have no stable
92
+ column set. A version row buys both, at one row per publish instead of one
93
+ snapshot per response.
94
+
95
+ ## Quick start
96
+
97
+ ```bash
98
+ pip install stapel-forms
99
+ ```
100
+
101
+ ```python
102
+ INSTALLED_APPS = [
103
+ # ...
104
+ "stapel_forms",
105
+ ]
106
+
107
+ # urls.py
108
+ path("forms/", include("stapel_forms.urls")) # -> /forms/api/v1/...
109
+ ```
110
+
111
+ The admin surface asks the `workspaces.check_capability` comm Function
112
+ (fail-closed, deny-by-default) — install stapel-workspaces or provide that
113
+ Function, and grant `forms.*` to the roles that should have it. The two
114
+ respondent endpoints need none of that.
115
+
116
+ ## The public surface is exactly two routes
117
+
118
+ ```
119
+ GET /forms/api/v1/public/<public_id>/ -> the active schema
120
+ POST /forms/api/v1/public/<public_id>/submissions/ -> 201 {accepted, confirmation}
121
+ ```
122
+
123
+ `public_id` is a 22-character random token, not the row's UUID: rotating it
124
+ after a leak must not re-key the row, and the primary key never travels to an
125
+ anonymous caller. What the GET returns is a dedicated envelope — no workspace
126
+ id, no internal id, no author, no counts, no sibling forms — built by its own
127
+ presenter rather than the admin one with fields removed, because dropping
128
+ fields is how leaks happen the day somebody adds one.
129
+
130
+ Unknown handle, soft-deleted form and unpublished draft all answer **one
131
+ byte-identical 404**. A closed form answers **410**, because its handle was
132
+ public by definition and the renderer needs the difference between "this form
133
+ is closed" and "broken link".
134
+
135
+ ## What a hostile submitter can and cannot do
136
+
137
+ Stated, because a security posture nobody wrote down is a security posture
138
+ nobody can check.
139
+
140
+ **Can**: burn its own IP budget; fill a form with garbage that passes typed
141
+ validation; consume rows up to the form's cap.
142
+
143
+ **Cannot**: enumerate forms (uniform 404 behind a throttled GET); learn the
144
+ workspace or any other tenant fact; store bytes outside typed answer values
145
+ (no file fields, and nothing reaches disk except what the version's own
146
+ configs produced through `normalize_to_dao`); make the module send mail at
147
+ attack rate (the notify cooldown is keyed on the form, and folds the interim
148
+ count into the next letter); read anything back (there is no public read of
149
+ responses — the POST returns confirmation text, not even a submission id); or
150
+ poison a reviewer's spreadsheet (CSV export escapes formula leads server-side,
151
+ so every consumer inherits the guard).
152
+
153
+ The ladder behind that: module-namespaced throttles, a `Content-Length` gate
154
+ before the JSON parse, netintel-tiered captcha (`@captcha_protected`), a
155
+ per-form submission cap, a per-workspace open-form cap, and the recipient-side
156
+ cooldown. Every one ships on; the confession switch is
157
+ `ALLOW_UNCAPTCHAED_PUBLIC`.
158
+
159
+ ## Privacy, said plainly
160
+
161
+ Form answers are **respondent PII**. This module guarantees typed storage,
162
+ finite retention, erasure for authenticated respondents, and admin deletion.
163
+
164
+ It does **not** give anonymous respondents a self-service erasure channel.
165
+ stapel-gdpr keys every subject on `user_id`, and an email a stranger typed
166
+ into a field is opaque answer content, not a subject key — an email-keyed
167
+ subject is upstream platform work, not something a form module should fake.
168
+ What v1 answers with instead is a finite default retention (365 days, per-form
169
+ overrides may only shorten it), a purge job that actually runs, and
170
+ `DELETE /submissions/<id>` for the request that arrives by email.
171
+
172
+ Hosts collecting sensitive categories should say so in the form's own
173
+ description.
174
+
175
+ ## Reacting to a response
176
+
177
+ ```python
178
+ # The module's own reaction: tell the form's destinations, cooldown-gated.
179
+ # Each key maps to the request_notification keyword that addresses it, so a
180
+ # telegram chat needs no account behind it.
181
+ form.settings = {
182
+ "notify_emails": ["sales@example.com"],
183
+ "notify_telegram_chat_ids": ["-1001234567890"],
184
+ }
185
+
186
+ # Anything else subscribes to the fact:
187
+ @on_action("form.submission.received")
188
+ def route_it(event):
189
+ ... # {form_id, form_version, submission_id, workspace_id}
190
+ ```
191
+
192
+ Events carry **ids only**. The outbox has no retention and a durable bus fans
193
+ out to every subscriber, so a respondent's answers never ride it; a consumer
194
+ that needs content reads it under `forms.responses.view`.
195
+
196
+ ## Not in v1, deliberately
197
+
198
+ File-upload fields (the platform CDN cannot take custody of an anonymous
199
+ stranger's bytes and cannot gate reads — a résumé served world-readable by URL
200
+ is the open switch, not the feature); conditional logic and branching (the
201
+ schema reserves `meta.logic`); multi-language form content; a realtime
202
+ response feed; honeypot fields; quotas or billing on submission volume; draft
203
+ respondent saves, response editing and quiz scoring.
204
+
205
+ ## License
206
+
207
+ MIT — see [LICENSE](https://github.com/usestapel/stapel-forms/blob/main/LICENSE).
208
+
209
+ ---
210
+
211
+ <sub>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`.</sub>
@@ -0,0 +1,182 @@
1
+ <!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
2
+
3
+ # stapel-forms
4
+
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/usestapel/stapel-forms/ci.yml?branch=main&logo=github&label=CI)](https://github.com/usestapel/stapel-forms/actions/workflows/ci.yml?query=branch%3Amain)
6
+ [![coverage](https://img.shields.io/codecov/c/github/usestapel/stapel-forms?branch=main&logo=codecov&label=coverage)](https://app.codecov.io/gh/usestapel/stapel-forms)
7
+ [![pypi](https://img.shields.io/pypi/v/stapel-forms?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/stapel-forms/)
8
+ [![downloads](https://static.pepy.tech/badge/stapel-forms/month)](https://pepy.tech/project/stapel-forms)
9
+ [![python](https://img.shields.io/pypi/pyversions/stapel-forms?logo=python&logoColor=white)](https://pypi.org/project/stapel-forms/)
10
+ [![license](https://img.shields.io/github/license/usestapel/stapel-forms)](https://github.com/usestapel/stapel-forms/blob/main/LICENSE)
11
+ [![llms.txt](https://img.shields.io/badge/llms.txt-blue)](https://github.com/usestapel/stapel-forms/blob/main/docs/llms.txt)
12
+
13
+ > 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.
14
+
15
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install stapel-forms
21
+ ```
22
+
23
+ ## At a glance
24
+
25
+ | Fact | Value |
26
+ |---|---|
27
+ | Version | `0.2.0` |
28
+ | Python | `>=3.11` (3.11, 3.12, 3.13) |
29
+ | HTTP operations | 18 |
30
+ | Config axes | 5 |
31
+ | Usage surface | 31 |
32
+ | Extension points | 4 |
33
+ | Error codes | 75 |
34
+ | Fleet dependencies | [`stapel-attributes`](https://github.com/usestapel/stapel-attributes) · [`stapel-core`](https://github.com/usestapel/stapel-core) · [`stapel-gdpr`](https://github.com/usestapel/stapel-gdpr) (optional) · [`stapel-notifications`](https://github.com/usestapel/stapel-notifications) (optional) · [`stapel-workspaces`](https://github.com/usestapel/stapel-workspaces) (optional) |
35
+
36
+ ## Documentation
37
+
38
+ [OpenAPI](https://github.com/usestapel/stapel-forms/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-forms/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-forms/blob/main/docs/llms.txt)
39
+
40
+ ## What this is
41
+
42
+ Admin-defined **forms** with **anonymous responses**. A workspace admin
43
+ defines a form's schema, the form gets a non-enumerable public handle, anyone
44
+ holding that handle can fetch the schema and answer it without an account, and
45
+ the admin reviews the answers.
46
+
47
+ Two decisions carry the whole design.
48
+
49
+ **The schema is a list of [stapel-attributes](https://github.com/usestapel/stapel-attributes)
50
+ `FeatureDef`s.** There is no `FormField` class here, no field-type enum, no
51
+ validation of its own: the fleet already has exactly one field-type vocabulary
52
+ — the same one that types a marketplace listing — and forms is its third
53
+ consumer. A host that registers a custom attribute kind gets it in forms for
54
+ free.
55
+
56
+ **A published version is immutable, and every response records which version
57
+ it answered.** Editing a live form publishes the next version; it never
58
+ rewrites a published one. A response is only interpretable against the schema
59
+ it answered — delete a field or narrow a choice list under a mutable schema
60
+ and `budget=3` becomes an orphan nobody can read. Snapshotting the schema into
61
+ every response would preserve meaning but destroy identity: "the 240 responses
62
+ to version 3" would become a JSON scan, and an export would have no stable
63
+ column set. A version row buys both, at one row per publish instead of one
64
+ snapshot per response.
65
+
66
+ ## Quick start
67
+
68
+ ```bash
69
+ pip install stapel-forms
70
+ ```
71
+
72
+ ```python
73
+ INSTALLED_APPS = [
74
+ # ...
75
+ "stapel_forms",
76
+ ]
77
+
78
+ # urls.py
79
+ path("forms/", include("stapel_forms.urls")) # -> /forms/api/v1/...
80
+ ```
81
+
82
+ The admin surface asks the `workspaces.check_capability` comm Function
83
+ (fail-closed, deny-by-default) — install stapel-workspaces or provide that
84
+ Function, and grant `forms.*` to the roles that should have it. The two
85
+ respondent endpoints need none of that.
86
+
87
+ ## The public surface is exactly two routes
88
+
89
+ ```
90
+ GET /forms/api/v1/public/<public_id>/ -> the active schema
91
+ POST /forms/api/v1/public/<public_id>/submissions/ -> 201 {accepted, confirmation}
92
+ ```
93
+
94
+ `public_id` is a 22-character random token, not the row's UUID: rotating it
95
+ after a leak must not re-key the row, and the primary key never travels to an
96
+ anonymous caller. What the GET returns is a dedicated envelope — no workspace
97
+ id, no internal id, no author, no counts, no sibling forms — built by its own
98
+ presenter rather than the admin one with fields removed, because dropping
99
+ fields is how leaks happen the day somebody adds one.
100
+
101
+ Unknown handle, soft-deleted form and unpublished draft all answer **one
102
+ byte-identical 404**. A closed form answers **410**, because its handle was
103
+ public by definition and the renderer needs the difference between "this form
104
+ is closed" and "broken link".
105
+
106
+ ## What a hostile submitter can and cannot do
107
+
108
+ Stated, because a security posture nobody wrote down is a security posture
109
+ nobody can check.
110
+
111
+ **Can**: burn its own IP budget; fill a form with garbage that passes typed
112
+ validation; consume rows up to the form's cap.
113
+
114
+ **Cannot**: enumerate forms (uniform 404 behind a throttled GET); learn the
115
+ workspace or any other tenant fact; store bytes outside typed answer values
116
+ (no file fields, and nothing reaches disk except what the version's own
117
+ configs produced through `normalize_to_dao`); make the module send mail at
118
+ attack rate (the notify cooldown is keyed on the form, and folds the interim
119
+ count into the next letter); read anything back (there is no public read of
120
+ responses — the POST returns confirmation text, not even a submission id); or
121
+ poison a reviewer's spreadsheet (CSV export escapes formula leads server-side,
122
+ so every consumer inherits the guard).
123
+
124
+ The ladder behind that: module-namespaced throttles, a `Content-Length` gate
125
+ before the JSON parse, netintel-tiered captcha (`@captcha_protected`), a
126
+ per-form submission cap, a per-workspace open-form cap, and the recipient-side
127
+ cooldown. Every one ships on; the confession switch is
128
+ `ALLOW_UNCAPTCHAED_PUBLIC`.
129
+
130
+ ## Privacy, said plainly
131
+
132
+ Form answers are **respondent PII**. This module guarantees typed storage,
133
+ finite retention, erasure for authenticated respondents, and admin deletion.
134
+
135
+ It does **not** give anonymous respondents a self-service erasure channel.
136
+ stapel-gdpr keys every subject on `user_id`, and an email a stranger typed
137
+ into a field is opaque answer content, not a subject key — an email-keyed
138
+ subject is upstream platform work, not something a form module should fake.
139
+ What v1 answers with instead is a finite default retention (365 days, per-form
140
+ overrides may only shorten it), a purge job that actually runs, and
141
+ `DELETE /submissions/<id>` for the request that arrives by email.
142
+
143
+ Hosts collecting sensitive categories should say so in the form's own
144
+ description.
145
+
146
+ ## Reacting to a response
147
+
148
+ ```python
149
+ # The module's own reaction: tell the form's destinations, cooldown-gated.
150
+ # Each key maps to the request_notification keyword that addresses it, so a
151
+ # telegram chat needs no account behind it.
152
+ form.settings = {
153
+ "notify_emails": ["sales@example.com"],
154
+ "notify_telegram_chat_ids": ["-1001234567890"],
155
+ }
156
+
157
+ # Anything else subscribes to the fact:
158
+ @on_action("form.submission.received")
159
+ def route_it(event):
160
+ ... # {form_id, form_version, submission_id, workspace_id}
161
+ ```
162
+
163
+ Events carry **ids only**. The outbox has no retention and a durable bus fans
164
+ out to every subscriber, so a respondent's answers never ride it; a consumer
165
+ that needs content reads it under `forms.responses.view`.
166
+
167
+ ## Not in v1, deliberately
168
+
169
+ File-upload fields (the platform CDN cannot take custody of an anonymous
170
+ stranger's bytes and cannot gate reads — a résumé served world-readable by URL
171
+ is the open switch, not the feature); conditional logic and branching (the
172
+ schema reserves `meta.logic`); multi-language form content; a realtime
173
+ response feed; honeypot fields; quotas or billing on submission volume; draft
174
+ respondent saves, response editing and quiz scoring.
175
+
176
+ ## License
177
+
178
+ MIT — see [LICENSE](https://github.com/usestapel/stapel-forms/blob/main/LICENSE).
179
+
180
+ ---
181
+
182
+ <sub>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`.</sub>
@@ -0,0 +1,31 @@
1
+ """stapel-forms — admin-defined forms, anonymous submissions, response review.
2
+
3
+ Public API (lazily exported, PEP 562 — importing this package never pulls
4
+ in Django or requires configured settings):
5
+
6
+ - ``forms_settings`` — resolved app settings (``stapel_forms.conf``).
7
+ """
8
+
9
+ __all__ = [
10
+ "forms_settings",
11
+ ]
12
+
13
+ # name -> submodule that defines it. Resolution is deferred until first
14
+ # attribute access so that `import stapel_forms` stays Django-free.
15
+ _LAZY_EXPORTS = {
16
+ "forms_settings": ".conf",
17
+ }
18
+
19
+
20
+ def __getattr__(name):
21
+ if name in _LAZY_EXPORTS:
22
+ from importlib import import_module
23
+
24
+ value = getattr(import_module(_LAZY_EXPORTS[name], __name__), name)
25
+ globals()[name] = value # cache for subsequent lookups
26
+ return value
27
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
28
+
29
+
30
+ def __dir__():
31
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,48 @@
1
+ """stapel-forms capabilities.json emitter — thin shim over stapel_tools.capabilities."""
2
+ from pathlib import Path
3
+
4
+ from stapel_tools.capabilities import axis_group_rules, run_capabilities_cli
5
+
6
+
7
+ def main(argv=None):
8
+ from stapel_forms._codegen import _configure
9
+
10
+ _configure()
11
+ from stapel_forms.conf import DEFAULTS
12
+ from stapel_forms.urls_v1 import GATE_REGISTRY
13
+
14
+ # The CTO-facing axes are the ones that change what the PRODUCT is
15
+ # allowed to do, not how fast it runs: which field kinds a form may
16
+ # use, whether respondent IPs are stored, how long answers are kept,
17
+ # and whether public forms may run without a captcha. Throttle rates,
18
+ # page sizes, cooldown seconds and the caps are tuning — they bound
19
+ # abuse, they do not change the deal with the respondent.
20
+ axes = {
21
+ "FIELD_KINDS",
22
+ "STORE_CLIENT_META",
23
+ "RETENTION_DAYS",
24
+ "ALLOW_UNCAPTCHAED_PUBLIC",
25
+ "ACCEPT_PREVIOUS_VERSION_SECONDS",
26
+ }
27
+ return run_capabilities_cli(
28
+ argv,
29
+ repo=Path(__file__).resolve().parent,
30
+ canonical_prefix="/forms/api/v1",
31
+ defaults=DEFAULTS,
32
+ registry=GATE_REGISTRY,
33
+ is_axis=lambda k: k in axes,
34
+ axis_group=axis_group_rules(
35
+ exact={
36
+ "FIELD_KINDS": "forms.schema",
37
+ "STORE_CLIENT_META": "forms.privacy",
38
+ "RETENTION_DAYS": "forms.privacy",
39
+ "ALLOW_UNCAPTCHAED_PUBLIC": "forms.public",
40
+ "ACCEPT_PREVIOUS_VERSION_SECONDS": "forms.public",
41
+ }
42
+ ),
43
+ prog="stapel-forms-capabilities",
44
+ )
45
+
46
+
47
+ if __name__ == "__main__":
48
+ raise SystemExit(main())