stapel-forms 0.2.0__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 +48 -0
- stapel_forms/_codegen.py +117 -0
- stapel_forms/_codegen_settings.py +108 -0
- stapel_forms/actions.py +51 -0
- stapel_forms/admin.py +44 -0
- stapel_forms/apps.py +31 -0
- stapel_forms/authz.py +110 -0
- stapel_forms/checks.py +144 -0
- stapel_forms/codegen_urls.py +15 -0
- stapel_forms/conf.py +107 -0
- stapel_forms/conftest.py +141 -0
- stapel_forms/docs/capabilities.json +424 -0
- stapel_forms/docs/errors.json +658 -0
- stapel_forms/docs/flows.json +1 -0
- stapel_forms/docs/llms.txt +237 -0
- stapel_forms/docs/schema.json +1466 -0
- stapel_forms/dto.py +110 -0
- stapel_forms/errors.py +136 -0
- stapel_forms/events.py +59 -0
- stapel_forms/export.py +122 -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 +178 -0
- stapel_forms/presenters.py +317 -0
- stapel_forms/py.typed +0 -0
- stapel_forms/schema.py +254 -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 +153 -0
- stapel_forms/services.py +463 -0
- stapel_forms/tasks.py +78 -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 +720 -0
- stapel_forms-0.2.0.dist-info/METADATA +211 -0
- stapel_forms-0.2.0.dist-info/RECORD +51 -0
- stapel_forms-0.2.0.dist-info/WHEEL +5 -0
- stapel_forms-0.2.0.dist-info/licenses/LICENSE +21 -0
- stapel_forms-0.2.0.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. |
|
|
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,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())
|
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,108 @@
|
|
|
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 settings_kwargs(
|
|
27
|
+
*,
|
|
28
|
+
root_urlconf: str = "stapel_forms.tests.urls",
|
|
29
|
+
contract: bool = False,
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""The ``settings.configure(**kwargs)`` for a single-module forms instance."""
|
|
32
|
+
if contract:
|
|
33
|
+
# Mirror stapel_core.django.settings.REST_FRAMEWORK exactly (the
|
|
34
|
+
# config a real deployment emits under). Inlined, not imported, to
|
|
35
|
+
# dodge the import-time settings read.
|
|
36
|
+
rest_framework = {
|
|
37
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
38
|
+
"stapel_core.django.jwt.authentication.JWTCookieAuthentication",
|
|
39
|
+
],
|
|
40
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
41
|
+
"stapel_core.django.api.permissions.IsServiceRequest",
|
|
42
|
+
"stapel_core.django.api.permissions.IsSuperUser",
|
|
43
|
+
],
|
|
44
|
+
"DEFAULT_RENDERER_CLASSES": [
|
|
45
|
+
"rest_framework.renderers.JSONRenderer",
|
|
46
|
+
"rest_framework.renderers.BrowsableAPIRenderer",
|
|
47
|
+
],
|
|
48
|
+
"DEFAULT_SCHEMA_CLASS": "stapel_core.django.openapi.schemas.PermissionAwareAutoSchema",
|
|
49
|
+
"EXCEPTION_HANDLER": "stapel_core.django.api.errors.stapel_exception_handler",
|
|
50
|
+
}
|
|
51
|
+
else:
|
|
52
|
+
rest_framework = None
|
|
53
|
+
|
|
54
|
+
kwargs = dict(
|
|
55
|
+
SECRET_KEY="test-secret-key-not-for-production",
|
|
56
|
+
INSTALLED_APPS=[
|
|
57
|
+
"django.contrib.contenttypes",
|
|
58
|
+
"django.contrib.auth",
|
|
59
|
+
"django.contrib.sessions",
|
|
60
|
+
"django.contrib.admin",
|
|
61
|
+
"django.contrib.messages",
|
|
62
|
+
"stapel_core.django.apps.CommonDjangoConfig",
|
|
63
|
+
"stapel_core.django.users",
|
|
64
|
+
"rest_framework",
|
|
65
|
+
"drf_spectacular",
|
|
66
|
+
"stapel_forms",
|
|
67
|
+
],
|
|
68
|
+
AUTH_USER_MODEL="users.User",
|
|
69
|
+
DATABASES={
|
|
70
|
+
"default": {
|
|
71
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
72
|
+
"NAME": ":memory:",
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
|
|
76
|
+
USE_TZ=True,
|
|
77
|
+
ROOT_URLCONF=root_urlconf,
|
|
78
|
+
CACHES={
|
|
79
|
+
"default": {
|
|
80
|
+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
# Synchronous in-process comm with schema validation ON, so the
|
|
84
|
+
# committed contracts in schemas/ are enforced by the tests.
|
|
85
|
+
STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
|
|
86
|
+
STAPEL_COMM={
|
|
87
|
+
"OUTBOX_ENABLED": False,
|
|
88
|
+
"ACTION_TRANSPORT": "inprocess",
|
|
89
|
+
"VALIDATE_SCHEMAS": True,
|
|
90
|
+
# An emit outside a transaction is a bug here, not a warning:
|
|
91
|
+
# the outbox canon is what makes "the row exists" and "the fact
|
|
92
|
+
# was announced" one decision.
|
|
93
|
+
"EMIT_OUTSIDE_ATOMIC": "error",
|
|
94
|
+
},
|
|
95
|
+
MIGRATION_MODULES={
|
|
96
|
+
"users": None,
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
if rest_framework is not None:
|
|
100
|
+
kwargs["REST_FRAMEWORK"] = rest_framework
|
|
101
|
+
return kwargs
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# The multi-module common path prefix drf-spectacular auto-detects when
|
|
105
|
+
# every pair-backend's schema is emitted inside an all-modules aggregate.
|
|
106
|
+
# Forced on the singleton by the harness so a single-module instance
|
|
107
|
+
# derives the same operationIds.
|
|
108
|
+
CODEGEN_SCHEMA_PATH_PREFIX = "/"
|
stapel_forms/actions.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Action subscriptions of stapel-forms.
|
|
2
|
+
|
|
3
|
+
Handlers are idempotent-minded (delivery is at-least-once — outbox retries,
|
|
4
|
+
broker redelivery). Transport is chosen by ``STAPEL_COMM`` (in-process in a
|
|
5
|
+
monolith, bus consumer in microservices); the handler code is identical.
|
|
6
|
+
|
|
7
|
+
Two consumers:
|
|
8
|
+
|
|
9
|
+
- ``user.deleted`` → the GDPR provider's erasure of an authenticated
|
|
10
|
+
respondent's answers;
|
|
11
|
+
- this module's OWN ``form.submission.received`` → the notify subscriber.
|
|
12
|
+
Reacting to our own event rather than calling the notifier inline is
|
|
13
|
+
what makes the auto-notify survive the transaction: the row is committed
|
|
14
|
+
before anybody is told about it, and a notification service outage can
|
|
15
|
+
never roll back a respondent's answer.
|
|
16
|
+
"""
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from stapel_core.comm import on_action
|
|
20
|
+
|
|
21
|
+
from .events import FORM_SUBMISSION_RECEIVED
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@on_action("user.deleted")
|
|
27
|
+
def handle_user_deleted(event):
|
|
28
|
+
"""Erase a user's form answers (GDPR Art. 17)."""
|
|
29
|
+
from .gdpr import FormsGDPRProvider
|
|
30
|
+
|
|
31
|
+
user_id = event.payload.get("user_id")
|
|
32
|
+
if not user_id:
|
|
33
|
+
logger.error("user.deleted event without user_id: %s", event.event_id)
|
|
34
|
+
return
|
|
35
|
+
FormsGDPRProvider().delete(user_id)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@on_action(FORM_SUBMISSION_RECEIVED)
|
|
39
|
+
def handle_submission_received(event):
|
|
40
|
+
"""Notify the form's configured recipients, behind the cooldown."""
|
|
41
|
+
from .models import Form
|
|
42
|
+
from .notifications import notify_submission_received
|
|
43
|
+
|
|
44
|
+
form_id = event.payload.get("form_id")
|
|
45
|
+
form = Form.objects.filter(id=form_id).first()
|
|
46
|
+
if form is None:
|
|
47
|
+
return
|
|
48
|
+
notify_submission_received(form)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = ["handle_user_deleted", "handle_submission_received"]
|
stapel_forms/admin.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Admin for stapel-forms — an operator peephole, nothing more.
|
|
2
|
+
|
|
3
|
+
Read-only across the board. Workspace admins are not Django staff: in a
|
|
4
|
+
multi-tenant product the people who author forms and review responses reach
|
|
5
|
+
them through the capability-gated REST surface, never through here. What
|
|
6
|
+
this registration is for is the operator looking at a support ticket.
|
|
7
|
+
|
|
8
|
+
``Submission`` is ``@access.sensitive`` (models.py), so the staff mandate
|
|
9
|
+
gates even this view at MID clearance — two independent doors, both shut by
|
|
10
|
+
default.
|
|
11
|
+
"""
|
|
12
|
+
from django.contrib import admin
|
|
13
|
+
|
|
14
|
+
from .models import Form, FormVersion, Submission
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _ReadOnlyAdmin(admin.ModelAdmin):
|
|
18
|
+
def has_add_permission(self, request):
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
def has_change_permission(self, request, obj=None):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
def has_delete_permission(self, request, obj=None):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@admin.register(Form)
|
|
29
|
+
class FormAdmin(_ReadOnlyAdmin):
|
|
30
|
+
list_display = ("id", "title", "workspace_id", "state", "deleted_at", "created_at")
|
|
31
|
+
list_filter = ("state",)
|
|
32
|
+
search_fields = ("id", "title", "workspace_id", "public_id")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@admin.register(FormVersion)
|
|
36
|
+
class FormVersionAdmin(_ReadOnlyAdmin):
|
|
37
|
+
list_display = ("id", "form", "version", "published_at")
|
|
38
|
+
search_fields = ("id", "form__id")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@admin.register(Submission)
|
|
42
|
+
class SubmissionAdmin(_ReadOnlyAdmin):
|
|
43
|
+
list_display = ("id", "form", "version", "submitted_at", "submitted_by", "erased_at")
|
|
44
|
+
search_fields = ("id", "form__id", "workspace_id")
|
stapel_forms/apps.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FormsConfig(AppConfig):
|
|
5
|
+
name = "stapel_forms"
|
|
6
|
+
label = "forms"
|
|
7
|
+
verbose_name = "Forms: admin-defined schemas, anonymous submissions, response review"
|
|
8
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
# Import-time side effects: system checks, error-key registration,
|
|
12
|
+
# comm providers. Keep each in its own module.
|
|
13
|
+
from . import checks # noqa: F401
|
|
14
|
+
from . import errors # noqa: F401
|
|
15
|
+
from . import functions # noqa: F401
|
|
16
|
+
|
|
17
|
+
# Action subscriptions (in-process in a monolith, bus consumer in
|
|
18
|
+
# microservices — same code, transport chosen by STAPEL_COMM):
|
|
19
|
+
# user.deleted, plus this module's own submission event driving the
|
|
20
|
+
# notify subscriber.
|
|
21
|
+
from . import actions # noqa: F401
|
|
22
|
+
|
|
23
|
+
# GDPR provider registration (monolith mode). Hosts must ALSO list
|
|
24
|
+
# "forms" in STAPEL_GDPR["DATA_OWNERS"] — registering without
|
|
25
|
+
# declaring is gdpr.E002, and the erasure closure never completes.
|
|
26
|
+
from stapel_core.gdpr import gdpr_registry
|
|
27
|
+
|
|
28
|
+
from .gdpr import FormsGDPRProvider
|
|
29
|
+
|
|
30
|
+
if FormsGDPRProvider().section not in gdpr_registry.sections:
|
|
31
|
+
gdpr_registry.register(FormsGDPRProvider())
|
stapel_forms/authz.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The single authorization choke point for the admin surface.
|
|
2
|
+
|
|
3
|
+
Every access decision about a form or its responses routes through
|
|
4
|
+
:func:`authorize` — there is no second read path. The public respondent
|
|
5
|
+
endpoints (§5.1) are deliberately outside it: they are ``AllowAny`` by
|
|
6
|
+
design and gated by the abuse ladder instead, which is why the public
|
|
7
|
+
presenter is a dedicated envelope and not the admin one with fields
|
|
8
|
+
dropped.
|
|
9
|
+
|
|
10
|
+
``unavailable`` means the workspaces service rendered no verdict — callers
|
|
11
|
+
answer 503, never 403 ("a routing 404 is not a verdict", stapel-core
|
|
12
|
+
workspaces client canon).
|
|
13
|
+
|
|
14
|
+
Known limitation, stated rather than papered over: on stapel-core 0.26
|
|
15
|
+
``require_capability`` collapses "denied" and "peer unavailable" into the
|
|
16
|
+
same ``None`` — it logs the outage and returns, so the branch below can
|
|
17
|
+
only fire if a future core raises. Until that core PR lands, a workspaces
|
|
18
|
+
outage renders 403 here, the same as in stapel-docs. The branch stays live
|
|
19
|
+
because the fix belongs in core, not in a per-module workaround that would
|
|
20
|
+
re-implement the capability call and its cache.
|
|
21
|
+
|
|
22
|
+
The :class:`Principal` form is fixed on day 1 so anonymous-link style
|
|
23
|
+
grants later are an additive branch, not a rewrite: ``user_id=None`` means
|
|
24
|
+
no session at all; ``is_anonymous`` marks an anonymous ACCOUNT of the auth
|
|
25
|
+
axis (which does have a user_id); ``link_token`` carries a presented bearer
|
|
26
|
+
token, unused by the admin surface.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Optional
|
|
33
|
+
from uuid import UUID
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
ALLOW = "allow"
|
|
38
|
+
DENY = "deny"
|
|
39
|
+
UNAVAILABLE = "unavailable"
|
|
40
|
+
|
|
41
|
+
#: Actions and the workspace capability answering each.
|
|
42
|
+
ACTION_CAPABILITIES = {
|
|
43
|
+
"view": "forms.view",
|
|
44
|
+
"manage": "forms.manage",
|
|
45
|
+
"responses.view": "forms.responses.view",
|
|
46
|
+
"responses.manage": "forms.responses.manage",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#: Declared in full on day 1 so host role overlays never have to migrate.
|
|
50
|
+
#: There is deliberately no separate ``forms.responses.export``: an export
|
|
51
|
+
#: is a read, and core's staff mandate grades view/add/change/delete only.
|
|
52
|
+
CAPABILITIES = (
|
|
53
|
+
"forms.view",
|
|
54
|
+
"forms.manage",
|
|
55
|
+
"forms.responses.view",
|
|
56
|
+
"forms.responses.manage",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Principal:
|
|
62
|
+
"""Who is asking. Built by the view layer, consumed only here."""
|
|
63
|
+
|
|
64
|
+
user_id: Optional[UUID]
|
|
65
|
+
is_anonymous: bool = False
|
|
66
|
+
link_token: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_request(cls, request) -> "Principal":
|
|
70
|
+
user = getattr(request, "user", None)
|
|
71
|
+
user_id = getattr(user, "pk", None) if getattr(user, "is_authenticated", False) else None
|
|
72
|
+
is_anon = bool(getattr(user, "is_anonymous_account", False))
|
|
73
|
+
return cls(user_id=user_id, is_anonymous=is_anon, link_token=None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def authorize(*, workspace_id, principal: Principal, action: str) -> str:
|
|
77
|
+
"""Decide *action* for *principal* on *workspace_id*.
|
|
78
|
+
|
|
79
|
+
Returns ``allow`` | ``deny`` | ``unavailable``.
|
|
80
|
+
"""
|
|
81
|
+
if action not in ACTION_CAPABILITIES:
|
|
82
|
+
raise ValueError(f"unknown forms action: {action!r}")
|
|
83
|
+
|
|
84
|
+
if principal.user_id is not None:
|
|
85
|
+
from stapel_core.django.workspaces import (
|
|
86
|
+
WorkspaceLookupUnavailable,
|
|
87
|
+
require_capability,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
membership = require_capability(
|
|
92
|
+
workspace_id, principal.user_id, ACTION_CAPABILITIES[action]
|
|
93
|
+
)
|
|
94
|
+
except WorkspaceLookupUnavailable:
|
|
95
|
+
return UNAVAILABLE
|
|
96
|
+
if membership is not None:
|
|
97
|
+
return ALLOW
|
|
98
|
+
|
|
99
|
+
return DENY
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
"ALLOW",
|
|
104
|
+
"DENY",
|
|
105
|
+
"UNAVAILABLE",
|
|
106
|
+
"ACTION_CAPABILITIES",
|
|
107
|
+
"CAPABILITIES",
|
|
108
|
+
"Principal",
|
|
109
|
+
"authorize",
|
|
110
|
+
]
|