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.
Files changed (55) hide show
  1. stapel_forms/CONFIG.MD +81 -0
  2. stapel_forms/__init__.py +31 -0
  3. stapel_forms/_capabilities.py +216 -0
  4. stapel_forms/_codegen.py +117 -0
  5. stapel_forms/_codegen_settings.py +165 -0
  6. stapel_forms/actions.py +179 -0
  7. stapel_forms/admin.py +440 -0
  8. stapel_forms/apps.py +31 -0
  9. stapel_forms/authz.py +137 -0
  10. stapel_forms/checks.py +188 -0
  11. stapel_forms/codegen_urls.py +15 -0
  12. stapel_forms/conf.py +131 -0
  13. stapel_forms/conftest.py +141 -0
  14. stapel_forms/docs/capabilities.json +536 -0
  15. stapel_forms/docs/errors.json +668 -0
  16. stapel_forms/docs/flows.json +1 -0
  17. stapel_forms/docs/llms.txt +253 -0
  18. stapel_forms/docs/schema.json +1514 -0
  19. stapel_forms/dto.py +110 -0
  20. stapel_forms/errors.py +148 -0
  21. stapel_forms/events.py +59 -0
  22. stapel_forms/export.py +151 -0
  23. stapel_forms/functions.py +13 -0
  24. stapel_forms/gdpr.py +82 -0
  25. stapel_forms/management/__init__.py +0 -0
  26. stapel_forms/management/commands/__init__.py +0 -0
  27. stapel_forms/management/commands/forms_purge_expired.py +22 -0
  28. stapel_forms/migrations/0001_initial.py +88 -0
  29. stapel_forms/migrations/__init__.py +0 -0
  30. stapel_forms/models.py +157 -0
  31. stapel_forms/notifications.py +231 -0
  32. stapel_forms/presenters.py +469 -0
  33. stapel_forms/py.typed +0 -0
  34. stapel_forms/schema.py +393 -0
  35. stapel_forms/schemas/emits/form.closed.json +12 -0
  36. stapel_forms/schemas/emits/form.published.json +13 -0
  37. stapel_forms/schemas/emits/form.submission.received.json +14 -0
  38. stapel_forms/serializers.py +163 -0
  39. stapel_forms/services.py +520 -0
  40. stapel_forms/static/stapel_forms/forms-builder.js +320 -0
  41. stapel_forms/tasks.py +78 -0
  42. stapel_forms/templates/admin/forms/change_form.html +32 -0
  43. stapel_forms/templates/admin/forms/responses.html +125 -0
  44. stapel_forms/templates/admin/forms/submission_answers.html +24 -0
  45. stapel_forms/translations/.state.json +174 -0
  46. stapel_forms/translations/errors.es.json +23 -0
  47. stapel_forms/translations/errors.ru.json +23 -0
  48. stapel_forms/urls.py +18 -0
  49. stapel_forms/urls_v1.py +93 -0
  50. stapel_forms/views.py +843 -0
  51. stapel_forms-0.6.3.dist-info/METADATA +211 -0
  52. stapel_forms-0.6.3.dist-info/RECORD +55 -0
  53. stapel_forms-0.6.3.dist-info/WHEEL +5 -0
  54. stapel_forms-0.6.3.dist-info/licenses/LICENSE +21 -0
  55. 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`. |
@@ -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())
@@ -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 = "/"