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.
- stapel_video-0.1.0/LICENSE +21 -0
- stapel_video-0.1.0/PKG-INFO +106 -0
- stapel_video-0.1.0/README.md +70 -0
- stapel_video-0.1.0/__init__.py +12 -0
- stapel_video-0.1.0/_capabilities.py +38 -0
- stapel_video-0.1.0/_codegen.py +116 -0
- stapel_video-0.1.0/_codegen_settings.py +114 -0
- stapel_video-0.1.0/actions.py +25 -0
- stapel_video-0.1.0/admin.py +25 -0
- stapel_video-0.1.0/apps.py +23 -0
- stapel_video-0.1.0/checks.py +79 -0
- stapel_video-0.1.0/codegen_urls.py +18 -0
- stapel_video-0.1.0/conf.py +77 -0
- stapel_video-0.1.0/conftest.py +66 -0
- stapel_video-0.1.0/consumers.py +81 -0
- stapel_video-0.1.0/dto.py +126 -0
- stapel_video-0.1.0/errors.py +34 -0
- stapel_video-0.1.0/gdpr.py +54 -0
- stapel_video-0.1.0/migrations/0001_initial.py +71 -0
- stapel_video-0.1.0/migrations/__init__.py +0 -0
- stapel_video-0.1.0/models.py +142 -0
- stapel_video-0.1.0/providers/__init__.py +22 -0
- stapel_video-0.1.0/providers/base.py +62 -0
- stapel_video-0.1.0/providers/livekit.py +198 -0
- stapel_video-0.1.0/py.typed +0 -0
- stapel_video-0.1.0/pyproject.toml +75 -0
- stapel_video-0.1.0/realtime.py +27 -0
- stapel_video-0.1.0/routing.py +20 -0
- stapel_video-0.1.0/schemas/consumes/user.deleted.json +14 -0
- stapel_video-0.1.0/schemas/emits/video.egress_ended.json +13 -0
- stapel_video-0.1.0/scope.py +56 -0
- stapel_video-0.1.0/serializers.py +51 -0
- stapel_video-0.1.0/services.py +288 -0
- stapel_video-0.1.0/setup.cfg +4 -0
- stapel_video-0.1.0/stapel_video.egg-info/PKG-INFO +106 -0
- stapel_video-0.1.0/stapel_video.egg-info/SOURCES.txt +79 -0
- stapel_video-0.1.0/stapel_video.egg-info/dependency_links.txt +1 -0
- stapel_video-0.1.0/stapel_video.egg-info/requires.txt +13 -0
- stapel_video-0.1.0/stapel_video.egg-info/top_level.txt +1 -0
- stapel_video-0.1.0/tests/test_checks.py +31 -0
- stapel_video-0.1.0/tests/test_consumer.py +95 -0
- stapel_video-0.1.0/tests/test_contract.py +172 -0
- stapel_video-0.1.0/tests/test_gdpr.py +45 -0
- stapel_video-0.1.0/tests/test_livekit_provider.py +174 -0
- stapel_video-0.1.0/tests/test_lobby.py +71 -0
- stapel_video-0.1.0/tests/test_pagination.py +77 -0
- stapel_video-0.1.0/tests/test_rooms.py +124 -0
- stapel_video-0.1.0/tests/test_webhook.py +63 -0
- stapel_video-0.1.0/urls.py +69 -0
- 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
|
+
]
|