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.
Files changed (51) hide show
  1. stapel_forms/CONFIG.MD +81 -0
  2. stapel_forms/__init__.py +31 -0
  3. stapel_forms/_capabilities.py +48 -0
  4. stapel_forms/_codegen.py +117 -0
  5. stapel_forms/_codegen_settings.py +108 -0
  6. stapel_forms/actions.py +51 -0
  7. stapel_forms/admin.py +44 -0
  8. stapel_forms/apps.py +31 -0
  9. stapel_forms/authz.py +110 -0
  10. stapel_forms/checks.py +144 -0
  11. stapel_forms/codegen_urls.py +15 -0
  12. stapel_forms/conf.py +107 -0
  13. stapel_forms/conftest.py +141 -0
  14. stapel_forms/docs/capabilities.json +424 -0
  15. stapel_forms/docs/errors.json +658 -0
  16. stapel_forms/docs/flows.json +1 -0
  17. stapel_forms/docs/llms.txt +237 -0
  18. stapel_forms/docs/schema.json +1466 -0
  19. stapel_forms/dto.py +110 -0
  20. stapel_forms/errors.py +136 -0
  21. stapel_forms/events.py +59 -0
  22. stapel_forms/export.py +122 -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 +178 -0
  32. stapel_forms/presenters.py +317 -0
  33. stapel_forms/py.typed +0 -0
  34. stapel_forms/schema.py +254 -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 +153 -0
  39. stapel_forms/services.py +463 -0
  40. stapel_forms/tasks.py +78 -0
  41. stapel_forms/translations/.state.json +174 -0
  42. stapel_forms/translations/errors.es.json +23 -0
  43. stapel_forms/translations/errors.ru.json +23 -0
  44. stapel_forms/urls.py +18 -0
  45. stapel_forms/urls_v1.py +93 -0
  46. stapel_forms/views.py +720 -0
  47. stapel_forms-0.2.0.dist-info/METADATA +211 -0
  48. stapel_forms-0.2.0.dist-info/RECORD +51 -0
  49. stapel_forms-0.2.0.dist-info/WHEEL +5 -0
  50. stapel_forms-0.2.0.dist-info/licenses/LICENSE +21 -0
  51. 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`. |
@@ -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())
@@ -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 = "/"
@@ -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
+ ]