stapel-forms 0.6.3__py3-none-any.whl
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/CONFIG.MD +81 -0
- stapel_forms/__init__.py +31 -0
- stapel_forms/_capabilities.py +216 -0
- stapel_forms/_codegen.py +117 -0
- stapel_forms/_codegen_settings.py +165 -0
- stapel_forms/actions.py +179 -0
- stapel_forms/admin.py +440 -0
- stapel_forms/apps.py +31 -0
- stapel_forms/authz.py +137 -0
- stapel_forms/checks.py +188 -0
- stapel_forms/codegen_urls.py +15 -0
- stapel_forms/conf.py +131 -0
- stapel_forms/conftest.py +141 -0
- stapel_forms/docs/capabilities.json +536 -0
- stapel_forms/docs/errors.json +668 -0
- stapel_forms/docs/flows.json +1 -0
- stapel_forms/docs/llms.txt +253 -0
- stapel_forms/docs/schema.json +1514 -0
- stapel_forms/dto.py +110 -0
- stapel_forms/errors.py +148 -0
- stapel_forms/events.py +59 -0
- stapel_forms/export.py +151 -0
- stapel_forms/functions.py +13 -0
- stapel_forms/gdpr.py +82 -0
- stapel_forms/management/__init__.py +0 -0
- stapel_forms/management/commands/__init__.py +0 -0
- stapel_forms/management/commands/forms_purge_expired.py +22 -0
- stapel_forms/migrations/0001_initial.py +88 -0
- stapel_forms/migrations/__init__.py +0 -0
- stapel_forms/models.py +157 -0
- stapel_forms/notifications.py +231 -0
- stapel_forms/presenters.py +469 -0
- stapel_forms/py.typed +0 -0
- stapel_forms/schema.py +393 -0
- stapel_forms/schemas/emits/form.closed.json +12 -0
- stapel_forms/schemas/emits/form.published.json +13 -0
- stapel_forms/schemas/emits/form.submission.received.json +14 -0
- stapel_forms/serializers.py +163 -0
- stapel_forms/services.py +520 -0
- stapel_forms/static/stapel_forms/forms-builder.js +320 -0
- stapel_forms/tasks.py +78 -0
- stapel_forms/templates/admin/forms/change_form.html +32 -0
- stapel_forms/templates/admin/forms/responses.html +125 -0
- stapel_forms/templates/admin/forms/submission_answers.html +24 -0
- stapel_forms/translations/.state.json +174 -0
- stapel_forms/translations/errors.es.json +23 -0
- stapel_forms/translations/errors.ru.json +23 -0
- stapel_forms/urls.py +18 -0
- stapel_forms/urls_v1.py +93 -0
- stapel_forms/views.py +843 -0
- stapel_forms-0.6.3.dist-info/METADATA +211 -0
- stapel_forms-0.6.3.dist-info/RECORD +55 -0
- stapel_forms-0.6.3.dist-info/WHEEL +5 -0
- stapel_forms-0.6.3.dist-info/licenses/LICENSE +21 -0
- stapel_forms-0.6.3.dist-info/top_level.txt +1 -0
stapel_forms/CONFIG.MD
ADDED
|
@@ -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. The four grantable strings, what each one means and exactly which operations it opens are published in `docs/capabilities.json` under `capabilities[]` (and per operation as `x-stapel-capability` in `docs/schema.json`), derived from the gate that enforces them — see MODULE.md §4. Do not transcribe them into a host-side constant: read `stapel_forms.authz.capability_for(action)` or the artifact. |
|
|
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`. |
|
stapel_forms/__init__.py
ADDED
|
@@ -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,216 @@
|
|
|
1
|
+
"""stapel-forms capabilities.json emitter — thin shim over stapel_tools.capabilities.
|
|
2
|
+
|
|
3
|
+
Two different things wear the word "capability" in this repo and the file
|
|
4
|
+
this module writes carries both, so the distinction is worth stating once:
|
|
5
|
+
|
|
6
|
+
* an **axis** is a configuration switch that changes what the PRODUCT may
|
|
7
|
+
do (may a form ask for a colour, are respondent IPs kept). The generic
|
|
8
|
+
mechanism in ``stapel_tools.capabilities`` derives those from
|
|
9
|
+
``conf.DEFAULTS``;
|
|
10
|
+
* a **workspace capability** is a permission string — ``forms.view``,
|
|
11
|
+
``forms.responses.manage`` — that says who may drive an endpoint. Those
|
|
12
|
+
are what a host grants in ``STAPEL_WORKSPACES["ROLES"]`` and what a UI
|
|
13
|
+
gates a control on.
|
|
14
|
+
|
|
15
|
+
Until 0.3.0 this artifact projected only the first kind, so
|
|
16
|
+
``forms.responses.manage`` existed in ``authz.py``, was enforced on two
|
|
17
|
+
endpoints, and appeared in NO machine-readable artifact this module ships.
|
|
18
|
+
A frontend could therefore not tell whether the responses surface should be
|
|
19
|
+
offered at all, and its only options were to show it to everyone or to hide
|
|
20
|
+
it from everyone. The ``capabilities`` section below closes that.
|
|
21
|
+
|
|
22
|
+
Why it is derived from ``docs/schema.json`` rather than restated here: the
|
|
23
|
+
capability lands in the schema as ``x-stapel-capability``, written by
|
|
24
|
+
``views.CapabilityAwareAutoSchema`` off the very attribute
|
|
25
|
+
``views.gated(...)`` stamps on the handler and ``views._access_error``
|
|
26
|
+
reads to authorize the request. One decorator, one attribute, and both the
|
|
27
|
+
projection and the enforcement hang off it — a capability cannot be
|
|
28
|
+
published for an operation that will not actually be gated on it, because
|
|
29
|
+
there is no second place to type the string.
|
|
30
|
+
"""
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
# The stable-JSON pinning of the contract pipeline. Imported rather than
|
|
34
|
+
# re-implemented: a second formatter here would make `make contract-check`
|
|
35
|
+
# fail on whitespace the day stapel-tools changes its pinning.
|
|
36
|
+
from stapel_tools.capabilities import (
|
|
37
|
+
_stable_json,
|
|
38
|
+
axis_group_rules,
|
|
39
|
+
emit_capabilities,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _capability_section(out: Path, meta: dict, enforced: tuple) -> list:
|
|
46
|
+
"""Build the ``capabilities`` section: derived operations, curated prose.
|
|
47
|
+
|
|
48
|
+
Mirrors the shape of an ``axes`` entry on purpose (``key`` +
|
|
49
|
+
``gates.operations`` + ``curated``), so a reader who knows one knows the
|
|
50
|
+
other and a consumer walks both with the same code.
|
|
51
|
+
|
|
52
|
+
Every direction of drift is a loud emission error:
|
|
53
|
+
|
|
54
|
+
* a capability the module enforces with no curated entry, or a curated
|
|
55
|
+
entry for a capability nothing enforces — the same missing/extra check
|
|
56
|
+
the axes already get;
|
|
57
|
+
* a capability that ``authz`` enforces but no operation projects. That
|
|
58
|
+
one is the real prize: it means a permission string is grantable and
|
|
59
|
+
documented while no endpoint asks for it, which is precisely the
|
|
60
|
+
"answers yes, endpoint answers 403" shape inverted.
|
|
61
|
+
"""
|
|
62
|
+
import json
|
|
63
|
+
|
|
64
|
+
schema = json.loads((out / "schema.json").read_text())
|
|
65
|
+
|
|
66
|
+
operations: dict[str, list[str]] = {cap: [] for cap in enforced}
|
|
67
|
+
for path_item in schema.get("paths", {}).values():
|
|
68
|
+
for method, op in path_item.items():
|
|
69
|
+
if method not in _HTTP_METHODS:
|
|
70
|
+
continue
|
|
71
|
+
capability = op.get("x-stapel-capability")
|
|
72
|
+
if capability is None:
|
|
73
|
+
continue
|
|
74
|
+
if capability not in operations:
|
|
75
|
+
raise SystemExit(
|
|
76
|
+
f"capabilities: schema.json projects {capability!r} on "
|
|
77
|
+
f"{op.get('operationId')!r}, but stapel_forms.authz does not "
|
|
78
|
+
"enforce it — the contract and the gate disagree."
|
|
79
|
+
)
|
|
80
|
+
operations[capability].append(op["operationId"])
|
|
81
|
+
|
|
82
|
+
ungated = sorted(cap for cap, ops in operations.items() if not ops)
|
|
83
|
+
if ungated:
|
|
84
|
+
raise SystemExit(
|
|
85
|
+
f"capabilities: {ungated} are enforced by stapel_forms.authz but no "
|
|
86
|
+
"operation in schema.json projects them — either an admin handler "
|
|
87
|
+
"lost its @gated(...) declaration, or the action is dead and must "
|
|
88
|
+
"leave ACTION_CAPABILITIES."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
curated = meta.get("capabilities", {})
|
|
92
|
+
missing = [c for c in enforced if c not in curated]
|
|
93
|
+
extra = [c for c in curated if c not in operations]
|
|
94
|
+
if missing or extra:
|
|
95
|
+
raise SystemExit(
|
|
96
|
+
"capabilities: curated meta out of sync with the enforced capability "
|
|
97
|
+
f"set — missing from capabilities.meta.json: {missing or 'none'}; "
|
|
98
|
+
f"stale in capabilities.meta.json: {extra or 'none'}."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
section = []
|
|
102
|
+
for capability in enforced:
|
|
103
|
+
entry = curated[capability]
|
|
104
|
+
for field in ("summary", "business_label"):
|
|
105
|
+
if not entry.get(field):
|
|
106
|
+
raise SystemExit(
|
|
107
|
+
f"capabilities: capability {capability!r} lacks a non-empty "
|
|
108
|
+
f"{field!r} in docs/capabilities.meta.json"
|
|
109
|
+
)
|
|
110
|
+
gates = {"operations": sorted(operations[capability])}
|
|
111
|
+
# Same optional `behavior` field an axis entry carries: what a
|
|
112
|
+
# consumer must know about the gate beyond which routes it opens.
|
|
113
|
+
# Here it is the one thing this capability CANNOT tell a client —
|
|
114
|
+
# see the note in docs/capabilities.meta.json.
|
|
115
|
+
if entry.get("behavior"):
|
|
116
|
+
gates["behavior"] = entry["behavior"]
|
|
117
|
+
section.append(
|
|
118
|
+
{
|
|
119
|
+
"key": capability,
|
|
120
|
+
"gates": gates,
|
|
121
|
+
"curated": {
|
|
122
|
+
"summary": entry["summary"],
|
|
123
|
+
"business_label": entry["business_label"],
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
return section
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv=None):
|
|
131
|
+
import argparse
|
|
132
|
+
import json
|
|
133
|
+
import sys
|
|
134
|
+
|
|
135
|
+
parser = argparse.ArgumentParser(
|
|
136
|
+
prog="stapel-forms-capabilities",
|
|
137
|
+
description="Emit docs/capabilities.json (fourth contract artifact): "
|
|
138
|
+
"config axes from conf.py DEFAULTS + the urls.py gate registry, and the "
|
|
139
|
+
"workspace capability projection derived from schema.json.",
|
|
140
|
+
)
|
|
141
|
+
parser.add_argument(
|
|
142
|
+
"--out",
|
|
143
|
+
default="docs",
|
|
144
|
+
help="Output directory; must already contain schema.json (default: docs).",
|
|
145
|
+
)
|
|
146
|
+
args = parser.parse_args(argv)
|
|
147
|
+
|
|
148
|
+
from stapel_forms._codegen import _configure
|
|
149
|
+
|
|
150
|
+
_configure()
|
|
151
|
+
from stapel_forms.authz import CAPABILITIES
|
|
152
|
+
from stapel_forms.conf import DEFAULTS
|
|
153
|
+
from stapel_forms.urls_v1 import GATE_REGISTRY
|
|
154
|
+
|
|
155
|
+
repo = Path(__file__).resolve().parent
|
|
156
|
+
out = Path(args.out)
|
|
157
|
+
|
|
158
|
+
# The CTO-facing axes are the ones that change what the PRODUCT is
|
|
159
|
+
# allowed to do, not how fast it runs: which field kinds a form may
|
|
160
|
+
# use, whether respondent IPs are stored, how long answers are kept,
|
|
161
|
+
# and whether public forms may run without a captcha. Throttle rates,
|
|
162
|
+
# page sizes, cooldown seconds and the caps are tuning — they bound
|
|
163
|
+
# abuse, they do not change the deal with the respondent.
|
|
164
|
+
axes = {
|
|
165
|
+
"FIELD_KINDS",
|
|
166
|
+
"STORE_CLIENT_META",
|
|
167
|
+
"RETENTION_DAYS",
|
|
168
|
+
"ALLOW_UNCAPTCHAED_PUBLIC",
|
|
169
|
+
"ACCEPT_PREVIOUS_VERSION_SECONDS",
|
|
170
|
+
}
|
|
171
|
+
doc = emit_capabilities(
|
|
172
|
+
out,
|
|
173
|
+
repo=repo,
|
|
174
|
+
canonical_prefix="/forms/api/v1",
|
|
175
|
+
defaults=DEFAULTS,
|
|
176
|
+
registry=GATE_REGISTRY,
|
|
177
|
+
is_axis=lambda k: k in axes,
|
|
178
|
+
axis_group=axis_group_rules(
|
|
179
|
+
exact={
|
|
180
|
+
"FIELD_KINDS": "forms.schema",
|
|
181
|
+
"STORE_CLIENT_META": "forms.privacy",
|
|
182
|
+
"RETENTION_DAYS": "forms.privacy",
|
|
183
|
+
"ALLOW_UNCAPTCHAED_PUBLIC": "forms.public",
|
|
184
|
+
"ACCEPT_PREVIOUS_VERSION_SECONDS": "forms.public",
|
|
185
|
+
}
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
meta = json.loads((repo / "docs" / "capabilities.meta.json").read_text())
|
|
190
|
+
section = _capability_section(out, meta, CAPABILITIES)
|
|
191
|
+
|
|
192
|
+
# Rebuild in a fixed key order — `capabilities` sits next to `axes`
|
|
193
|
+
# because the two answer the paired questions "what may this deployment
|
|
194
|
+
# do" and "who in it may do it".
|
|
195
|
+
ordered = {}
|
|
196
|
+
for key, value in doc.items():
|
|
197
|
+
ordered[key] = value
|
|
198
|
+
if key == "axes":
|
|
199
|
+
ordered["capabilities"] = section
|
|
200
|
+
(out / "capabilities.json").write_text(_stable_json(ordered))
|
|
201
|
+
|
|
202
|
+
gated = sum(1 for a in ordered["axes"] if a["gates"]["operations"])
|
|
203
|
+
print(
|
|
204
|
+
f"{ordered['module']} capabilities: {len(ordered['axes'])} axes ({gated} "
|
|
205
|
+
f"gating operations), {len(section)} workspace capabilities over "
|
|
206
|
+
f"{sum(len(c['gates']['operations']) for c in section)} gated operations, "
|
|
207
|
+
f"{len(ordered.get('surface') or [])} surface entries, "
|
|
208
|
+
f"{ordered['operations_total']} operations total → "
|
|
209
|
+
f"{args.out}/capabilities.json",
|
|
210
|
+
file=sys.stderr,
|
|
211
|
+
)
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
raise SystemExit(main())
|
stapel_forms/_codegen.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""stapel-forms contract-emission harness (contract-pipeline.md §2-3).
|
|
2
|
+
|
|
3
|
+
Emits the module's own contract triad into ``docs/`` from a single-module
|
|
4
|
+
``{forms + core}`` Django instance mounted at the canonical ``/forms/api/v1``
|
|
5
|
+
prefix:
|
|
6
|
+
|
|
7
|
+
docs/schema.json drf-spectacular OpenAPI, this module only, canonical prefix
|
|
8
|
+
docs/flows.json generate_flow_docs machine artifact
|
|
9
|
+
docs/errors.json generate_error_keys registry (the per-module etalon)
|
|
10
|
+
|
|
11
|
+
stapel-forms is not mounted in stapel-example-monolith, so there is no
|
|
12
|
+
aggregate slice to diff against for byte-identity — validation is
|
|
13
|
+
standalone (determinism + closure + canonical prefix), see
|
|
14
|
+
``tests/test_contract.py``.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python -m stapel_forms._codegen --out docs # `make contract`
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _configure() -> None:
|
|
28
|
+
"""Configure + boot the single-module Django instance for emission."""
|
|
29
|
+
# `python -m` prepends cwd to sys.path; strip the repo root the way a
|
|
30
|
+
# flat-layout conftest does, so `import forms`-shaped collisions cannot
|
|
31
|
+
# shadow anything.
|
|
32
|
+
repo_root = os.path.dirname(os.path.abspath(__file__))
|
|
33
|
+
sys.path[:] = [p for p in sys.path if os.path.abspath(p or os.getcwd()) != repo_root]
|
|
34
|
+
|
|
35
|
+
from django.conf import settings
|
|
36
|
+
|
|
37
|
+
if not settings.configured:
|
|
38
|
+
from stapel_forms._codegen_settings import settings_kwargs
|
|
39
|
+
|
|
40
|
+
settings.configure(
|
|
41
|
+
**settings_kwargs(root_urlconf="stapel_forms.codegen_urls", contract=True)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
import django
|
|
45
|
+
|
|
46
|
+
django.setup()
|
|
47
|
+
|
|
48
|
+
# drf-spectacular froze its settings singleton at import time (before
|
|
49
|
+
# configure() ran), so it is on drf defaults. The one knob to force is
|
|
50
|
+
# SCHEMA_PATH_PREFIX: left None, drf derives the operationId prefix
|
|
51
|
+
# from the common path of all endpoints — "/" across a multi-module
|
|
52
|
+
# aggregate but "/forms/api" in a single-module harness, which would
|
|
53
|
+
# strip it to bare anonymous names. Pin it to the aggregate convention.
|
|
54
|
+
from drf_spectacular.settings import spectacular_settings
|
|
55
|
+
|
|
56
|
+
from stapel_forms._codegen_settings import CODEGEN_SCHEMA_PATH_PREFIX
|
|
57
|
+
|
|
58
|
+
spectacular_settings.SCHEMA_PATH_PREFIX = CODEGEN_SCHEMA_PATH_PREFIX
|
|
59
|
+
|
|
60
|
+
# A real all-modules deployment registers drf-spectacular's JWT-cookie
|
|
61
|
+
# security-scheme extension as a side effect of its dev-only Swagger
|
|
62
|
+
# URLs. A single-module harness has no co-mounted sibling to trigger
|
|
63
|
+
# it, so without this the protected endpoints would emit without their
|
|
64
|
+
# `security` entry.
|
|
65
|
+
from stapel_core.django.openapi.swagger import _register_jwt_auth_extension
|
|
66
|
+
|
|
67
|
+
_register_jwt_auth_extension()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _require_python_312() -> None:
|
|
71
|
+
"""Abort emission if not running the pinned 3.12 interpreter.
|
|
72
|
+
|
|
73
|
+
drf-spectacular renders component descriptions (``Optional[X]`` vs
|
|
74
|
+
``X | None``) differently across Python minor versions, so emitting on
|
|
75
|
+
anything but the CI/monolith pin produces false diffs against the
|
|
76
|
+
committed docs/*.json.
|
|
77
|
+
"""
|
|
78
|
+
if sys.version_info[:2] != (3, 12):
|
|
79
|
+
got = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
80
|
+
raise SystemExit(
|
|
81
|
+
f"stapel-forms contract emission ABORTED: running Python {got}, "
|
|
82
|
+
"but contracts must be emitted on Python 3.12 (the CI/monolith pin). "
|
|
83
|
+
"Re-run under a 3.12 interpreter."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
_require_python_312()
|
|
89
|
+
|
|
90
|
+
parser = argparse.ArgumentParser(
|
|
91
|
+
prog="stapel-forms-contract",
|
|
92
|
+
description="Emit this module's contract triad (schema.json + flows.json "
|
|
93
|
+
"+ errors.json) into --out, canonical /forms/api/v1 prefix.",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument("--out", default="docs", help="Output directory (default: docs).")
|
|
96
|
+
args = parser.parse_args(argv)
|
|
97
|
+
|
|
98
|
+
_configure()
|
|
99
|
+
|
|
100
|
+
from stapel_tools.codegen import emit_errors, emit_flows, emit_schema
|
|
101
|
+
|
|
102
|
+
out = Path(args.out)
|
|
103
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
paths = emit_schema(out / "schema.json")
|
|
105
|
+
flows = emit_flows(out / "flows.json")
|
|
106
|
+
errors = emit_errors(out / "errors.json")
|
|
107
|
+
|
|
108
|
+
print(
|
|
109
|
+
f"stapel-forms contract: {paths} paths, {flows} flows, {errors} error keys "
|
|
110
|
+
f"-> {out}/",
|
|
111
|
+
file=sys.stderr,
|
|
112
|
+
)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Single-module Django settings for stapel-forms.
|
|
2
|
+
|
|
3
|
+
One ``settings.configure(...)`` block serves three callers, which is the
|
|
4
|
+
point: the test suite, the contract-emission harness and the capabilities
|
|
5
|
+
emitter cannot drift apart if there is nothing to drift.
|
|
6
|
+
|
|
7
|
+
- ``conftest.py`` — the bare test mount (``stapel_forms.tests.urls``);
|
|
8
|
+
- ``_codegen.py`` / ``make contract`` — the CANONICAL mount
|
|
9
|
+
(``stapel_forms.codegen_urls`` → ``forms/``; the module's own
|
|
10
|
+
``urls.py`` bakes the ``api/v1`` segment in, so the full public prefix
|
|
11
|
+
is ``/forms/api/v1``), plus drf-spectacular and the production
|
|
12
|
+
``REST_FRAMEWORK`` block so the emitted schema matches what a real
|
|
13
|
+
deployment serves;
|
|
14
|
+
- ``_capabilities.py``, which reuses ``_codegen._configure``.
|
|
15
|
+
|
|
16
|
+
``SPECTACULAR_SETTINGS`` is deliberately not set: drf-spectacular builds
|
|
17
|
+
its settings singleton at import time, before a ``configure()``-based
|
|
18
|
+
harness can populate it, so the emitter runs on drf defaults — the state
|
|
19
|
+
every other pair-backend's harness emits under. The one knob that must
|
|
20
|
+
still be forced, ``SCHEMA_PATH_PREFIX``, is patched on the singleton
|
|
21
|
+
directly by the harness.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _attributes_static_dir() -> str:
|
|
27
|
+
"""The static/ directory inside the installed stapel-attributes."""
|
|
28
|
+
import pathlib
|
|
29
|
+
|
|
30
|
+
import stapel_attributes
|
|
31
|
+
|
|
32
|
+
return str(pathlib.Path(stapel_attributes.__file__).resolve().parent / "static")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def settings_kwargs(
|
|
36
|
+
*,
|
|
37
|
+
root_urlconf: str = "stapel_forms.tests.urls",
|
|
38
|
+
contract: bool = False,
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""The ``settings.configure(**kwargs)`` for a single-module forms instance."""
|
|
41
|
+
if contract:
|
|
42
|
+
# Mirror stapel_core.django.settings.REST_FRAMEWORK exactly (the
|
|
43
|
+
# config a real deployment emits under). Inlined, not imported, to
|
|
44
|
+
# dodge the import-time settings read.
|
|
45
|
+
rest_framework = {
|
|
46
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
47
|
+
"stapel_core.django.jwt.authentication.JWTCookieAuthentication",
|
|
48
|
+
],
|
|
49
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
50
|
+
"stapel_core.django.api.permissions.IsServiceRequest",
|
|
51
|
+
"stapel_core.django.api.permissions.IsSuperUser",
|
|
52
|
+
],
|
|
53
|
+
"DEFAULT_RENDERER_CLASSES": [
|
|
54
|
+
"rest_framework.renderers.JSONRenderer",
|
|
55
|
+
"rest_framework.renderers.BrowsableAPIRenderer",
|
|
56
|
+
],
|
|
57
|
+
"DEFAULT_SCHEMA_CLASS": "stapel_core.django.openapi.schemas.PermissionAwareAutoSchema",
|
|
58
|
+
"EXCEPTION_HANDLER": "stapel_core.django.api.errors.stapel_exception_handler",
|
|
59
|
+
}
|
|
60
|
+
else:
|
|
61
|
+
rest_framework = None
|
|
62
|
+
|
|
63
|
+
kwargs = dict(
|
|
64
|
+
SECRET_KEY="test-secret-key-not-for-production",
|
|
65
|
+
INSTALLED_APPS=[
|
|
66
|
+
"django.contrib.contenttypes",
|
|
67
|
+
"django.contrib.auth",
|
|
68
|
+
"django.contrib.sessions",
|
|
69
|
+
"django.contrib.admin",
|
|
70
|
+
"django.contrib.staticfiles",
|
|
71
|
+
"django.contrib.messages",
|
|
72
|
+
"stapel_core.django.apps.CommonDjangoConfig",
|
|
73
|
+
"stapel_core.django.users",
|
|
74
|
+
"rest_framework",
|
|
75
|
+
"drf_spectacular",
|
|
76
|
+
"stapel_forms",
|
|
77
|
+
],
|
|
78
|
+
AUTH_USER_MODEL="users.User",
|
|
79
|
+
DATABASES={
|
|
80
|
+
"default": {
|
|
81
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
82
|
+
"NAME": ":memory:",
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
|
|
86
|
+
USE_TZ=True,
|
|
87
|
+
STATIC_URL="/static/",
|
|
88
|
+
# stapel-attributes is an EMBEDDED library, not a Django app, so
|
|
89
|
+
# `AppDirectoriesFinder` never walks it and its shipped admin bundle
|
|
90
|
+
# — the builder's per-kind config editor — is invisible to
|
|
91
|
+
# staticfiles unless the directory is named. This is the remedy
|
|
92
|
+
# `stapel_forms.W004` tells a host to apply, applied here so the
|
|
93
|
+
# module's own harness is a correctly configured deployment rather
|
|
94
|
+
# than one that quietly demonstrates the defect.
|
|
95
|
+
STATICFILES_DIRS=[_attributes_static_dir()],
|
|
96
|
+
ROOT_URLCONF=root_urlconf,
|
|
97
|
+
# The admin surface (0.6.0) is a tested surface, so the harness has
|
|
98
|
+
# to be able to render it: templates, the session/message/auth
|
|
99
|
+
# middleware chain the admin requires, and the mandate backend that
|
|
100
|
+
# turns `@access.sensitive` into a real refusal rather than a
|
|
101
|
+
# docstring. `MandateBackend` first and `AuditedModelBackend`
|
|
102
|
+
# second mirrors the fleet's production chain — without the second
|
|
103
|
+
# entry `authenticate()` has no backend and even login fails.
|
|
104
|
+
TEMPLATES=[
|
|
105
|
+
{
|
|
106
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
107
|
+
"APP_DIRS": True,
|
|
108
|
+
"OPTIONS": {
|
|
109
|
+
"context_processors": [
|
|
110
|
+
"django.template.context_processors.request",
|
|
111
|
+
"django.contrib.auth.context_processors.auth",
|
|
112
|
+
"django.contrib.messages.context_processors.messages",
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
MIDDLEWARE=[
|
|
118
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
119
|
+
"django.middleware.common.CommonMiddleware",
|
|
120
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
121
|
+
"django.contrib.messages.middleware.MessageMiddleware",
|
|
122
|
+
],
|
|
123
|
+
# `AuditedModelBackend` FIRST because it is the only one of the two
|
|
124
|
+
# that can carry a session: `MandateBackend` is an
|
|
125
|
+
# `AuthorizationOnlyBackend` with no `get_user`, so a `force_login`
|
|
126
|
+
# (or a real login) that binds to it loses the user on the next
|
|
127
|
+
# request and every admin page silently redirects to the login
|
|
128
|
+
# screen — a test suite that would then "pass" by asserting against
|
|
129
|
+
# an empty body. Order does not weaken the mandate: Django ORs
|
|
130
|
+
# `has_perm` across the chain, so clearance still grants.
|
|
131
|
+
AUTHENTICATION_BACKENDS=[
|
|
132
|
+
"stapel_core.access.backend.AuditedModelBackend",
|
|
133
|
+
"stapel_core.access.backend.MandateBackend",
|
|
134
|
+
],
|
|
135
|
+
CACHES={
|
|
136
|
+
"default": {
|
|
137
|
+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
# Synchronous in-process comm with schema validation ON, so the
|
|
141
|
+
# committed contracts in schemas/ are enforced by the tests.
|
|
142
|
+
STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
|
|
143
|
+
STAPEL_COMM={
|
|
144
|
+
"OUTBOX_ENABLED": False,
|
|
145
|
+
"ACTION_TRANSPORT": "inprocess",
|
|
146
|
+
"VALIDATE_SCHEMAS": True,
|
|
147
|
+
# An emit outside a transaction is a bug here, not a warning:
|
|
148
|
+
# the outbox canon is what makes "the row exists" and "the fact
|
|
149
|
+
# was announced" one decision.
|
|
150
|
+
"EMIT_OUTSIDE_ATOMIC": "error",
|
|
151
|
+
},
|
|
152
|
+
MIGRATION_MODULES={
|
|
153
|
+
"users": None,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
if rest_framework is not None:
|
|
157
|
+
kwargs["REST_FRAMEWORK"] = rest_framework
|
|
158
|
+
return kwargs
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# The multi-module common path prefix drf-spectacular auto-detects when
|
|
162
|
+
# every pair-backend's schema is emitted inside an all-modules aggregate.
|
|
163
|
+
# Forced on the singleton by the harness so a single-module instance
|
|
164
|
+
# derives the same operationIds.
|
|
165
|
+
CODEGEN_SCHEMA_PATH_PREFIX = "/"
|