stapel-video 0.1.0__tar.gz

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 (50) hide show
  1. stapel_video-0.1.0/LICENSE +21 -0
  2. stapel_video-0.1.0/PKG-INFO +106 -0
  3. stapel_video-0.1.0/README.md +70 -0
  4. stapel_video-0.1.0/__init__.py +12 -0
  5. stapel_video-0.1.0/_capabilities.py +38 -0
  6. stapel_video-0.1.0/_codegen.py +116 -0
  7. stapel_video-0.1.0/_codegen_settings.py +114 -0
  8. stapel_video-0.1.0/actions.py +25 -0
  9. stapel_video-0.1.0/admin.py +25 -0
  10. stapel_video-0.1.0/apps.py +23 -0
  11. stapel_video-0.1.0/checks.py +79 -0
  12. stapel_video-0.1.0/codegen_urls.py +18 -0
  13. stapel_video-0.1.0/conf.py +77 -0
  14. stapel_video-0.1.0/conftest.py +66 -0
  15. stapel_video-0.1.0/consumers.py +81 -0
  16. stapel_video-0.1.0/dto.py +126 -0
  17. stapel_video-0.1.0/errors.py +34 -0
  18. stapel_video-0.1.0/gdpr.py +54 -0
  19. stapel_video-0.1.0/migrations/0001_initial.py +71 -0
  20. stapel_video-0.1.0/migrations/__init__.py +0 -0
  21. stapel_video-0.1.0/models.py +142 -0
  22. stapel_video-0.1.0/providers/__init__.py +22 -0
  23. stapel_video-0.1.0/providers/base.py +62 -0
  24. stapel_video-0.1.0/providers/livekit.py +198 -0
  25. stapel_video-0.1.0/py.typed +0 -0
  26. stapel_video-0.1.0/pyproject.toml +75 -0
  27. stapel_video-0.1.0/realtime.py +27 -0
  28. stapel_video-0.1.0/routing.py +20 -0
  29. stapel_video-0.1.0/schemas/consumes/user.deleted.json +14 -0
  30. stapel_video-0.1.0/schemas/emits/video.egress_ended.json +13 -0
  31. stapel_video-0.1.0/scope.py +56 -0
  32. stapel_video-0.1.0/serializers.py +51 -0
  33. stapel_video-0.1.0/services.py +288 -0
  34. stapel_video-0.1.0/setup.cfg +4 -0
  35. stapel_video-0.1.0/stapel_video.egg-info/PKG-INFO +106 -0
  36. stapel_video-0.1.0/stapel_video.egg-info/SOURCES.txt +79 -0
  37. stapel_video-0.1.0/stapel_video.egg-info/dependency_links.txt +1 -0
  38. stapel_video-0.1.0/stapel_video.egg-info/requires.txt +13 -0
  39. stapel_video-0.1.0/stapel_video.egg-info/top_level.txt +1 -0
  40. stapel_video-0.1.0/tests/test_checks.py +31 -0
  41. stapel_video-0.1.0/tests/test_consumer.py +95 -0
  42. stapel_video-0.1.0/tests/test_contract.py +172 -0
  43. stapel_video-0.1.0/tests/test_gdpr.py +45 -0
  44. stapel_video-0.1.0/tests/test_livekit_provider.py +174 -0
  45. stapel_video-0.1.0/tests/test_lobby.py +71 -0
  46. stapel_video-0.1.0/tests/test_pagination.py +77 -0
  47. stapel_video-0.1.0/tests/test_rooms.py +124 -0
  48. stapel_video-0.1.0/tests/test_webhook.py +63 -0
  49. stapel_video-0.1.0/urls.py +69 -0
  50. stapel_video-0.1.0/views.py +316 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stapel contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-video
