stapel-docs 0.1.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_docs/CONFIG.MD +65 -0
  2. stapel_docs/__init__.py +31 -0
  3. stapel_docs/_capabilities.py +39 -0
  4. stapel_docs/_codegen.py +143 -0
  5. stapel_docs/_codegen_settings.py +132 -0
  6. stapel_docs/actions.py +100 -0
  7. stapel_docs/admin.py +53 -0
  8. stapel_docs/apps.py +30 -0
  9. stapel_docs/assets/DEJAVU-FONTS-LICENSE.txt +99 -0
  10. stapel_docs/assets/DejaVuSans-Bold.ttf +0 -0
  11. stapel_docs/assets/DejaVuSans.ttf +0 -0
  12. stapel_docs/authz.py +121 -0
  13. stapel_docs/checks.py +129 -0
  14. stapel_docs/codegen_urls.py +25 -0
  15. stapel_docs/conf.py +106 -0
  16. stapel_docs/conftest.py +107 -0
  17. stapel_docs/doc_types.py +163 -0
  18. stapel_docs/docs/capabilities.json +392 -0
  19. stapel_docs/docs/errors.json +450 -0
  20. stapel_docs/docs/flows.json +1 -0
  21. stapel_docs/docs/llms.txt +238 -0
  22. stapel_docs/dto.py +79 -0
  23. stapel_docs/errors.py +52 -0
  24. stapel_docs/events.py +72 -0
  25. stapel_docs/exporters.py +159 -0
  26. stapel_docs/functions.py +53 -0
  27. stapel_docs/gdpr.py +71 -0
  28. stapel_docs/management/__init__.py +0 -0
  29. stapel_docs/management/commands/__init__.py +0 -0
  30. stapel_docs/management/commands/docs_purge_expired.py +21 -0
  31. stapel_docs/migrations/0001_initial.py +126 -0
  32. stapel_docs/migrations/__init__.py +0 -0
  33. stapel_docs/models.py +216 -0
  34. stapel_docs/presenters.py +271 -0
  35. stapel_docs/py.typed +0 -0
  36. stapel_docs/schemas/emits/document.created.json +16 -0
  37. stapel_docs/schemas/emits/document.deleted.json +12 -0
  38. stapel_docs/schemas/emits/document.storage_changed.json +12 -0
  39. stapel_docs/schemas/emits/document.updated.json +13 -0
  40. stapel_docs/schemas/functions/docs.create_document.json +18 -0
  41. stapel_docs/serializers.py +173 -0
  42. stapel_docs/services.py +817 -0
  43. stapel_docs/storage.py +380 -0
  44. stapel_docs/urls.py +16 -0
  45. stapel_docs/urls_v1.py +77 -0
  46. stapel_docs/views.py +855 -0
  47. stapel_docs-0.1.0.dist-info/METADATA +193 -0
  48. stapel_docs-0.1.0.dist-info/RECORD +51 -0
  49. stapel_docs-0.1.0.dist-info/WHEEL +5 -0
  50. stapel_docs-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. stapel_docs-0.1.0.dist-info/top_level.txt +1 -0
