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.
- stapel_forms-0.2.0/CONFIG.MD +81 -0
- stapel_forms-0.2.0/LICENSE +21 -0
- stapel_forms-0.2.0/PKG-INFO +211 -0
- stapel_forms-0.2.0/README.md +182 -0
- stapel_forms-0.2.0/__init__.py +31 -0
- stapel_forms-0.2.0/_capabilities.py +48 -0
- stapel_forms-0.2.0/_codegen.py +117 -0
- stapel_forms-0.2.0/_codegen_settings.py +108 -0
- stapel_forms-0.2.0/actions.py +51 -0
- stapel_forms-0.2.0/admin.py +44 -0
- stapel_forms-0.2.0/apps.py +31 -0
- stapel_forms-0.2.0/authz.py +110 -0
- stapel_forms-0.2.0/checks.py +144 -0
- stapel_forms-0.2.0/codegen_urls.py +15 -0
- stapel_forms-0.2.0/conf.py +107 -0
- stapel_forms-0.2.0/conftest.py +141 -0
- stapel_forms-0.2.0/docs/capabilities.json +424 -0
- stapel_forms-0.2.0/docs/errors.json +658 -0
- stapel_forms-0.2.0/docs/flows.json +1 -0
- stapel_forms-0.2.0/docs/llms.txt +237 -0
- stapel_forms-0.2.0/docs/schema.json +1466 -0
- stapel_forms-0.2.0/dto.py +110 -0
- stapel_forms-0.2.0/errors.py +136 -0
- stapel_forms-0.2.0/events.py +59 -0
- stapel_forms-0.2.0/export.py +122 -0
- stapel_forms-0.2.0/functions.py +13 -0
- stapel_forms-0.2.0/gdpr.py +82 -0
- stapel_forms-0.2.0/management/__init__.py +0 -0
- stapel_forms-0.2.0/management/commands/__init__.py +0 -0
- stapel_forms-0.2.0/management/commands/forms_purge_expired.py +22 -0
- stapel_forms-0.2.0/migrations/0001_initial.py +88 -0
- stapel_forms-0.2.0/migrations/__init__.py +0 -0
- stapel_forms-0.2.0/models.py +157 -0
- stapel_forms-0.2.0/notifications.py +178 -0
- stapel_forms-0.2.0/presenters.py +317 -0
- stapel_forms-0.2.0/py.typed +0 -0
- stapel_forms-0.2.0/pyproject.toml +105 -0
- stapel_forms-0.2.0/schema.py +254 -0
- stapel_forms-0.2.0/schemas/emits/form.closed.json +12 -0
- stapel_forms-0.2.0/schemas/emits/form.published.json +13 -0
- stapel_forms-0.2.0/schemas/emits/form.submission.received.json +14 -0
- stapel_forms-0.2.0/serializers.py +153 -0
- stapel_forms-0.2.0/services.py +463 -0
- stapel_forms-0.2.0/setup.cfg +4 -0
- stapel_forms-0.2.0/stapel_forms.egg-info/PKG-INFO +211 -0
- stapel_forms-0.2.0/stapel_forms.egg-info/SOURCES.txt +111 -0
- stapel_forms-0.2.0/stapel_forms.egg-info/dependency_links.txt +1 -0
- stapel_forms-0.2.0/stapel_forms.egg-info/requires.txt +4 -0
- stapel_forms-0.2.0/stapel_forms.egg-info/top_level.txt +1 -0
- stapel_forms-0.2.0/tasks.py +78 -0
- stapel_forms-0.2.0/tests/test_admin_api.py +242 -0
- stapel_forms-0.2.0/tests/test_checks.py +76 -0
- stapel_forms-0.2.0/tests/test_comm.py +117 -0
- stapel_forms-0.2.0/tests/test_contract.py +124 -0
- stapel_forms-0.2.0/tests/test_export.py +118 -0
- stapel_forms-0.2.0/tests/test_field_kinds.py +210 -0
- stapel_forms-0.2.0/tests/test_lifecycle.py +126 -0
- stapel_forms-0.2.0/tests/test_notifications.py +147 -0
- stapel_forms-0.2.0/tests/test_public_api.py +211 -0
- stapel_forms-0.2.0/tests/test_retention.py +137 -0
- stapel_forms-0.2.0/tests/test_schema.py +181 -0
- stapel_forms-0.2.0/translations/.state.json +174 -0
- stapel_forms-0.2.0/translations/errors.es.json +23 -0
- stapel_forms-0.2.0/translations/errors.ru.json +23 -0
- stapel_forms-0.2.0/urls.py +18 -0
- stapel_forms-0.2.0/urls_v1.py +93 -0
- 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
|
+
[](https://github.com/usestapel/stapel-forms/actions/workflows/ci.yml?query=branch%3Amain)
|
|
35
|
+
[](https://app.codecov.io/gh/usestapel/stapel-forms)
|
|
36
|
+
[](https://pypi.org/project/stapel-forms/)
|
|
37
|
+
[](https://pepy.tech/project/stapel-forms)
|
|
38
|
+
[](https://pypi.org/project/stapel-forms/)
|
|
39
|
+
[](https://github.com/usestapel/stapel-forms/blob/main/LICENSE)
|
|
40
|
+
[](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
|
+
[](https://github.com/usestapel/stapel-forms/actions/workflows/ci.yml?query=branch%3Amain)
|
|
6
|
+
[](https://app.codecov.io/gh/usestapel/stapel-forms)
|
|
7
|
+
[](https://pypi.org/project/stapel-forms/)
|
|
8
|
+
[](https://pepy.tech/project/stapel-forms)
|
|
9
|
+
[](https://pypi.org/project/stapel-forms/)
|
|
10
|
+
[](https://github.com/usestapel/stapel-forms/blob/main/LICENSE)
|
|
11
|
+
[](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())
|