3
+ Version: 0.1.0
4
+ Summary: Video calls (rooms, lobby admission, recording-egress seam) for the Stapel framework
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/usestapel/stapel-video
7
+ Project-URL: Repository, https://github.com/usestapel/stapel-video
8
+ Project-URL: Documentation, https://github.com/usestapel/stapel-video#readme
9
+ Project-URL: Changelog, https://github.com/usestapel/stapel-video/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/usestapel/stapel-video/issues
11
+ Keywords: django,stapel,video,livekit,webrtc
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Django
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: stapel-core<0.11,>=0.10
26
+ Provides-Extra: livekit
27
+ Requires-Dist: livekit-api<1,>=0.7; extra == "livekit"
28
+ Requires-Dist: requests<3,>=2.28; extra == "livekit"
29
+ Provides-Extra: channels
30
+ Requires-Dist: stapel-core[channels]; extra == "channels"
31
+ Provides-Extra: all
32
+ Requires-Dist: livekit-api<1,>=0.7; extra == "all"
33
+ Requires-Dist: requests<3,>=2.28; extra == "all"
34
+ Requires-Dist: stapel-core[channels]; extra == "all"
35
+ Dynamic: license-file
36
+
37
+ # stapel-video
38
+
39
+ Video calls for the [Stapel](https://github.com/usestapel) framework — a thin,
40
+ provider-agnostic library over a real-time video backend (LiveKit by default).
41
+
42
+ - **Rooms** with human-shareable join codes (`abc-defg-hij`).
43
+ - **Admission model** — `public` (anyone with the code joins), `scope_trusted`
44
+ (members of the room's scope join instantly, others wait), `restricted`
45
+ (everyone but the host waits in a lobby).
46
+ - **Realtime lobby** over WebSockets (Channels) — `waiting` / `admitted` /
47
+ `denied`, authenticated by the same Stapel JWT stack HTTP uses.
48
+ - **Host controls** — admit / deny waiting guests.
49
+ - **Provider seam** — one `VideoProvider` ABC (mint join token, create room,
50
+ start/stop recording egress, verify webhook). Swap vendors without forking.
51
+ - **Recording** is a *seam*, not a pipeline: `start`/`stop_egress` proxy the
52
+ provider and a `video.egress_ended` comm event carries the storage key to
53
+ [stapel-recordings](https://github.com/usestapel/stapel-recordings) — by
54
+ event, never by import.
55
+
56
+ Alpha (`0.1.0`). See [MODULE.md](MODULE.md) for the agent-facing map of seams.
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install stapel-video # core library
62
+ pip install 'stapel-video[livekit]' # + the default LiveKit backend
63
+ pip install 'stapel-video[channels]'# + the realtime lobby (WebSockets)
64
+ ```
65
+
66
+ ## Mount
67
+
68
+ ```python
69
+ # urls.py
70
+ path("video/", include("stapel_video.urls"))
71
+
72
+ # asgi.py (realtime lobby)
73
+ from channels.routing import ProtocolTypeRouter, URLRouter
74
+ from stapel_core.django.jwt.channels import JWTAuthMiddlewareStack
75
+ from stapel_video.routing import websocket_urlpatterns
76
+
77
+ application = ProtocolTypeRouter({
78
+ "http": django_asgi_app,
79
+ "websocket": JWTAuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
80
+ })
81
+ ```
82
+
83
+ ## API
84
+
85
+ | Method | Path | What |
86
+ |---|---|---|
87
+ | POST | `/video/api/rooms` | Create a room (creator auto-admitted host, with a token) |
88
+ | GET | `/video/api/rooms/{join_code}` | Room info |
89
+ | POST | `/video/api/rooms/{join_code}/join` | Join → admitted / waiting / denied |
90
+ | GET | `/video/api/rooms/{join_code}/participants` | Participants (anchor-paginated) |
91
+ | POST | `/video/api/rooms/{join_code}/lobby/admit` | Admit a waiting guest (host-only) |
92
+ | POST | `/video/api/rooms/{join_code}/lobby/deny` | Deny a waiting guest (host-only) |
93
+ | POST | `/video/api/webhook` | Provider webhook ingress (signed, unauthenticated) |
94
+
95
+ ## Configuration (`STAPEL_VIDEO`)
96
+
97
+ | Key | Default | What |
98
+ |---|---|---|
99
+ | `VIDEO_PROVIDER` | `…livekit.LiveKitProvider` | Video backend (dotted path) |
100
+ | `SCOPE_PROVIDER` | `…scope.DefaultScopeProvider` | scope_key resolution + membership |
101
+ | `DEFAULT_ACCESS_LEVEL` | `restricted` | Access level for a room created without one |
102
+ | `DEFAULT_ADMIT_REQUIRED` | `True` | Whether new rooms start with the lobby on |
103
+ | `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | `""` | Default-provider credentials |
104
+
105
+ `VIDEO_PROVIDER`, `DEFAULT_ACCESS_LEVEL` and `DEFAULT_ADMIT_REQUIRED` are the
106
+ three CTO-facing config axes surfaced in `docs/capabilities.json`.
@@ -0,0 +1,70 @@
1
+ # stapel-video
2
+
3
+ Video calls for the [Stapel](https://github.com/usestapel) framework — a thin,
4
+ provider-agnostic library over a real-time video backend (LiveKit by default).
5
+
6
+ - **Rooms** with human-shareable join codes (`abc-defg-hij`).
7
+ - **Admission model** — `public` (anyone with the code joins), `scope_trusted`
8
+ (members of the room's scope join instantly, others wait), `restricted`
9
+ (everyone but the host waits in a lobby).
10
+ - **Realtime lobby** over WebSockets (Channels) — `waiting` / `admitted` /
11
+ `denied`, authenticated by the same Stapel JWT stack HTTP uses.
12
+ - **Host controls** — admit / deny waiting guests.
13
+ - **Provider seam** — one `VideoProvider` ABC (mint join token, create room,
14
+ start/stop recording egress, verify webhook). Swap vendors without forking.
15
+ - **Recording** is a *seam*, not a pipeline: `start`/`stop_egress` proxy the
16
+ provider and a `video.egress_ended` comm event carries the storage key to
17
+ [stapel-recordings](https://github.com/usestapel/stapel-recordings) — by
18
+ event, never by import.
19
+
20
+ Alpha (`0.1.0`). See [MODULE.md](MODULE.md) for the agent-facing map of seams.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install stapel-video # core library
26
+ pip install 'stapel-video[livekit]' # + the default LiveKit backend
27
+ pip install 'stapel-video[channels]'# + the realtime lobby (WebSockets)
28
+ ```
29
+
30
+ ## Mount
31
+
32
+ ```python
33
+ # urls.py
34
+ path("video/", include("stapel_video.urls"))
35
+
36
+ # asgi.py (realtime lobby)
37
+ from channels.routing import ProtocolTypeRouter, URLRouter
38
+ from stapel_core.django.jwt.channels import JWTAuthMiddlewareStack
39
+ from stapel_video.routing import websocket_urlpatterns
40
+
41
+ application = ProtocolTypeRouter({
42
+ "http": django_asgi_app,
43
+ "websocket": JWTAuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
44
+ })
45
+ ```
46
+
47
+ ## API
48
+
49
+ | Method | Path | What |
50
+ |---|---|---|
51
+ | POST | `/video/api/rooms` | Create a room (creator auto-admitted host, with a token) |
52
+ | GET | `/video/api/rooms/{join_code}` | Room info |
53
+ | POST | `/video/api/rooms/{join_code}/join` | Join → admitted / waiting / denied |
54
+ | GET | `/video/api/rooms/{join_code}/participants` | Participants (anchor-paginated) |
55
+ | POST | `/video/api/rooms/{join_code}/lobby/admit` | Admit a waiting guest (host-only) |
56
+ | POST | `/video/api/rooms/{join_code}/lobby/deny` | Deny a waiting guest (host-only) |
57
+ | POST | `/video/api/webhook` | Provider webhook ingress (signed, unauthenticated) |
58
+
59
+ ## Configuration (`STAPEL_VIDEO`)
60
+
61
+ | Key | Default | What |
62
+ |---|---|---|
63
+ | `VIDEO_PROVIDER` | `…livekit.LiveKitProvider` | Video backend (dotted path) |
64
+ | `SCOPE_PROVIDER` | `…scope.DefaultScopeProvider` | scope_key resolution + membership |
65
+ | `DEFAULT_ACCESS_LEVEL` | `restricted` | Access level for a room created without one |
66
+ | `DEFAULT_ADMIT_REQUIRED` | `True` | Whether new rooms start with the lobby on |
67
+ | `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | `""` | Default-provider credentials |
68
+
69
+ `VIDEO_PROVIDER`, `DEFAULT_ACCESS_LEVEL` and `DEFAULT_ADMIT_REQUIRED` are the
70
+ three CTO-facing config axes surfaced in `docs/capabilities.json`.
@@ -0,0 +1,12 @@
1
+ """stapel-video — video calls for the Stapel framework.
2
+
3
+ A thin, provider-agnostic library over a real-time video backend (LiveKit by
4
+ default): rooms with join codes, an access-level admission model
5
+ (public / scope-trusted / restricted lobby), a realtime waiting-room over
6
+ Channels, and a recording-egress *seam* (start/stop + a ``video.egress_ended``
7
+ comm emit) that integrates with stapel-recordings by event, never by import.
8
+ """
9
+
10
+ default_app_config = "stapel_video.apps.VideoConfig"
11
+
12
+ __all__ = ["default_app_config"]
@@ -0,0 +1,38 @@
1
+ """stapel-video 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
+ #: The three CTO-facing config axes (capability-config.md §16). Every OTHER
7
+ #: DEFAULTS key is an extension seam (SCOPE_PROVIDER dotted path) or a tuning
8
+ #: knob (LiveKit credentials, token TTL, egress store).
9
+ _AXES = {"VIDEO_PROVIDER", "DEFAULT_ACCESS_LEVEL", "DEFAULT_ADMIT_REQUIRED"}
10
+
11
+
12
+ def main(argv=None):
13
+ from stapel_video._codegen import _configure
14
+
15
+ _configure()
16
+ from stapel_video.conf import DEFAULTS
17
+ from stapel_video.urls import GATE_REGISTRY
18
+
19
+ return run_capabilities_cli(
20
+ argv,
21
+ repo=Path(__file__).resolve().parent,
22
+ canonical_prefix="/video",
23
+ defaults=DEFAULTS,
24
+ registry=GATE_REGISTRY,
25
+ is_axis=lambda k: k in _AXES,
26
+ axis_group=axis_group_rules(
27
+ exact={
28
+ "VIDEO_PROVIDER": "video.provider",
29
+ "DEFAULT_ACCESS_LEVEL": "video.access",
30
+ "DEFAULT_ADMIT_REQUIRED": "video.admission",
31
+ }
32
+ ),
33
+ prog="stapel-video-capabilities",
34
+ )
35
+
36
+
37
+ if __name__ == "__main__":
38
+ raise SystemExit(main())
@@ -0,0 +1,116 @@
1
+ """stapel-video contract-emission harness (contract-pipeline.md §2-3).
2
+
3
+ Emits the module's own contract triad into ``docs/`` from a single-module
4
+ ``{video + core}`` Django instance mounted at the canonical ``video/api/``
5
+ prefix:
6
+
7
+ docs/schema.json drf-spectacular OpenAPI, this module only, canonical prefix
8
+ docs/flows.json generate_flow_docs machine artifact ([] — no @flow_step here)
9
+ docs/errors.json generate_error_keys registry
10
+
11
+ Copied from stapel-calendar's reference implementation; the *mechanism* is
12
+ stapel_tools.codegen (unchanged, shared), this file is the thin per-module
13
+ *config* that wires the module's settings + canonical mount into it.
14
+
15
+ Usage:
16
+ python -m stapel_video._codegen --out docs # `make contract`
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import os
22
+ import sys
23
+ from pathlib import Path
24
+
25
+
26
+ def _configure() -> None:
27
+ """Configure + boot the single-module Django instance for emission."""
28
+ repo_root = os.path.dirname(os.path.abspath(__file__))
29
+ sys.path[:] = [p for p in sys.path if os.path.abspath(p or os.getcwd()) != repo_root]
30
+
31
+ from django.conf import settings
32
+
33
+ if not settings.configured:
34
+ from stapel_video._codegen_settings import settings_kwargs
35
+
36
+ settings.configure(
37
+ **settings_kwargs(root_urlconf="stapel_video.codegen_urls", contract=True)
38
+ )
39
+
40
+ import django
41
+
42
+ django.setup()
43
+
44
+ from drf_spectacular.settings import spectacular_settings
45
+
46
+ from stapel_video._codegen_settings import CODEGEN_SCHEMA_PATH_PREFIX
47
+
48
+ spectacular_settings.SCHEMA_PATH_PREFIX = CODEGEN_SCHEMA_PATH_PREFIX
49
+
50
+ # A real multi-module host registers drf-spectacular's JWT cookie-auth
51
+ # extension as a side effect of wiring its dev-only Swagger URLs — a global
52
+ # registration, not tied to any one module's urls.py. video mounts alone,
53
+ # so without registering it explicitly its protected endpoints would emit
54
+ # without their `security: [{"JWTCookieAuth": []}]` entry (the webhook is
55
+ # deliberately unauthenticated and carries none). Applied per the calendar
56
+ # precedent.
57
+ from stapel_core.django.openapi.swagger import _register_jwt_auth_extension
58
+
59
+ _register_jwt_auth_extension()
60
+
61
+
62
+ def _require_python_312() -> None:
63
+ """Abort emission if not running the pinned 3.12 interpreter.
64
+
65
+ drf-spectacular's rendering of component descriptions (``Optional[X]`` vs
66
+ ``X | None``) depends on the Python minor version — contracts emitted on
67
+ anything other than 3.12 (the CI/monolith pin) produce false diffs against
68
+ the committed docs/*.json.
69
+ """
70
+ if sys.version_info[:2] != (3, 12):
71
+ got = f"{sys.version_info.major}.{sys.version_info.minor}"
72
+ raise SystemExit(
73
+ f"stapel-video contract emission ABORTED: running Python {got}, but "
74
+ "contracts must be emitted on Python 3.12 (the CI/monolith pin). "
75
+ "drf-spectacular renders component descriptions (Optional[X] vs "
76
+ "X | None) differently across Python minor versions, so emitting on "
77
+ "any other minor produces false diffs against the committed "
78
+ "docs/*.json. Re-run under a 3.12 interpreter."
79
+ )
80
+
81
+
82
+ def main(argv: list[str] | None = None) -> int:
83
+ _require_python_312()
84
+
85
+ parser = argparse.ArgumentParser(
86
+ prog="stapel-video-contract",
87
+ description="Emit this module's contract triad (schema.json + flows.json "
88
+ "+ errors.json) into --out, canonical /video/api/ prefix.",
89
+ )
90
+ parser.add_argument(
91
+ "--out",
92
+ default="docs",
93
+ help="Output directory for the triad (default: docs).",
94
+ )
95
+ args = parser.parse_args(argv)
96
+
97
+ _configure()
98
+
99
+ from stapel_tools.codegen import emit_errors, emit_flows, emit_schema
100
+
101
+ out = Path(args.out)
102
+ out.mkdir(parents=True, exist_ok=True)
103
+ paths = emit_schema(out / "schema.json")
104
+ flows = emit_flows(out / "flows.json")
105
+ errors = emit_errors(out / "errors.json")
106
+
107
+ print(
108
+ f"stapel-video contract: {paths} paths, {flows} flows, {errors} error keys "
109
+ f"→ {out}/",
110
+ file=sys.stderr,
111
+ )
112
+ return 0
113
+
114
+
115
+ if __name__ == "__main__":
116
+ raise SystemExit(main())
@@ -0,0 +1,114 @@
1
+ """Single-module Django settings for stapel-video's harnesses.
2
+
3
+ Single source of truth for the ``settings.configure(...)`` block shared by:
4
+
5
+ - the pytest suite (``conftest.py``) — mounts video on its *bare* test
6
+ urlconf (``stapel_video.tests.urls`` -> ``video/`` -> the module's own
7
+ ``api/rooms`` etc.); and
8
+ - the contract-emission harness (``_codegen.py`` / ``make contract``) —
9
+ mounts video on its *canonical* public API prefix
10
+ (``stapel_video.codegen_urls`` -> ``video/`` -> same ``api/*`` paths the
11
+ module's own ``urls.py`` declares) and enables drf-spectacular, so the
12
+ emitted ``schema.json`` / ``flows.json`` paths are byte-identical to what a
13
+ host mounting this module would serve (contract-pipeline.md §2).
14
+
15
+ Keeping one copy here means the harness and the tests can never drift in their
16
+ ``INSTALLED_APPS`` / mock config. Copied from stapel-calendar's etalon and
17
+ tailored to this module (no gdpr sibling; adds the in-process comm bus + schema
18
+ validation the suite needs).
19
+ """
20
+ from __future__ import annotations
21
+
22
+
23
+ def settings_kwargs(
24
+ *,
25
+ root_urlconf: str = "stapel_video.tests.urls",
26
+ contract: bool = False,
27
+ ) -> dict:
28
+ """Return the ``settings.configure(**kwargs)`` for a single-module video
29
+ instance. ``contract=True`` swaps in the production ``REST_FRAMEWORK`` (the
30
+ canonical stapel-core config, inlined) so the emitted schema carries the
31
+ real permission/renderer/schema classes — kept in lockstep by
32
+ test_contract.py's identity gate."""
33
+ if contract:
34
+ rest_framework = {
35
+ "DEFAULT_AUTHENTICATION_CLASSES": [
36
+ "stapel_core.django.jwt.authentication.JWTCookieAuthentication",
37
+ ],
38
+ "DEFAULT_PERMISSION_CLASSES": [
39
+ "stapel_core.django.api.permissions.IsServiceRequest",
40
+ "stapel_core.django.api.permissions.IsSuperUser",
41
+ ],
42
+ "DEFAULT_RENDERER_CLASSES": [
43
+ "rest_framework.renderers.JSONRenderer",
44
+ "rest_framework.renderers.BrowsableAPIRenderer",
45
+ ],
46
+ "DEFAULT_SCHEMA_CLASS": "stapel_core.django.openapi.schemas.PermissionAwareAutoSchema",
47
+ "EXCEPTION_HANDLER": "stapel_core.django.api.errors.stapel_exception_handler",
48
+ }
49
+ else:
50
+ rest_framework = None
51
+
52
+ kwargs = dict(
53
+ SECRET_KEY="test-secret-key-not-for-production",
54
+ INSTALLED_APPS=[
55
+ "django.contrib.contenttypes",
56
+ "django.contrib.auth",
57
+ "django.contrib.sessions",
58
+ "django.contrib.admin",
59
+ "django.contrib.messages",
60
+ # CommonDjangoConfig ships the stapel_core management commands
61
+ # (generate_error_keys, used by the errors.json drift gate).
62
+ "stapel_core.django.apps.CommonDjangoConfig",
63
+ "stapel_core.django.users",
64
+ "rest_framework",
65
+ "drf_spectacular",
66
+ "stapel_video",
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
+ },
91
+ # In-memory channel layer for the realtime lobby consumer tests.
92
+ CHANNEL_LAYERS={
93
+ "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}
94
+ },
95
+ # Test video provider (no network) — overridden per-test where needed.
96
+ STAPEL_VIDEO={
97
+ "VIDEO_PROVIDER": "stapel_video.tests.fakeprovider.FakeProvider",
98
+ },
99
+ # Skip migrations — create tables directly from models.
100
+ MIGRATION_MODULES={
101
+ "users": None,
102
+ "video": None,
103
+ },
104
+ )
105
+ if rest_framework is not None:
106
+ kwargs["REST_FRAMEWORK"] = rest_framework
107
+ return kwargs
108
+
109
+
110
+ # The multi-module common path prefix drf-spectacular auto-detects in a
111
+ # multi-module aggregate. Forced on the drf-spectacular settings singleton by
112
+ # the harness so a single-module instance derives the same style of
113
+ # operationIds. Uniform across all pair-backends.
114
+ CODEGEN_SCHEMA_PATH_PREFIX = "/"
@@ -0,0 +1,25 @@
1
+ """Action subscriptions of stapel-video.
2
+
3
+ Handlers must be idempotent: delivery is at-least-once (outbox retries, broker
4
+ redelivery). Consumes contracts live in ``schemas/consumes/``.
5
+ """
6
+ import logging
7
+
8
+ from stapel_core.comm import on_action
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @on_action("user.deleted")
14
+ def handle_user_deleted(event):
15
+ """Erase this module's PII when an account deletion is executed: rooms the
16
+ user created (and their participants) and the user's participations in
17
+ other rooms."""
18
+ from .gdpr import VideoGDPRProvider
19
+
20
+ user_id = event.payload.get("user_id")
21
+ if not user_id:
22
+ logger.error("user.deleted event without user_id: %s", event.event_id)
23
+ return
24
+ VideoGDPRProvider().delete(user_id)
25
+ logger.info("video data erased for deleted user %s", user_id)
@@ -0,0 +1,25 @@
1
+ """Django admin for stapel-video.
2
+
3
+ Both models are ``business`` (visible, staff-manageable) by the admin-suite
4
+ default (admin-suite AS-5); neither is ops-machinery nor a secret carrier, so
5
+ both stay undecorated. ``provider_room_ref`` is an opaque provider room name,
6
+ not a credential.
7
+ """
8
+ from django.contrib import admin
9
+
10
+ from .models import Room, RoomParticipant
11
+
12
+
13
+ @admin.register(Room)
14
+ class RoomAdmin(admin.ModelAdmin):
15
+ list_display = ("join_code", "access_level", "admit_required", "created_by", "created_at")
16
+ list_filter = ("access_level", "admit_required")
17
+ search_fields = ("join_code", "scope_key", "provider_room_ref")
18
+ raw_id_fields = ("created_by",)
19
+
20
+
21
+ @admin.register(RoomParticipant)
22
+ class RoomParticipantAdmin(admin.ModelAdmin):
23
+ list_display = ("room", "user", "status", "role", "joined_at")
24
+ list_filter = ("status", "role")
25
+ raw_id_fields = ("room", "user")
@@ -0,0 +1,23 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class VideoConfig(AppConfig):
5
+ name = "stapel_video"
6
+ label = "video"
7
+ verbose_name = "Video calls"
8
+ default_auto_field = "django.db.models.BigAutoField"
9
+
10
+ def ready(self):
11
+ # Import-time side effects: comm actions, system checks, error-key
12
+ # registration. Keep each in its own module.
13
+ from . import actions # noqa: F401
14
+ from . import checks # noqa: F401
15
+ from . import errors # noqa: F401
16
+
17
+ # GDPR: register the per-app data handler (monolith in-process mode).
18
+ from stapel_core.gdpr import gdpr_registry
19
+
20
+ from .gdpr import VideoGDPRProvider
21
+
22
+ if not any(p.section == "video" for p in gdpr_registry.providers):
23
+ gdpr_registry.register(VideoGDPRProvider())
@@ -0,0 +1,79 @@
1
+ """Django system checks for stapel-video configuration.
2
+
3
+ Policy (docs/library-standard.md §3.7): E-level for configuration the service
4
+ cannot run with; W-level for entries that only degrade lazily.
5
+
6
+ - VIDEO_PROVIDER unimportable / not a VideoProvider -> E (no room can mint a
7
+ join token — the module cannot serve calls).
8
+ - SCOPE_PROVIDER unimportable / not a ScopeProvider -> E (create & the
9
+ scope_trusted join decision cannot resolve scope/membership).
10
+ - DEFAULT_ACCESS_LEVEL not a valid access level -> E (every default-level room
11
+ create would produce an unjoinable room).
12
+ """
13
+ from django.core import checks
14
+
15
+
16
+ @checks.register(checks.Tags.compatibility)
17
+ def check_video_provider(app_configs, **kwargs):
18
+ from .conf import video_settings
19
+ from .providers import VideoProvider
20
+
21
+ try:
22
+ provider = video_settings.VIDEO_PROVIDER
23
+ except Exception as exc:
24
+ return [
25
+ checks.Error(
26
+ f"STAPEL_VIDEO['VIDEO_PROVIDER'] could not be imported: {exc}",
27
+ id="stapel_video.E001",
28
+ )
29
+ ]
30
+ target = provider if isinstance(provider, type) else type(provider)
31
+ if not issubclass(target, VideoProvider):
32
+ return [
33
+ checks.Error(
34
+ "STAPEL_VIDEO['VIDEO_PROVIDER'] must be a VideoProvider subclass",
35
+ id="stapel_video.E002",
36
+ )
37
+ ]
38
+ return []
39
+
40
+
41
+ @checks.register(checks.Tags.compatibility)
42
+ def check_scope_provider(app_configs, **kwargs):
43
+ from .conf import video_settings
44
+ from .scope import ScopeProvider
45
+
46
+ try:
47
+ provider = video_settings.SCOPE_PROVIDER
48
+ except Exception as exc:
49
+ return [
50
+ checks.Error(
51
+ f"STAPEL_VIDEO['SCOPE_PROVIDER'] could not be imported: {exc}",
52
+ id="stapel_video.E003",
53
+ )
54
+ ]
55
+ target = provider if isinstance(provider, type) else type(provider)
56
+ if not issubclass(target, ScopeProvider):
57
+ return [
58
+ checks.Error(
59
+ "STAPEL_VIDEO['SCOPE_PROVIDER'] must be a ScopeProvider subclass",
60
+ id="stapel_video.E004",
61
+ )
62
+ ]
63
+ return []
64
+
65
+
66
+ @checks.register(checks.Tags.compatibility)
67
+ def check_default_access_level(app_configs, **kwargs):
68
+ from .conf import video_settings
69
+ from .models import AccessLevel
70
+
71
+ if video_settings.DEFAULT_ACCESS_LEVEL not in AccessLevel.values:
72
+ return [
73
+ checks.Error(
74
+ "STAPEL_VIDEO['DEFAULT_ACCESS_LEVEL'] must be one of "
75
+ f"{list(AccessLevel.values)}.",
76
+ id="stapel_video.E005",
77
+ )
78
+ ]
79
+ return []
@@ -0,0 +1,18 @@
1
+ """Canonical-prefix URLconf for contract emission (contract-pipeline.md §2).
2
+
3
+ stapel-video's own ``urls.py`` already bakes ``api/`` into every path
4
+ (``api/rooms``, ``api/webhook``, ...) and documents its expected host mount::
5
+
6
+ path("video/", include("stapel_video.urls"))
7
+
8
+ That is the canonical public API prefix (``video/api/...``) — the same
9
+ ``<mod>/api/`` shape every other pair-backend uses. This harness urlconf
10
+ reproduces exactly that documented mount, so drf-spectacular emits
11
+ ``/video/api/...`` paths. video is validated standalone (no monolith slice
12
+ exists yet to diff against; contract-pipeline.md §9 fallback path applies).
13
+ """
14
+ from django.urls import include, path
15
+
16
+ urlpatterns = [
17
+ path("video/", include("stapel_video.urls")),
18
+ ]