stapel_docs/CONFIG.MD ADDED
@@ -0,0 +1,65 @@
1
+ # CONFIG.MD — stapel-docs
2
+
3
+ Config registry for **stapel-docs** (`static-scaffold-and-config.md` §2).
4
+ One row per configuration key the module reads, its **source** (`env` = the
5
+ process environment / the `STAPEL_DOCS` settings dict), what it is for,
6
+ whether it is required, and its default.
7
+
8
+ All keys are read through `docs_settings`
9
+ (`stapel_docs.conf.AppSettings`, namespace `STAPEL_DOCS`).
10
+ Resolution order per key: `settings.STAPEL_DOCS` dict → a flat Django
11
+ setting of the same name → environment variable → the default below.
12
+ `STORAGE` is resolved with `import_string` (dotted path). `SHARING` is one
13
+ nested dict key; its sub-keys are listed as `SHARING.<KEY>` rows below.
14
+
15
+ ## stapel-docs
16
+
17
+ ### Storage seam & presigned URLs (source: env)
18
+
19
+ | Key | Source | Purpose | Required | Default |
20
+ |-----|--------|---------|----------|---------|
21
+ | STORAGE | env | dotted path to a DocsStorage implementation (REPLACE) — every byte of document content I/O goes through it. | no | stapel_docs.storage.DjangoStorageBackend |
22
+ | STORAGE_PREFIX | env | object-key prefix: `{STORAGE_PREFIX}/{workspace_id}/{document_id}/…`. | no | docs |
23
+ | UPLOAD_URL_EXPIRES_SECONDS | env | presigned PUT URL lifetime (upload tickets). | no | 900 |
24
+ | DOWNLOAD_URL_EXPIRES_SECONDS | env | presigned GET URL lifetime (downloads). | no | 3600 |
25
+
26
+ ### S3Backend (extra `[s3]`; read only when STORAGE points at it) (source: env)
27
+
28
+ | Key | Source | Purpose | Required | Default |
29
+ |-----|--------|---------|----------|---------|
30
+ | S3_ENDPOINT_URL | env | S3/MinIO endpoint URL (None = AWS default). | no | None |
31
+ | S3_PUBLIC_URL | env | public base URL substituted into presigned URLs (split-horizon deployments). | no | None |
32
+ | S3_ACCESS_KEY | env | access key id. | no | None |
33
+ | S3_SECRET_KEY | env | secret access key. | no | None |
34
+ | S3_REGION | env | bucket region. | no | us-east-1 |
35
+ | S3_BUCKET | env | bucket name. | no | stapel-docs |
36
+ | S3_CONNECT_TIMEOUT | env | botocore connect timeout (seconds) — tight on purpose; a fast failure beats a spinner. | no | 5 |
37
+ | S3_READ_TIMEOUT | env | botocore read timeout (seconds). | no | 15 |
38
+ | S3_MAX_ATTEMPTS | env | botocore retry ceiling. | no | 2 |
39
+
40
+ ### Registries & seams (source: env)
41
+
42
+ | Key | Source | Purpose | Required | Default |
43
+ |-----|--------|---------|----------|---------|
44
+ | DOC_TYPES | env | document-type registry overlay merged OVER the txt/md/csv/file builtins ({slug: dotted-path to DocTypeSpec \| None removes}). | no | {} |
45
+ | EXPORTERS | env | export-format registry overlay merged OVER the built-in pdf ({format: dotted-path \| None removes}). | no | {} |
46
+ | INGEST | env | event-driven ingest ({action_name: dotted-path mapper payload → create_document kwargs}); broken entries raise ImproperlyConfigured at wiring. | no | {} |
47
+
48
+ ### Journal / revisions / tree / trash (source: env)
49
+
50
+ | Key | Source | Purpose | Required | Default |
51
+ |-----|--------|---------|----------|---------|
52
+ | REPLAY_WINDOW | env | journal rows with seq ≤ snapshot_seq − window are compacted away (chat-pattern replay window). | no | 500 |
53
+ | AUTO_REVISION_INTERVAL_SECONDS | env | a snapshot save mints an `auto` Revision when the newest revision is older than this (0 = revision on every save). | no | 300 |
54
+ | FOLDER_MAX_DEPTH | env | folder tree depth ceiling (create/move/ingest-path all enforce it). | no | 10 |
55
+ | TRASH_RETENTION_DAYS | env | soft-deleted items become purgeable by `docs_purge_expired` after this many days; purge is irreversible. | no | 30 |
56
+
57
+ ### Sharing axis (closed defaults; v1 system-check guards E010-E014) (source: env)
58
+
59
+ | Key | Source | Purpose | Required | Default |
60
+ |-----|--------|---------|----------|---------|
61
+ | SHARING.MODES | env | additional grant sources over the immutable workspace baseline (subset of whitelist \| link). v1 implements neither — non-empty is a system-check ERROR. | no | [] |
62
+ | SHARING.RESOLVERS | env | {ref_kind: dotted-path} whitelist subject-reference resolvers — real-but-empty seam; entries are import-validated at check time (E014). | no | {} |
63
+ | SHARING.LINK.ANONYMOUS | env | anonymous link redemption. True in v1 = system-check ERROR (E012). | no | false |
64
+ | SHARING.LINK.MAX_LEVEL | env | ceiling for minted link level. Above `view` in v1 = system-check ERROR (E013); `manage` is never grantable by any share source. | no | view |
65
+ | SHARING.LINK.TTL_DAYS | env | link TTL; None (perpetual) only ever by explicit host choice. | no | 30 |
@@ -0,0 +1,31 @@
1
+ """stapel-docs — Documents: storage, revisions and per-type editors for the Stapel framework.
2
+
3
+ Public API (lazily exported, PEP 562 — importing this package never pulls
4
+ in Django or requires configured settings):
5
+
6
+ - ``docs_settings`` — resolved app settings (``stapel_docs.conf``).
7
+ """
8
+
9
+ __all__ = [
10
+ "docs_settings",
11
+ ]
12
+
13
+ # name -> submodule that defines it. Resolution is deferred until first
14
+ # attribute access so that `import stapel_docs` stays Django-free.
15
+ _LAZY_EXPORTS = {
16
+ "docs_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,39 @@
1
+ """stapel-docs 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_docs._codegen import _configure
9
+
10
+ _configure()
11
+ from stapel_docs.conf import DEFAULTS
12
+ from stapel_docs.urls_v1 import GATE_REGISTRY
13
+
14
+ # SHARING is the one CTO-facing axis: does the product grant access
15
+ # beyond the immutable workspace baseline (MODES / LINK.* — closed by
16
+ # default, v1 system checks E010-E013 refuse opening it before the
17
+ # mechanism exists). The emitter expresses axes as top-level DEFAULTS
18
+ # keys only, so the nested MODES/LINK.* knobs surface as ONE composite
19
+ # SHARING axis (derived kind "enum", default = the closed dict) rather
20
+ # than per-key axes — honest within the mechanism, detailed in the
21
+ # curated summary. STORAGE/DOC_TYPES/EXPORTERS/INGEST/SHARING RESOLVERS
22
+ # are extension seams (curated in docs/capabilities.meta.json);
23
+ # timeouts, URL lifetimes, REPLAY_WINDOW, AUTO_REVISION_INTERVAL,
24
+ # FOLDER_MAX_DEPTH, TRASH_RETENTION_DAYS and the S3_* block are tuning
25
+ # — neither axes nor extension points.
26
+ return run_capabilities_cli(
27
+ argv,
28
+ repo=Path(__file__).resolve().parent,
29
+ canonical_prefix="/docs/api/v1",
30
+ defaults=DEFAULTS,
31
+ registry=GATE_REGISTRY,
32
+ is_axis=lambda k: k == "SHARING",
33
+ axis_group=axis_group_rules(exact={"SHARING": "docs.sharing"}),
34
+ prog="stapel-docs-capabilities",
35
+ )
36
+
37
+
38
+ if __name__ == "__main__":
39
+ raise SystemExit(main())
@@ -0,0 +1,143 @@
1
+ """stapel-docs contract-emission harness (contract-pipeline.md §2-3).
2
+
3
+ Emits the module's own contract triad into ``docs/`` from a single-module
4
+ ``{docs + core}`` Django instance mounted at the canonical ``/docs/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 — empty here, this
9
+ module has no ``@flow_step`` annotations
10
+ docs/errors.json generate_error_keys registry (the per-module etalon)
11
+
12
+ Copied from stapel-recordings' adaptation of the stapel-auth reference
13
+ implementation; the *mechanism* is stapel_tools.codegen (unchanged, shared),
14
+ this file is the thin per-module *config* that wires the module's settings +
15
+ canonical mount into it.
16
+
17
+ Like recordings, stapel-docs is **not mounted in stapel-example-monolith**
18
+ (grep-confirmed: no route for it in ``svc-app/core/urls.py``), so there is no
19
+ monolith aggregate slice to diff this artifact against for byte-identity.
20
+ Validation is standalone instead — see ``tests/test_contract.py``.
21
+
22
+ Usage:
23
+ python -m stapel_docs._codegen --out docs # `make contract`
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import os
29
+ import sys
30
+ from pathlib import Path
31
+
32
+
33
+ def _configure() -> None:
34
+ """Configure + boot the single-module Django instance for emission."""
35
+ # `python -m` prepends cwd to sys.path; strip the repo root the same way
36
+ # a flat-layout conftest does (defensively — docs has no colliding
37
+ # subpackage today, but the guard costs nothing and keeps the harness
38
+ # identical in shape to auth/profiles/recordings).
39
+ repo_root = os.path.dirname(os.path.abspath(__file__))
40
+ sys.path[:] = [p for p in sys.path if os.path.abspath(p or os.getcwd()) != repo_root]
41
+
42
+ from django.conf import settings
43
+
44
+ if not settings.configured:
45
+ from stapel_docs._codegen_settings import settings_kwargs
46
+
47
+ settings.configure(
48
+ **settings_kwargs(root_urlconf="stapel_docs.codegen_urls", contract=True)
49
+ )
50
+
51
+ import django
52
+
53
+ django.setup()
54
+
55
+ # drf-spectacular froze its settings singleton at import time (before this
56
+ # harness ran configure()), so it is on drf defaults — the same state
57
+ # every other pair-backend's harness emits under. The one knob to force is
58
+ # SCHEMA_PATH_PREFIX: left None, drf derives the operationId prefix from
59
+ # the common path of all endpoints — "/" across a multi-module aggregate
60
+ # (operationIds keep the mount segment, docs_api_*), but "/docs/api" in a
61
+ # single-module harness (which would strip it to bare anonymous names).
62
+ # Pin it to the aggregate convention so the operationIds match every other
63
+ # module's harness; SCHEMA_PATH_PREFIX_TRIM stays False (default) so the
64
+ # path *keys* keep /docs/api/v1/ on both sides.
65
+ from drf_spectacular.settings import spectacular_settings
66
+
67
+ from stapel_docs._codegen_settings import CODEGEN_SCHEMA_PATH_PREFIX
68
+
69
+ spectacular_settings.SCHEMA_PATH_PREFIX = CODEGEN_SCHEMA_PATH_PREFIX
70
+
71
+ # A real all-modules deployment registers drf-spectacular's JWT-cookie
72
+ # security-scheme extension as a side effect of its own dev-only Swagger
73
+ # URLs (DJANGO_ENV=local) — a *global* registration on drf-spectacular's
74
+ # extension registry, not tied to any one module's urls.py. stapel-docs
75
+ # has no co-mounted sibling to trigger it (the profiles finding —
76
+ # contract-pipeline.md brief), so without registering it explicitly here,
77
+ # docs' protected endpoints (every view is
78
+ # ``permission_classes = [IsNotAnonymousUser]``) would emit without their
79
+ # `security: [{"JWTCookieAuth": []}]` entry.
80
+ from stapel_core.django.openapi.swagger import _register_jwt_auth_extension
81
+
82
+ _register_jwt_auth_extension()
83
+
84
+
85
+ def _require_python_312() -> None:
86
+ """Abort emission if not running the pinned 3.12 interpreter.
87
+
88
+ drf-spectacular's rendering of component descriptions (``Optional[X]`` vs
89
+ ``X | None``) depends on the Python **minor** version — contracts emitted
90
+ on anything other than 3.12 (the CI/monolith pin) produce false diffs
91
+ against the committed docs/*.json. Emission must never proceed on the
92
+ wrong minor.
93
+ """
94
+ if sys.version_info[:2] != (3, 12):
95
+ got = f"{sys.version_info.major}.{sys.version_info.minor}"
96
+ raise SystemExit(
97
+ f"stapel-docs contract emission ABORTED: running Python {got}, "
98
+ "but contracts must be emitted on Python 3.12 (the CI/monolith "
99
+ "pin). drf-spectacular renders component descriptions "
100
+ "(Optional[X] vs X | None) differently across Python minor "
101
+ "versions, so emitting on any other minor produces false diffs "
102
+ "against the committed docs/*.json. Re-run under a 3.12 "
103
+ "interpreter."
104
+ )
105
+
106
+
107
+ def main(argv: list[str] | None = None) -> int:
108
+ _require_python_312()
109
+
110
+ parser = argparse.ArgumentParser(
111
+ prog="stapel-docs-contract",
112
+ description="Emit this module's contract triad (schema.json + flows.json "
113
+ "+ errors.json) into --out, canonical /docs/api/v1 prefix.",
114
+ )
115
+ parser.add_argument(
116
+ "--out",
117
+ default="docs",
118
+ help="Output directory for the triad (default: docs).",
119
+ )
120
+ args = parser.parse_args(argv)
121
+
122
+ _configure()
123
+
124
+ # Reuse the shared mechanism's byte-stable emitters (contract-pipeline.md §2:
125
+ # "the single-module harness already exists").
126
+ from stapel_tools.codegen import emit_errors, emit_flows, emit_schema
127
+
128
+ out = Path(args.out)
129
+ out.mkdir(parents=True, exist_ok=True)
130
+ paths = emit_schema(out / "schema.json")
131
+ flows = emit_flows(out / "flows.json")
132
+ errors = emit_errors(out / "errors.json")
133
+
134
+ print(
135
+ f"stapel-docs contract: {paths} paths, {flows} flows, {errors} error keys "
136
+ f"→ {out}/",
137
+ file=sys.stderr,
138
+ )
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ raise SystemExit(main())
@@ -0,0 +1,132 @@
1
+ """Single-module Django settings for stapel-docs's contract harness.
2
+
3
+ The ``settings.configure(...)`` block for:
4
+
5
+ - the contract-emission harness (``_codegen.py`` / ``make contract``) —
6
+ mounts docs on its *canonical* public prefix
7
+ (``stapel_docs.codegen_urls`` → ``docs/`` — the module's own ``urls.py``
8
+ bakes the ``api/v1`` segment in, so the full canonical prefix is
9
+ ``/docs/api/v1``) and enables drf-spectacular, so the emitted
10
+ ``schema.json`` / ``flows.json`` paths match the module's documented
11
+ mount recipe (``urls.py``: ``path("docs/", include("stapel_docs.urls"))``)
12
+ (contract-pipeline.md §2); and
13
+ - the capabilities emitter (``_capabilities.py``), which reuses
14
+ ``_codegen._configure``.
15
+
16
+ Shape copied from stapel-recordings' harness (itself from the stapel-auth
17
+ etalon via stapel-profiles). One deliberate deviation from the recordings
18
+ mold: ``conftest.py`` keeps its historical inline ``settings.configure``
19
+ block instead of importing :func:`settings_kwargs` — the conftest belongs to
20
+ the test workstream, and folding it in here is its call. Until then this
21
+ file MIRRORS the conftest config (INSTALLED_APPS / comm / migrations) with
22
+ only the contract-required additions:
23
+
24
+ - ``stapel_core.django.apps.CommonDjangoConfig`` + ``drf_spectacular`` —
25
+ the conftest predates contract emission and carries neither;
26
+ ``CommonDjangoConfig`` supplies the ``generate_flow_docs`` /
27
+ ``generate_error_keys`` management commands the codegen harness calls;
28
+ - ``contract=True`` swaps in the *production* ``REST_FRAMEWORK`` (the
29
+ canonical stapel-core config, inlined as plain dotted paths — importing
30
+ it would trip the same chicken-and-egg as spectacular). This matters for
31
+ byte-identity: a real deployment emits with
32
+ ``DEFAULT_SCHEMA_CLASS=PermissionAwareAutoSchema`` and the real
33
+ permission/renderer classes, and DRF caches ``REST_FRAMEWORK`` on first
34
+ access, so it must be right at ``configure()`` time.
35
+
36
+ ``SPECTACULAR_SETTINGS`` is deliberately *not* set: drf-spectacular builds
37
+ its settings singleton at *import* time, before a ``configure()``-based
38
+ harness can populate it, so the emitter runs on drf defaults — the same
39
+ state every other pair-backend's harness emits under. The one knob that
40
+ still must be forced, ``SCHEMA_PATH_PREFIX``, is patched on the singleton
41
+ directly by the harness (see ``_codegen._configure``).
42
+ """
43
+ from __future__ import annotations
44
+
45
+
46
+ def settings_kwargs(
47
+ *,
48
+ root_urlconf: str = "stapel_docs.tests.urls",
49
+ contract: bool = False,
50
+ ) -> dict:
51
+ """Return the ``settings.configure(**kwargs)`` for a single-module docs
52
+ instance. ``root_urlconf`` selects the mount: bare
53
+ (``stapel_docs.tests.urls``) mirrors the test layout, canonical-prefix
54
+ (``stapel_docs.codegen_urls`` → ``docs/``) is what contract emission
55
+ uses."""
56
+ if contract:
57
+ # Mirror stapel_core.django.settings.REST_FRAMEWORK exactly (the
58
+ # config a real deployment emits under; auth/profiles/recordings
59
+ # inline the same block). Inlined, not imported, to dodge the
60
+ # import-time settings read.
61
+ rest_framework = {
62
+ "DEFAULT_AUTHENTICATION_CLASSES": [
63
+ "stapel_core.django.jwt.authentication.JWTCookieAuthentication",
64
+ ],
65
+ "DEFAULT_PERMISSION_CLASSES": [
66
+ "stapel_core.django.api.permissions.IsServiceRequest",
67
+ "stapel_core.django.api.permissions.IsSuperUser",
68
+ ],
69
+ "DEFAULT_RENDERER_CLASSES": [
70
+ "rest_framework.renderers.JSONRenderer",
71
+ "rest_framework.renderers.BrowsableAPIRenderer",
72
+ ],
73
+ "DEFAULT_SCHEMA_CLASS": "stapel_core.django.openapi.schemas.PermissionAwareAutoSchema",
74
+ "EXCEPTION_HANDLER": "stapel_core.django.api.errors.stapel_exception_handler",
75
+ }
76
+ else:
77
+ rest_framework = None
78
+
79
+ kwargs = dict(
80
+ SECRET_KEY="test-secret-key-not-for-production",
81
+ INSTALLED_APPS=[
82
+ "django.contrib.contenttypes",
83
+ "django.contrib.auth",
84
+ "django.contrib.sessions",
85
+ "django.contrib.admin",
86
+ "django.contrib.messages",
87
+ "stapel_core.django.apps.CommonDjangoConfig",
88
+ "stapel_core.django.users",
89
+ "rest_framework",
90
+ "drf_spectacular",
91
+ "stapel_docs",
92
+ ],
93
+ AUTH_USER_MODEL="users.User",
94
+ DATABASES={
95
+ "default": {
96
+ "ENGINE": "django.db.backends.sqlite3",
97
+ "NAME": ":memory:",
98
+ }
99
+ },
100
+ DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
101
+ USE_TZ=True,
102
+ ROOT_URLCONF=root_urlconf,
103
+ CACHES={
104
+ "default": {
105
+ "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
106
+ }
107
+ },
108
+ # Same comm shape as the conftest: synchronous in-process comm with
109
+ # schema validation ON. Schema emission never executes an action or
110
+ # Function, so this only needs to be present, not exercised.
111
+ STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
112
+ STAPEL_COMM={
113
+ "OUTBOX_ENABLED": False,
114
+ "ACTION_TRANSPORT": "inprocess",
115
+ "VALIDATE_SCHEMAS": True,
116
+ },
117
+ MIGRATION_MODULES={
118
+ "users": None,
119
+ "docs": None,
120
+ },
121
+ )
122
+ if rest_framework is not None:
123
+ kwargs["REST_FRAMEWORK"] = rest_framework
124
+ return kwargs
125
+
126
+
127
+ # The multi-module common path prefix drf-spectacular auto-detects when every
128
+ # pair-backend's schema is emitted inside an all-modules aggregate. Forced on
129
+ # the drf-spectacular settings singleton by the harness so a single-module
130
+ # instance derives the same operationIds (see _codegen._configure). Uniform
131
+ # across all pair-backends (contract-pipeline.md §2).
132
+ CODEGEN_SCHEMA_PATH_PREFIX = "/"
stapel_docs/actions.py ADDED
@@ -0,0 +1,100 @@
1
+ """Action subscriptions of stapel-docs.
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 live here:
8
+
9
+ - ``user.deleted`` → the GDPR provider's erasure (anonymize authorship,
10
+ never destroy surviving workspace content — storage-verdict §3);
11
+ - the INGEST seam (design §2/§6): ``STAPEL_DOCS["INGEST"]`` maps
12
+ ``{action_name: dotted-path mapper}`` so a host gets event-driven ingest
13
+ without writing a subscriber. Docs never learns a foreign event schema —
14
+ the mapper (host code) turns the payload into ``create_document`` kwargs.
15
+ """
16
+ import logging
17
+ from typing import Callable
18
+
19
+ from django.core.exceptions import ImproperlyConfigured
20
+ from stapel_core.comm import on_action, subscribe_action
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @on_action("user.deleted")
26
+ def handle_user_deleted(event):
27
+ """Erase a user's docs slice (GDPR Art. 17). Anonymize semantics: the
28
+ provider nulls authorship and keeps documents (idempotent — a nulled
29
+ row nulls to itself on redelivery)."""
30
+ from .gdpr import DocsGDPRProvider
31
+
32
+ user_id = event.payload.get("user_id")
33
+ if not user_id:
34
+ logger.error("user.deleted event without user_id: %s", event.event_id)
35
+ return
36
+ DocsGDPRProvider().delete(user_id)
37
+ logger.info("docs authorship anonymized for deleted user %s", user_id)
38
+
39
+
40
+ # ─── INGEST seam ─────────────────────────────────────────────────────
41
+
42
+ #: action name -> resolved mapper. Rebuilt atomically by :func:`wire_ingest`;
43
+ #: the single dispatcher below reads it at delivery time, so re-wiring
44
+ #: (tests, settings overlays) never stacks duplicate subscriptions.
45
+ _INGEST_MAPPERS: dict[str, Callable[[dict], dict]] = {}
46
+
47
+
48
+ def wire_ingest() -> None:
49
+ """Resolve ``STAPEL_DOCS["INGEST"]`` and subscribe the dispatcher.
50
+
51
+ Called from ``apps.py:ready()``; tests re-call it after overriding
52
+ settings. Configured-but-broken must not be silent (system-check
53
+ failure genre): an unimportable or non-callable mapper raises
54
+ :class:`ImproperlyConfigured` instead of a log-and-skip.
55
+ """
56
+ from django.utils.module_loading import import_string
57
+
58
+ from .conf import docs_settings
59
+
60
+ resolved: dict[str, Callable[[dict], dict]] = {}
61
+ for action_name, dotted in (docs_settings.INGEST or {}).items():
62
+ try:
63
+ mapper = import_string(dotted)
64
+ except ImportError as exc:
65
+ raise ImproperlyConfigured(
66
+ f"STAPEL_DOCS['INGEST'][{action_name!r}] = {dotted!r} cannot be imported"
67
+ ) from exc
68
+ if not callable(mapper):
69
+ raise ImproperlyConfigured(
70
+ f"STAPEL_DOCS['INGEST'][{action_name!r}] = {dotted!r} is not callable"
71
+ )
72
+ resolved[action_name] = mapper
73
+
74
+ _INGEST_MAPPERS.clear()
75
+ _INGEST_MAPPERS.update(resolved)
76
+ for action_name in resolved:
77
+ # subscribe() dedups an identical handler — re-wiring is safe.
78
+ subscribe_action(action_name, _handle_ingest)
79
+
80
+
81
+ def _handle_ingest(event):
82
+ """Route a configured host action into a document.
83
+
84
+ Delivery is at-least-once; create is not naturally idempotent, so
85
+ dedup (e.g. an idempotency key in metadata) is the mapper/host's call —
86
+ same contract as any bus consumer creating rows.
87
+ """
88
+ mapper = _INGEST_MAPPERS.get(event.event_type)
89
+ if mapper is None:
90
+ # Stale subscription: a re-wire dropped this action (there is no
91
+ # unsubscribe in the registry) — inert by design.
92
+ return
93
+ kwargs = mapper(event.payload)
94
+
95
+ from . import services # lazy: mirror functions.py
96
+
97
+ services.create_document(**kwargs)
98
+
99
+
100
+ __all__ = ["handle_user_deleted", "wire_ingest"]
stapel_docs/admin.py ADDED
@@ -0,0 +1,53 @@
1
+ """Admin for stapel-docs.
2
+
3
+ Read-only across the board: every row here is workspace content or write
4
+ machinery with no staff add/change/delete workflow — mutations go through
5
+ the API (where authorize() and the outbox emits live), never the admin.
6
+ """
7
+ from django.contrib import admin
8
+
9
+ from .models import Document, DocumentUpdate, Folder, Revision, UploadSession
10
+
11
+
12
+ class _ReadOnlyAdmin(admin.ModelAdmin):
13
+ def has_add_permission(self, request):
14
+ return False
15
+
16
+ def has_change_permission(self, request, obj=None):
17
+ return False
18
+
19
+ def has_delete_permission(self, request, obj=None):
20
+ return False
21
+
22
+
23
+ @admin.register(Folder)
24
+ class FolderAdmin(_ReadOnlyAdmin):
25
+ list_display = ("id", "name", "workspace_id", "parent", "deleted_at", "created_at")
26
+ search_fields = ("id", "name", "workspace_id")
27
+
28
+
29
+ @admin.register(Document)
30
+ class DocumentAdmin(_ReadOnlyAdmin):
31
+ list_display = (
32
+ "id", "title", "type", "workspace_id", "head_seq", "size_bytes",
33
+ "deleted_at", "created_at",
34
+ )
35
+ list_filter = ("type",)
36
+ search_fields = ("id", "title", "workspace_id")
37
+
38
+
39
+ @admin.register(DocumentUpdate)
40
+ class DocumentUpdateAdmin(_ReadOnlyAdmin):
41
+ list_display = ("id", "document", "seq", "author_id", "created_at")
42
+
43
+
44
+ @admin.register(Revision)
45
+ class RevisionAdmin(_ReadOnlyAdmin):
46
+ list_display = ("id", "document", "seq", "kind", "name", "size_bytes", "created_at")
47
+ list_filter = ("kind",)
48
+
49
+
50
+ @admin.register(UploadSession)
51
+ class UploadSessionAdmin(_ReadOnlyAdmin):
52
+ list_display = ("id", "title", "workspace_id", "state", "size_bytes", "created_at")
53
+ list_filter = ("state",)
stapel_docs/apps.py ADDED
@@ -0,0 +1,30 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DocsConfig(AppConfig):
5
+ name = "stapel_docs"
6
+ label = "docs"
7
+ verbose_name = "Documents: storage, revisions and per-type editors"
8
+ default_auto_field = "django.db.models.BigAutoField"
9
+
10
+ def ready(self):
11
+ # Import-time side effects: comm functions/actions, system checks,
12
+ # error-key registration. 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 consumer + the INGEST seam.
20
+ from . import actions
21
+
22
+ actions.wire_ingest()
23
+
24
+ # GDPR provider registration (monolith mode).
25
+ from stapel_core.gdpr import gdpr_registry
26
+
27
+ from .gdpr import DocsGDPRProvider
28
+
29
+ if DocsGDPRProvider().section not in gdpr_registry.sections:
30
+ gdpr_registry.register(DocsGDPRProvider())