stapel-chat 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_chat-0.1.0/LICENSE +21 -0
- stapel_chat-0.1.0/PKG-INFO +93 -0
- stapel_chat-0.1.0/README.md +62 -0
- stapel_chat-0.1.0/__init__.py +11 -0
- stapel_chat-0.1.0/_capabilities.py +41 -0
- stapel_chat-0.1.0/_codegen.py +117 -0
- stapel_chat-0.1.0/_codegen_settings.py +106 -0
- stapel_chat-0.1.0/actions.py +25 -0
- stapel_chat-0.1.0/admin.py +26 -0
- stapel_chat-0.1.0/apps.py +26 -0
- stapel_chat-0.1.0/checks.py +73 -0
- stapel_chat-0.1.0/codegen_urls.py +19 -0
- stapel_chat-0.1.0/conf.py +60 -0
- stapel_chat-0.1.0/conftest.py +75 -0
- stapel_chat-0.1.0/consumers.py +232 -0
- stapel_chat-0.1.0/dto.py +125 -0
- stapel_chat-0.1.0/errors.py +52 -0
- stapel_chat-0.1.0/functions.py +20 -0
- stapel_chat-0.1.0/gdpr.py +74 -0
- stapel_chat-0.1.0/migrations/0001_initial.py +92 -0
- stapel_chat-0.1.0/migrations/__init__.py +0 -0
- stapel_chat-0.1.0/models.py +252 -0
- stapel_chat-0.1.0/py.typed +0 -0
- stapel_chat-0.1.0/pyproject.toml +66 -0
- stapel_chat-0.1.0/realtime.py +62 -0
- stapel_chat-0.1.0/schemas/consumes/user.deleted.json +14 -0
- stapel_chat-0.1.0/schemas/emits/chat.message.json +21 -0
- stapel_chat-0.1.0/schemas/emits/chat.support.assigned.json +13 -0
- stapel_chat-0.1.0/scope.py +42 -0
- stapel_chat-0.1.0/serializers.py +56 -0
- stapel_chat-0.1.0/services.py +317 -0
- stapel_chat-0.1.0/setup.cfg +4 -0
- stapel_chat-0.1.0/stapel_chat.egg-info/PKG-INFO +93 -0
- stapel_chat-0.1.0/stapel_chat.egg-info/SOURCES.txt +74 -0
- stapel_chat-0.1.0/stapel_chat.egg-info/dependency_links.txt +1 -0
- stapel_chat-0.1.0/stapel_chat.egg-info/requires.txt +7 -0
- stapel_chat-0.1.0/stapel_chat.egg-info/top_level.txt +1 -0
- stapel_chat-0.1.0/tests/test_checks.py +38 -0
- stapel_chat-0.1.0/tests/test_contract.py +164 -0
- stapel_chat-0.1.0/tests/test_conversations.py +101 -0
- stapel_chat-0.1.0/tests/test_messages.py +146 -0
- stapel_chat-0.1.0/tests/test_read_unread.py +64 -0
- stapel_chat-0.1.0/tests/test_realtime.py +134 -0
- stapel_chat-0.1.0/tests/test_seq_and_outbox.py +78 -0
- stapel_chat-0.1.0/tests/test_support.py +112 -0
- stapel_chat-0.1.0/urls.py +84 -0
- stapel_chat-0.1.0/views.py +459 -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,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stapel-chat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Conversations, messaging and support chat for the Stapel framework
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/usestapel/stapel-chat
|
|
7
|
+
Project-URL: Repository, https://github.com/usestapel/stapel-chat
|
|
8
|
+
Project-URL: Documentation, https://github.com/usestapel/stapel-chat#readme
|
|
9
|
+
Project-URL: Changelog, https://github.com/usestapel/stapel-chat/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/usestapel/stapel-chat/issues
|
|
11
|
+
Keywords: django,stapel,chat,messaging,support
|
|
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: channels
|
|
27
|
+
Requires-Dist: stapel-core[channels]<0.11,>=0.10; extra == "channels"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: stapel-core[channels]<0.11,>=0.10; extra == "all"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# stapel-chat
|
|
33
|
+
|
|
34
|
+
Conversations, messaging and support chat for the [Stapel](https://github.com/usestapel)
|
|
35
|
+
framework — a reusable Django app you mount in a host project.
|
|
36
|
+
|
|
37
|
+
One model backs three kinds of thread: **direct** (1:1, idempotent by
|
|
38
|
+
participant pair), **group**, and **support** (a customer↔operator thread with a
|
|
39
|
+
queue and assignment lifecycle). Messages carry a monotonic per-conversation
|
|
40
|
+
**seq** that is the canonical anchor for history pagination and the resume
|
|
41
|
+
cursor for realtime — so nothing depends on a socket staying up.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
INSTALLED_APPS = [
|
|
45
|
+
# ...
|
|
46
|
+
"stapel_core.django.apps.CommonDjangoConfig",
|
|
47
|
+
"stapel_core.django.users",
|
|
48
|
+
"rest_framework",
|
|
49
|
+
"stapel_chat",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# urls.py
|
|
53
|
+
urlpatterns = [
|
|
54
|
+
path("chat/", include("stapel_chat.urls")),
|
|
55
|
+
]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What you get
|
|
59
|
+
|
|
60
|
+
- **Conversations** — `POST /chat/api/conversations` (`direct` / `group` /
|
|
61
|
+
`support`); direct is get-or-create by participant pair. `GET` lists your
|
|
62
|
+
conversations (anchor-paginated) with `unread_count`.
|
|
63
|
+
- **Messages** — `GET/POST /chat/api/conversations/{id}/messages`. History is
|
|
64
|
+
anchored on `seq` (newest-first, both directions). Sending allocates the next
|
|
65
|
+
`seq` and emits `chat.message` in one transaction.
|
|
66
|
+
- **Read markers** — `POST /chat/api/conversations/{id}/read` (`upto_seq`).
|
|
67
|
+
- **Support** — `GET /chat/api/support/queue`,
|
|
68
|
+
`POST .../support/conversations/{id}/{assign,resolve,reopen}`.
|
|
69
|
+
- **Realtime (optional)** — `stapel_chat.consumers.ChatConsumer` over Channels:
|
|
70
|
+
`hello{last_seq}` → replay by seq → live delivery. Install the extra:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
pip install 'stapel-chat[channels]'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
and wire it behind `stapel_core.django.jwt.channels.JWTAuthMiddlewareStack` in
|
|
77
|
+
your `asgi.py`.
|
|
78
|
+
|
|
79
|
+
## Configuration (`STAPEL_CHAT`)
|
|
80
|
+
|
|
81
|
+
| Key | Default | Meaning |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `CHAT_KINDS` | `["direct","group","support"]` | Enabled thread kinds (drop `support` to disable the operator queue) |
|
|
84
|
+
| `ATTACHMENTS` | `True` | Allow opaque attachment keys on messages |
|
|
85
|
+
| `MAX_BODY_LENGTH` | `4000` | Hard cap on a text body |
|
|
86
|
+
| `SCOPE_PROVIDER` | `stapel_chat.scope.DefaultScopeProvider` | Resolve/enforce the opaque `scope_key` (e.g. per workspace) |
|
|
87
|
+
|
|
88
|
+
See [MODULE.md](MODULE.md) for the extension seams, comm surface and
|
|
89
|
+
anti-patterns.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# stapel-chat
|
|
2
|
+
|
|
3
|
+
Conversations, messaging and support chat for the [Stapel](https://github.com/usestapel)
|
|
4
|
+
framework — a reusable Django app you mount in a host project.
|
|
5
|
+
|
|
6
|
+
One model backs three kinds of thread: **direct** (1:1, idempotent by
|
|
7
|
+
participant pair), **group**, and **support** (a customer↔operator thread with a
|
|
8
|
+
queue and assignment lifecycle). Messages carry a monotonic per-conversation
|
|
9
|
+
**seq** that is the canonical anchor for history pagination and the resume
|
|
10
|
+
cursor for realtime — so nothing depends on a socket staying up.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
INSTALLED_APPS = [
|
|
14
|
+
# ...
|
|
15
|
+
"stapel_core.django.apps.CommonDjangoConfig",
|
|
16
|
+
"stapel_core.django.users",
|
|
17
|
+
"rest_framework",
|
|
18
|
+
"stapel_chat",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# urls.py
|
|
22
|
+
urlpatterns = [
|
|
23
|
+
path("chat/", include("stapel_chat.urls")),
|
|
24
|
+
]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## What you get
|
|
28
|
+
|
|
29
|
+
- **Conversations** — `POST /chat/api/conversations` (`direct` / `group` /
|
|
30
|
+
`support`); direct is get-or-create by participant pair. `GET` lists your
|
|
31
|
+
conversations (anchor-paginated) with `unread_count`.
|
|
32
|
+
- **Messages** — `GET/POST /chat/api/conversations/{id}/messages`. History is
|
|
33
|
+
anchored on `seq` (newest-first, both directions). Sending allocates the next
|
|
34
|
+
`seq` and emits `chat.message` in one transaction.
|
|
35
|
+
- **Read markers** — `POST /chat/api/conversations/{id}/read` (`upto_seq`).
|
|
36
|
+
- **Support** — `GET /chat/api/support/queue`,
|
|
37
|
+
`POST .../support/conversations/{id}/{assign,resolve,reopen}`.
|
|
38
|
+
- **Realtime (optional)** — `stapel_chat.consumers.ChatConsumer` over Channels:
|
|
39
|
+
`hello{last_seq}` → replay by seq → live delivery. Install the extra:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pip install 'stapel-chat[channels]'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
and wire it behind `stapel_core.django.jwt.channels.JWTAuthMiddlewareStack` in
|
|
46
|
+
your `asgi.py`.
|
|
47
|
+
|
|
48
|
+
## Configuration (`STAPEL_CHAT`)
|
|
49
|
+
|
|
50
|
+
| Key | Default | Meaning |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `CHAT_KINDS` | `["direct","group","support"]` | Enabled thread kinds (drop `support` to disable the operator queue) |
|
|
53
|
+
| `ATTACHMENTS` | `True` | Allow opaque attachment keys on messages |
|
|
54
|
+
| `MAX_BODY_LENGTH` | `4000` | Hard cap on a text body |
|
|
55
|
+
| `SCOPE_PROVIDER` | `stapel_chat.scope.DefaultScopeProvider` | Resolve/enforce the opaque `scope_key` (e.g. per workspace) |
|
|
56
|
+
|
|
57
|
+
See [MODULE.md](MODULE.md) for the extension seams, comm surface and
|
|
58
|
+
anti-patterns.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""stapel-chat — conversations, messaging and support chat for Stapel.
|
|
2
|
+
|
|
3
|
+
A generic messaging core: ``Conversation`` (direct / group / support),
|
|
4
|
+
``Message`` (monotonic per-conversation ``seq``, text or system), per-participant
|
|
5
|
+
read markers, an idempotent direct-conversation primitive, anchor-paginated
|
|
6
|
+
history and conversation lists, and a support layer (queue → assign → resolve →
|
|
7
|
+
reopen) built on the same model. Realtime delivery is an optional Channels
|
|
8
|
+
consumer; correctness never depends on it (clients replay by ``seq``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
default_app_config = "stapel_chat.apps.ChatConfig"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""stapel-chat 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_chat._codegen import _configure
|
|
9
|
+
|
|
10
|
+
_configure()
|
|
11
|
+
from stapel_chat.conf import DEFAULTS
|
|
12
|
+
from stapel_chat.urls import GATE_REGISTRY
|
|
13
|
+
|
|
14
|
+
# Three CTO-facing config axes (capability-config.md §16), all behavioral
|
|
15
|
+
# (they widen/narrow what endpoints accept, they do not unmount any URL):
|
|
16
|
+
# CHAT_KINDS (list) — which conversation kinds are offered
|
|
17
|
+
# ATTACHMENTS (bool) — whether messages may carry attachment keys
|
|
18
|
+
# MAX_BODY_LENGTH (int→enum kind) — hard cap on a text body
|
|
19
|
+
# SCOPE_PROVIDER is the one extension seam (curated in
|
|
20
|
+
# docs/capabilities.meta.json), not an axis.
|
|
21
|
+
axes = {"CHAT_KINDS", "ATTACHMENTS", "MAX_BODY_LENGTH"}
|
|
22
|
+
return run_capabilities_cli(
|
|
23
|
+
argv,
|
|
24
|
+
repo=Path(__file__).resolve().parent,
|
|
25
|
+
canonical_prefix="/chat",
|
|
26
|
+
defaults=DEFAULTS,
|
|
27
|
+
registry=GATE_REGISTRY,
|
|
28
|
+
is_axis=lambda k: k in axes,
|
|
29
|
+
axis_group=axis_group_rules(
|
|
30
|
+
exact={
|
|
31
|
+
"CHAT_KINDS": "chat.kinds",
|
|
32
|
+
"ATTACHMENTS": "chat.attachments",
|
|
33
|
+
"MAX_BODY_LENGTH": "chat.limits",
|
|
34
|
+
}
|
|
35
|
+
),
|
|
36
|
+
prog="stapel-chat-capabilities",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""stapel-chat contract-emission harness (contract-pipeline.md §2-3).
|
|
2
|
+
|
|
3
|
+
Emits the module's own contract triad into ``docs/`` from a single-module
|
|
4
|
+
``{chat + core}`` Django instance mounted at the canonical ``chat/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 adaptation of the stapel-auth reference
|
|
12
|
+
implementation; the *mechanism* is stapel_tools.codegen (shared), this file is
|
|
13
|
+
the thin per-module *config*.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python -m stapel_chat._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_chat._codegen_settings import settings_kwargs
|
|
35
|
+
|
|
36
|
+
settings.configure(
|
|
37
|
+
**settings_kwargs(root_urlconf="stapel_chat.codegen_urls", contract=True)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
import django
|
|
41
|
+
|
|
42
|
+
django.setup()
|
|
43
|
+
|
|
44
|
+
# Pin SCHEMA_PATH_PREFIX to the aggregate-style common prefix so operationIds
|
|
45
|
+
# match the convention every other pair-backend uses (see calendar's etalon
|
|
46
|
+
# note); the path keys keep /chat/api/ on both sides.
|
|
47
|
+
from drf_spectacular.settings import spectacular_settings
|
|
48
|
+
|
|
49
|
+
from stapel_chat._codegen_settings import CODEGEN_SCHEMA_PATH_PREFIX
|
|
50
|
+
|
|
51
|
+
spectacular_settings.SCHEMA_PATH_PREFIX = CODEGEN_SCHEMA_PATH_PREFIX
|
|
52
|
+
|
|
53
|
+
# Register drf-spectacular's JWT cookie-auth extension explicitly — this
|
|
54
|
+
# module mounts alone (no sibling triggers it), and every chat endpoint
|
|
55
|
+
# requires IsAuthenticated, so without it the schema would emit without the
|
|
56
|
+
# `security: [{"JWTCookieAuth": []}]` entry (a real contract gap). Same
|
|
57
|
+
# precedent as stapel-calendar / stapel-profiles.
|
|
58
|
+
from stapel_core.django.openapi.swagger import _register_jwt_auth_extension
|
|
59
|
+
|
|
60
|
+
_register_jwt_auth_extension()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _require_python_312() -> None:
|
|
64
|
+
"""Abort emission if not running the pinned 3.12 interpreter.
|
|
65
|
+
|
|
66
|
+
drf-spectacular's rendering of component descriptions (``Optional[X]`` vs
|
|
67
|
+
``X | None``) depends on the Python minor version — contracts emitted on
|
|
68
|
+
anything other than 3.12 produce false diffs against the committed
|
|
69
|
+
docs/*.json.
|
|
70
|
+
"""
|
|
71
|
+
if sys.version_info[:2] != (3, 12):
|
|
72
|
+
got = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
73
|
+
raise SystemExit(
|
|
74
|
+
f"stapel-chat contract emission ABORTED: running Python {got}, but "
|
|
75
|
+
"contracts must be emitted on Python 3.12 (the CI/monolith pin). "
|
|
76
|
+
"drf-spectacular renders component descriptions (Optional[X] vs "
|
|
77
|
+
"X | None) differently across Python minor versions, so emitting on "
|
|
78
|
+
"any other minor produces false diffs against the committed "
|
|
79
|
+
"docs/*.json. Re-run under a 3.12 interpreter."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: list[str] | None = None) -> int:
|
|
84
|
+
_require_python_312()
|
|
85
|
+
|
|
86
|
+
parser = argparse.ArgumentParser(
|
|
87
|
+
prog="stapel-chat-contract",
|
|
88
|
+
description="Emit this module's contract triad (schema.json + flows.json "
|
|
89
|
+
"+ errors.json) into --out, canonical /chat/api/ prefix.",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--out",
|
|
93
|
+
default="docs",
|
|
94
|
+
help="Output directory for the triad (default: docs).",
|
|
95
|
+
)
|
|
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-chat 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,106 @@
|
|
|
1
|
+
"""Single-module Django settings for stapel-chat's harnesses.
|
|
2
|
+
|
|
3
|
+
Single source of truth for the ``settings.configure(...)`` block shared by:
|
|
4
|
+
|
|
5
|
+
- the pytest suite (``conftest.py``) — mounts chat on its *bare* test urlconf
|
|
6
|
+
(``stapel_chat.tests.urls`` -> ``chat/`` -> the module's own ``api/*``); and
|
|
7
|
+
- the contract-emission harness (``_codegen.py`` / ``make contract``) — mounts
|
|
8
|
+
chat on its *canonical* public API prefix (``stapel_chat.codegen_urls`` ->
|
|
9
|
+
``chat/`` -> the same ``api/*`` paths) and enables drf-spectacular, so the
|
|
10
|
+
emitted ``schema.json`` / ``flows.json`` paths are byte-identical to what a
|
|
11
|
+
host mounting this module would serve (contract-pipeline.md §2).
|
|
12
|
+
|
|
13
|
+
Keeping one copy here means the harness and the tests can never drift in their
|
|
14
|
+
``INSTALLED_APPS`` / comm config. Copied from stapel-calendar's adaptation of
|
|
15
|
+
the stapel-auth etalon; tailored to this module (no gdpr/social_django/JWT/
|
|
16
|
+
Twilio — chat carries none of that, but it needs the in-process comm bus +
|
|
17
|
+
schema validation the tests rely on).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def settings_kwargs(
|
|
23
|
+
*,
|
|
24
|
+
root_urlconf: str = "stapel_chat.tests.urls",
|
|
25
|
+
contract: bool = False,
|
|
26
|
+
) -> dict:
|
|
27
|
+
"""Return the ``settings.configure(**kwargs)`` for a single-module chat
|
|
28
|
+
instance. ``contract=True`` swaps in the production ``REST_FRAMEWORK`` (the
|
|
29
|
+
canonical stapel-core config) so the emitted schema is byte-identical to a
|
|
30
|
+
real host's."""
|
|
31
|
+
if contract:
|
|
32
|
+
rest_framework = {
|
|
33
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
34
|
+
"stapel_core.django.jwt.authentication.JWTCookieAuthentication",
|
|
35
|
+
],
|
|
36
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
37
|
+
"stapel_core.django.api.permissions.IsServiceRequest",
|
|
38
|
+
"stapel_core.django.api.permissions.IsSuperUser",
|
|
39
|
+
],
|
|
40
|
+
"DEFAULT_RENDERER_CLASSES": [
|
|
41
|
+
"rest_framework.renderers.JSONRenderer",
|
|
42
|
+
"rest_framework.renderers.BrowsableAPIRenderer",
|
|
43
|
+
],
|
|
44
|
+
"DEFAULT_SCHEMA_CLASS": "stapel_core.django.openapi.schemas.PermissionAwareAutoSchema",
|
|
45
|
+
"EXCEPTION_HANDLER": "stapel_core.django.api.errors.stapel_exception_handler",
|
|
46
|
+
}
|
|
47
|
+
else:
|
|
48
|
+
rest_framework = None
|
|
49
|
+
|
|
50
|
+
kwargs = dict(
|
|
51
|
+
SECRET_KEY="test-secret-key-not-for-production",
|
|
52
|
+
INSTALLED_APPS=[
|
|
53
|
+
"django.contrib.contenttypes",
|
|
54
|
+
"django.contrib.auth",
|
|
55
|
+
"django.contrib.sessions",
|
|
56
|
+
"django.contrib.admin",
|
|
57
|
+
"django.contrib.messages",
|
|
58
|
+
"stapel_core.django.apps.CommonDjangoConfig",
|
|
59
|
+
"stapel_core.django.users",
|
|
60
|
+
"rest_framework",
|
|
61
|
+
"drf_spectacular",
|
|
62
|
+
"stapel_chat",
|
|
63
|
+
],
|
|
64
|
+
AUTH_USER_MODEL="users.User",
|
|
65
|
+
DATABASES={
|
|
66
|
+
"default": {
|
|
67
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
68
|
+
"NAME": ":memory:",
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
|
|
72
|
+
USE_TZ=True,
|
|
73
|
+
ROOT_URLCONF=root_urlconf,
|
|
74
|
+
CACHES={
|
|
75
|
+
"default": {
|
|
76
|
+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
# Synchronous in-process comm with schema validation ON, so the
|
|
80
|
+
# committed contracts in schemas/ are enforced by the tests.
|
|
81
|
+
STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
|
|
82
|
+
STAPEL_COMM={
|
|
83
|
+
"OUTBOX_ENABLED": False,
|
|
84
|
+
"ACTION_TRANSPORT": "inprocess",
|
|
85
|
+
"VALIDATE_SCHEMAS": True,
|
|
86
|
+
},
|
|
87
|
+
# In-memory Channels layer so the realtime consumer tests can exercise
|
|
88
|
+
# group fan-out without a broker.
|
|
89
|
+
CHANNEL_LAYERS={
|
|
90
|
+
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
|
|
91
|
+
},
|
|
92
|
+
MIGRATION_MODULES={
|
|
93
|
+
"users": None,
|
|
94
|
+
"chat": None,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
if rest_framework is not None:
|
|
98
|
+
kwargs["REST_FRAMEWORK"] = rest_framework
|
|
99
|
+
return kwargs
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# The multi-module common path prefix drf-spectacular auto-detects in a
|
|
103
|
+
# multi-module aggregate. Forced on the drf-spectacular settings singleton by
|
|
104
|
+
# the harness so a single-module instance derives the same style of
|
|
105
|
+
# operationIds. Uniform across all pair-backends.
|
|
106
|
+
CODEGEN_SCHEMA_PATH_PREFIX = "/"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Action subscriptions of stapel-chat.
|
|
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: the user's
|
|
16
|
+
authored messages, their conversation participations, and any direct
|
|
17
|
+
conversation that becomes empty as a result."""
|
|
18
|
+
from .gdpr import ChatGDPRProvider
|
|
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
|
+
ChatGDPRProvider().delete(user_id)
|
|
25
|
+
logger.info("chat data erased for deleted user %s", user_id)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Admin registrations for stapel-chat (observability; kept minimal)."""
|
|
2
|
+
from django.contrib import admin
|
|
3
|
+
|
|
4
|
+
from .models import Conversation, ConversationParticipant, Message
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ParticipantInline(admin.TabularInline):
|
|
8
|
+
model = ConversationParticipant
|
|
9
|
+
extra = 0
|
|
10
|
+
fields = ("user", "role", "last_read_seq")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@admin.register(Conversation)
|
|
14
|
+
class ConversationAdmin(admin.ModelAdmin):
|
|
15
|
+
list_display = ("id", "kind", "scope_key", "support_status", "assigned_operator", "last_seq")
|
|
16
|
+
list_filter = ("kind", "support_status")
|
|
17
|
+
search_fields = ("id", "scope_key")
|
|
18
|
+
inlines = [ParticipantInline]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@admin.register(Message)
|
|
22
|
+
class MessageAdmin(admin.ModelAdmin):
|
|
23
|
+
list_display = ("conversation", "seq", "kind", "sender", "created_at")
|
|
24
|
+
list_filter = ("kind",)
|
|
25
|
+
search_fields = ("conversation__id", "body")
|
|
26
|
+
date_hierarchy = "created_at"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ChatConfig(AppConfig):
|
|
5
|
+
name = "stapel_chat"
|
|
6
|
+
label = "chat"
|
|
7
|
+
verbose_name = "Chat and messaging"
|
|
8
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
# Import-time side effects: comm actions/functions, system checks,
|
|
12
|
+
# error-key registration. Keep each in its own module. Realtime
|
|
13
|
+
# (consumers.py) is deliberately NOT imported here — Channels is an
|
|
14
|
+
# optional extra and an HTTP-only host must not pay for it.
|
|
15
|
+
from . import actions # noqa: F401
|
|
16
|
+
from . import checks # noqa: F401
|
|
17
|
+
from . import errors # noqa: F401
|
|
18
|
+
from . import functions # noqa: F401
|
|
19
|
+
|
|
20
|
+
# GDPR: register the per-app data handler (monolith in-process mode).
|
|
21
|
+
from stapel_core.gdpr import gdpr_registry
|
|
22
|
+
|
|
23
|
+
from .gdpr import ChatGDPRProvider
|
|
24
|
+
|
|
25
|
+
if not any(p.section == "chat" for p in gdpr_registry.providers):
|
|
26
|
+
gdpr_registry.register(ChatGDPRProvider())
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Django system checks for stapel-chat 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
|
+
- SCOPE_PROVIDER unimportable / not a ScopeProvider -> E (create & list cannot
|
|
7
|
+
resolve/filter scope).
|
|
8
|
+
- CHAT_KINDS not a subset of {direct, group, support} -> E (an unknown kind
|
|
9
|
+
would be un-creatable and confuse the capability report).
|
|
10
|
+
- MAX_BODY_LENGTH not a positive int -> E (would reject or admit bodies
|
|
11
|
+
nonsensically).
|
|
12
|
+
"""
|
|
13
|
+
from django.core import checks
|
|
14
|
+
|
|
15
|
+
_VALID_KINDS = {"direct", "group", "support"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@checks.register(checks.Tags.compatibility)
|
|
19
|
+
def check_scope_provider(app_configs, **kwargs):
|
|
20
|
+
from .conf import chat_settings
|
|
21
|
+
from .scope import ScopeProvider
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
provider = chat_settings.SCOPE_PROVIDER
|
|
25
|
+
except Exception as exc:
|
|
26
|
+
return [
|
|
27
|
+
checks.Error(
|
|
28
|
+
f"STAPEL_CHAT['SCOPE_PROVIDER'] could not be imported: {exc}",
|
|
29
|
+
id="stapel_chat.E001",
|
|
30
|
+
)
|
|
31
|
+
]
|
|
32
|
+
target = provider if isinstance(provider, type) else type(provider)
|
|
33
|
+
if not issubclass(target, ScopeProvider):
|
|
34
|
+
return [
|
|
35
|
+
checks.Error(
|
|
36
|
+
"STAPEL_CHAT['SCOPE_PROVIDER'] must be a ScopeProvider subclass",
|
|
37
|
+
id="stapel_chat.E002",
|
|
38
|
+
)
|
|
39
|
+
]
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@checks.register(checks.Tags.compatibility)
|
|
44
|
+
def check_chat_kinds(app_configs, **kwargs):
|
|
45
|
+
from .conf import chat_settings
|
|
46
|
+
|
|
47
|
+
kinds = chat_settings.CHAT_KINDS
|
|
48
|
+
if not isinstance(kinds, (list, tuple)) or not kinds or (
|
|
49
|
+
set(kinds) - _VALID_KINDS
|
|
50
|
+
):
|
|
51
|
+
return [
|
|
52
|
+
checks.Error(
|
|
53
|
+
"STAPEL_CHAT['CHAT_KINDS'] must be a non-empty subset of "
|
|
54
|
+
"{direct, group, support}.",
|
|
55
|
+
id="stapel_chat.E003",
|
|
56
|
+
)
|
|
57
|
+
]
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@checks.register(checks.Tags.compatibility)
|
|
62
|
+
def check_max_body_length(app_configs, **kwargs):
|
|
63
|
+
from .conf import chat_settings
|
|
64
|
+
|
|
65
|
+
value = chat_settings.MAX_BODY_LENGTH
|
|
66
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
67
|
+
return [
|
|
68
|
+
checks.Error(
|
|
69
|
+
"STAPEL_CHAT['MAX_BODY_LENGTH'] must be a positive integer.",
|
|
70
|
+
id="stapel_chat.E004",
|
|
71
|
+
)
|
|
72
|
+
]
|
|
73
|
+
return []
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Canonical-prefix URLconf for contract emission (contract-pipeline.md §2).
|
|
2
|
+
|
|
3
|
+
stapel-chat's own ``urls.py`` already bakes ``api/`` into every path
|
|
4
|
+
(``api/conversations``, ``api/support/queue``, ...) and documents its expected
|
|
5
|
+
host mount::
|
|
6
|
+
|
|
7
|
+
path("chat/", include("stapel_chat.urls"))
|
|
8
|
+
|
|
9
|
+
This harness urlconf reproduces exactly that mount, so drf-spectacular emits
|
|
10
|
+
``/chat/api/...`` paths — the same ``<mod>/api/`` shape every pair-backend uses.
|
|
11
|
+
Chat is validated standalone (no monolith slice yet; contract-pipeline.md §9
|
|
12
|
+
fallback path applies).
|
|
13
|
+
"""
|
|
14
|
+
from django.conf.urls import include
|
|
15
|
+
from django.urls import path
|
|
16
|
+
|
|
17
|
+
urlpatterns = [
|
|
18
|
+
path("chat/", include("stapel_chat.urls")),
|
|
19
|
+
]
|