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.
Files changed (47) hide show
  1. stapel_chat-0.1.0/LICENSE +21 -0
  2. stapel_chat-0.1.0/PKG-INFO +93 -0
  3. stapel_chat-0.1.0/README.md +62 -0
  4. stapel_chat-0.1.0/__init__.py +11 -0
  5. stapel_chat-0.1.0/_capabilities.py +41 -0
  6. stapel_chat-0.1.0/_codegen.py +117 -0
  7. stapel_chat-0.1.0/_codegen_settings.py +106 -0
  8. stapel_chat-0.1.0/actions.py +25 -0
  9. stapel_chat-0.1.0/admin.py +26 -0
  10. stapel_chat-0.1.0/apps.py +26 -0
  11. stapel_chat-0.1.0/checks.py +73 -0
  12. stapel_chat-0.1.0/codegen_urls.py +19 -0
  13. stapel_chat-0.1.0/conf.py +60 -0
  14. stapel_chat-0.1.0/conftest.py +75 -0
  15. stapel_chat-0.1.0/consumers.py +232 -0
  16. stapel_chat-0.1.0/dto.py +125 -0
  17. stapel_chat-0.1.0/errors.py +52 -0
  18. stapel_chat-0.1.0/functions.py +20 -0
  19. stapel_chat-0.1.0/gdpr.py +74 -0
  20. stapel_chat-0.1.0/migrations/0001_initial.py +92 -0
  21. stapel_chat-0.1.0/migrations/__init__.py +0 -0
  22. stapel_chat-0.1.0/models.py +252 -0
  23. stapel_chat-0.1.0/py.typed +0 -0
  24. stapel_chat-0.1.0/pyproject.toml +66 -0
  25. stapel_chat-0.1.0/realtime.py +62 -0
  26. stapel_chat-0.1.0/schemas/consumes/user.deleted.json +14 -0
  27. stapel_chat-0.1.0/schemas/emits/chat.message.json +21 -0
  28. stapel_chat-0.1.0/schemas/emits/chat.support.assigned.json +13 -0
  29. stapel_chat-0.1.0/scope.py +42 -0
  30. stapel_chat-0.1.0/serializers.py +56 -0
  31. stapel_chat-0.1.0/services.py +317 -0
  32. stapel_chat-0.1.0/setup.cfg +4 -0
  33. stapel_chat-0.1.0/stapel_chat.egg-info/PKG-INFO +93 -0
  34. stapel_chat-0.1.0/stapel_chat.egg-info/SOURCES.txt +74 -0
  35. stapel_chat-0.1.0/stapel_chat.egg-info/dependency_links.txt +1 -0
  36. stapel_chat-0.1.0/stapel_chat.egg-info/requires.txt +7 -0
  37. stapel_chat-0.1.0/stapel_chat.egg-info/top_level.txt +1 -0
  38. stapel_chat-0.1.0/tests/test_checks.py +38 -0
  39. stapel_chat-0.1.0/tests/test_contract.py +164 -0
  40. stapel_chat-0.1.0/tests/test_conversations.py +101 -0
  41. stapel_chat-0.1.0/tests/test_messages.py +146 -0
  42. stapel_chat-0.1.0/tests/test_read_unread.py +64 -0
  43. stapel_chat-0.1.0/tests/test_realtime.py +134 -0
  44. stapel_chat-0.1.0/tests/test_seq_and_outbox.py +78 -0
  45. stapel_chat-0.1.0/tests/test_support.py +112 -0
  46. stapel_chat-0.1.0/urls.py +84 -0
  47. 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
+ ]