university-messaging 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 (28) hide show
  1. university_messaging-0.1.0/.gitignore +42 -0
  2. university_messaging-0.1.0/LICENSE +21 -0
  3. university_messaging-0.1.0/PKG-INFO +68 -0
  4. university_messaging-0.1.0/README.md +44 -0
  5. university_messaging-0.1.0/pyproject.toml +41 -0
  6. university_messaging-0.1.0/src/university/messaging/__init__.py +3 -0
  7. university_messaging-0.1.0/src/university/messaging/admin.py +12 -0
  8. university_messaging-0.1.0/src/university/messaging/api/__init__.py +0 -0
  9. university_messaging-0.1.0/src/university/messaging/api/v1/__init__.py +0 -0
  10. university_messaging-0.1.0/src/university/messaging/api/v1/serializers.py +116 -0
  11. university_messaging-0.1.0/src/university/messaging/api/v1/urls.py +21 -0
  12. university_messaging-0.1.0/src/university/messaging/api/v1/views.py +193 -0
  13. university_messaging-0.1.0/src/university/messaging/apps.py +64 -0
  14. university_messaging-0.1.0/src/university/messaging/conf.py +22 -0
  15. university_messaging-0.1.0/src/university/messaging/content.py +84 -0
  16. university_messaging-0.1.0/src/university/messaging/crypto.py +40 -0
  17. university_messaging-0.1.0/src/university/messaging/events.py +10 -0
  18. university_messaging-0.1.0/src/university/messaging/integrations.py +27 -0
  19. university_messaging-0.1.0/src/university/messaging/migrations/0001_initial.py +88 -0
  20. university_messaging-0.1.0/src/university/messaging/migrations/__init__.py +0 -0
  21. university_messaging-0.1.0/src/university/messaging/models.py +101 -0
  22. university_messaging-0.1.0/src/university/messaging/selectors.py +51 -0
  23. university_messaging-0.1.0/src/university/messaging/services.py +319 -0
  24. university_messaging-0.1.0/tests/__init__.py +0 -0
  25. university_messaging-0.1.0/tests/conftest.py +1 -0
  26. university_messaging-0.1.0/tests/settings.py +14 -0
  27. university_messaging-0.1.0/tests/test_messaging.py +213 -0
  28. university_messaging-0.1.0/tests/urls.py +3 -0
@@ -0,0 +1,42 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .nox/
11
+ .tox/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ coverage.xml
17
+ htmlcov/
18
+
19
+ # Django
20
+ *.sqlite3
21
+ # Local user uploads (never the university.media source package).
22
+ /media/
23
+ /private-media/
24
+ example_project/media/
25
+ example_project/private-media/
26
+ staticfiles/
27
+
28
+ # Secrets — never commit
29
+ .env
30
+ .env.*
31
+ !.env.example
32
+ *.pem
33
+ *.key
34
+
35
+ # Editors / OS
36
+ .idea/
37
+ .vscode/
38
+ .DS_Store
39
+ Thumbs.db
40
+
41
+ # GistLoop is a separate project with its own repository.
42
+ /GistLoop/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [YEAR] [COPYRIGHT HOLDER]
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,68 @@
1
+ Metadata-Version: 2.5
2
+ Name: university-messaging
3
+ Version: 0.1.0
4
+ Summary: University Messaging: Direct and group messaging with message requests, read receipts and encryption at rest.
5
+ Author-email: Ifeanyi Stanley <nnamaniifeanyi10@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Framework :: Django
10
+ Classifier: Framework :: Django :: 5.2
11
+ Classifier: Framework :: Django :: 6.0
12
+ Classifier: Framework :: Django :: 6.1
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: cryptography>=42
17
+ Requires-Dist: university-core<0.2,>=0.1.1
18
+ Provides-Extra: test
19
+ Requires-Dist: drf-spectacular>=0.27; extra == 'test'
20
+ Requires-Dist: pytest-cov>=5; extra == 'test'
21
+ Requires-Dist: pytest-django>=4.8; extra == 'test'
22
+ Requires-Dist: pytest>=8; extra == 'test'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # university-messaging
26
+
27
+ Direct and group messaging with message requests, read receipts and encryption at rest.
28
+
29
+ An optional **level-two** module of the University platform.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install university-messaging
35
+ ```
36
+
37
+ ```python
38
+ INSTALLED_APPS = [
39
+ # ...
40
+ *CORE_APPS,
41
+ "university.messaging",
42
+ ]
43
+ ```
44
+
45
+ Run `python manage.py migrate`. The module's API is mounted automatically under
46
+ `/api/v1/messaging/`.
47
+
48
+ ## Configuration
49
+
50
+ ```python
51
+ UNIVERSITY = {
52
+ "MESSAGING": {
53
+ "ENABLED": True,
54
+ },
55
+ }
56
+ ```
57
+
58
+ ## Permissions
59
+
60
+ | Code | Scope | Description |
61
+ | --- | --- | --- |
62
+ | `messaging.items.create` | platform | Create items |
63
+
64
+ ## Events
65
+
66
+ | Event | Payload |
67
+ | --- | --- |
68
+ | `messaging.item.created` | `item_id`, `owner_id` |
@@ -0,0 +1,44 @@
1
+ # university-messaging
2
+
3
+ Direct and group messaging with message requests, read receipts and encryption at rest.
4
+
5
+ An optional **level-two** module of the University platform.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install university-messaging
11
+ ```
12
+
13
+ ```python
14
+ INSTALLED_APPS = [
15
+ # ...
16
+ *CORE_APPS,
17
+ "university.messaging",
18
+ ]
19
+ ```
20
+
21
+ Run `python manage.py migrate`. The module's API is mounted automatically under
22
+ `/api/v1/messaging/`.
23
+
24
+ ## Configuration
25
+
26
+ ```python
27
+ UNIVERSITY = {
28
+ "MESSAGING": {
29
+ "ENABLED": True,
30
+ },
31
+ }
32
+ ```
33
+
34
+ ## Permissions
35
+
36
+ | Code | Scope | Description |
37
+ | --- | --- | --- |
38
+ | `messaging.items.create` | platform | Create items |
39
+
40
+ ## Events
41
+
42
+ | Event | Payload |
43
+ | --- | --- |
44
+ | `messaging.item.created` | `item_id`, `owner_id` |
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "university-messaging"
7
+ dynamic = ["version"]
8
+ description = "University Messaging: Direct and group messaging with message requests, read receipts and encryption at rest."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ authors = [{ name = "Ifeanyi Stanley", email = "nnamaniifeanyi10@gmail.com" }]
14
+ classifiers = [
15
+ "Development Status :: 2 - Pre-Alpha",
16
+ "Framework :: Django",
17
+ "Framework :: Django :: 5.2",
18
+ "Framework :: Django :: 6.0",
19
+ "Framework :: Django :: 6.1",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Typing :: Typed",
22
+ ]
23
+ # Level-two modules depend on Core only. Depend on another module only when
24
+ # unavoidable, and never in a cycle.
25
+ dependencies = ["university-core>=0.1.1,<0.2", "cryptography>=42"]
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest>=8", "pytest-django>=4.8", "pytest-cov>=5", "drf-spectacular>=0.27"]
29
+
30
+ [tool.hatch.version]
31
+ path = "src/university/messaging/__init__.py"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/university"]
35
+
36
+ [tool.pytest.ini_options]
37
+ DJANGO_SETTINGS_MODULE = "tests.settings"
38
+ pythonpath = ["."]
39
+ testpaths = ["tests"]
40
+ addopts = "-ra --strict-markers"
41
+ markers = ["security: security regression tests"]
@@ -0,0 +1,3 @@
1
+ """University Messaging: direct and group conversations with message requests and encryption at rest."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,12 @@
1
+ from django.contrib import admin
2
+
3
+ from university.messaging.models import Conversation
4
+
5
+
6
+ @admin.register(Conversation)
7
+ class ConversationAdmin(admin.ModelAdmin):
8
+ """Metadata only: message text is private and never shown in the admin."""
9
+
10
+ list_display = ("id", "kind", "last_message_at", "created_at")
11
+ list_filter = ("kind",)
12
+ readonly_fields = ("kind", "title", "created_by", "direct_key", "last_message_at")
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from rest_framework import serializers
6
+
7
+ from university.core.api.schema import extend_schema_field
8
+ from university.messaging.models import Message, Participant, ParticipantState
9
+ from university.users.refs import PersonRefSerializer, person_ref
10
+
11
+ PLACEHOLDERS = {"deleted": "This message was deleted.", "hidden": "This message was removed by the moderators."}
12
+
13
+
14
+ class MessagingMessageSerializer(serializers.Serializer):
15
+ id = serializers.UUIDField(read_only=True)
16
+ conversation = serializers.UUIDField(source="conversation_id", read_only=True)
17
+ sender = serializers.SerializerMethodField()
18
+ body = serializers.SerializerMethodField()
19
+ status = serializers.CharField(read_only=True)
20
+ reply_to = serializers.UUIDField(source="reply_to_id", read_only=True, allow_null=True)
21
+ is_mine = serializers.SerializerMethodField()
22
+ edited_at = serializers.DateTimeField(read_only=True, allow_null=True)
23
+ created_at = serializers.DateTimeField(read_only=True)
24
+
25
+ @extend_schema_field(PersonRefSerializer(allow_null=True))
26
+ def get_sender(self, obj: Message) -> dict[str, Any] | None:
27
+ return person_ref(obj.sender)
28
+
29
+ def get_body(self, obj: Message) -> str:
30
+ return obj.text if obj.status == "visible" else PLACEHOLDERS.get(obj.status, "")
31
+
32
+ def get_is_mine(self, obj: Message) -> bool:
33
+ viewer = self.context.get("viewer")
34
+ return bool(viewer is not None and obj.sender_id == viewer.pk)
35
+
36
+
37
+ class MessagingConversationSerializer(serializers.Serializer):
38
+ """A conversation as one participant sees it."""
39
+
40
+ id = serializers.UUIDField(source="conversation_id", read_only=True)
41
+ kind = serializers.CharField(source="conversation.kind", read_only=True)
42
+ title = serializers.SerializerMethodField()
43
+ participants = serializers.SerializerMethodField()
44
+ state = serializers.CharField(read_only=True)
45
+ is_admin = serializers.BooleanField(read_only=True)
46
+ muted = serializers.BooleanField(read_only=True)
47
+ archived = serializers.BooleanField(read_only=True)
48
+ unread_count = serializers.SerializerMethodField()
49
+ last_message = serializers.SerializerMethodField()
50
+ last_message_at = serializers.DateTimeField(source="conversation.last_message_at", read_only=True, allow_null=True)
51
+
52
+ def _others(self, obj: Participant) -> list[Participant]:
53
+ cache = self.context.setdefault("_others", {})
54
+ if obj.conversation_id not in cache:
55
+ cache[obj.conversation_id] = list(
56
+ obj.conversation.participants.exclude(user_id=obj.user_id)
57
+ .exclude(state__in=[ParticipantState.LEFT, ParticipantState.DECLINED])
58
+ .select_related("user")
59
+ )
60
+ return cache[obj.conversation_id]
61
+
62
+ def get_title(self, obj: Participant) -> str:
63
+ if obj.conversation.title:
64
+ return obj.conversation.title
65
+ names = [p.user.display_name or p.user.handle or "" for p in self._others(obj)]
66
+ return ", ".join(n for n in names if n)[:100] or "Conversation"
67
+
68
+ @extend_schema_field(PersonRefSerializer(many=True))
69
+ def get_participants(self, obj: Participant) -> list[dict[str, Any] | None]:
70
+ return [person_ref(p.user) for p in self._others(obj)]
71
+
72
+ def get_unread_count(self, obj: Participant) -> int:
73
+ return int(getattr(obj, "unread", 0) or 0)
74
+
75
+ @extend_schema_field(MessagingMessageSerializer(allow_null=True))
76
+ def get_last_message(self, obj: Participant) -> dict[str, Any] | None:
77
+ message_id = getattr(obj, "last_message_id", None)
78
+ if not message_id:
79
+ return None
80
+ message = Message.objects.select_related("sender").filter(pk=message_id).first()
81
+ return MessagingMessageSerializer(message, context=self.context).data if message else None
82
+
83
+
84
+ class MessagingStartSerializer(serializers.Serializer):
85
+ participant_ids = serializers.ListField(child=serializers.UUIDField(), min_length=1, max_length=49)
86
+ body = serializers.CharField(max_length=20000)
87
+ title = serializers.CharField(max_length=100, required=False, allow_blank=True)
88
+
89
+
90
+ class MessagingSendSerializer(serializers.Serializer):
91
+ body = serializers.CharField(max_length=20000)
92
+ reply_to = serializers.UUIDField(required=False, allow_null=True)
93
+
94
+
95
+ class MessagingEditSerializer(serializers.Serializer):
96
+ body = serializers.CharField(max_length=20000)
97
+
98
+
99
+ class MessagingConversationUpdateSerializer(serializers.Serializer):
100
+ title = serializers.CharField(max_length=100, required=False, allow_blank=True)
101
+ muted = serializers.BooleanField(required=False)
102
+ archived = serializers.BooleanField(required=False)
103
+
104
+
105
+ class MessagingAddParticipantsSerializer(serializers.Serializer):
106
+ user_ids = serializers.ListField(child=serializers.UUIDField(), min_length=1, max_length=49)
107
+
108
+
109
+ class MessagingUnreadSerializer(serializers.Serializer):
110
+ conversations = serializers.IntegerField()
111
+ messages = serializers.IntegerField()
112
+ requests = serializers.IntegerField()
113
+
114
+
115
+ class MessagingSettingsSerializer(serializers.Serializer):
116
+ allow_requests = serializers.BooleanField()
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from django.urls import path
4
+
5
+ from university.messaging.api.v1 import views
6
+
7
+ app_name = "messaging"
8
+
9
+ urlpatterns = [
10
+ path("conversations/", views.ConversationListCreateView.as_view(), name="conversation-list"),
11
+ path("conversations/<uuid:conversation_id>/", views.ConversationDetailView.as_view(), name="conversation-detail"),
12
+ path("conversations/<uuid:conversation_id>/accept/", views.AcceptView.as_view(), name="conversation-accept"),
13
+ path("conversations/<uuid:conversation_id>/decline/", views.DeclineView.as_view(), name="conversation-decline"),
14
+ path("conversations/<uuid:conversation_id>/read/", views.ReadView.as_view(), name="conversation-read"),
15
+ path("conversations/<uuid:conversation_id>/leave/", views.LeaveView.as_view(), name="conversation-leave"),
16
+ path("conversations/<uuid:conversation_id>/participants/", views.ParticipantsView.as_view(), name="participants"),
17
+ path("conversations/<uuid:conversation_id>/messages/", views.MessageListCreateView.as_view(), name="messages"),
18
+ path("messages/<uuid:message_id>/", views.MessageDetailView.as_view(), name="message-detail"),
19
+ path("unread/", views.UnreadView.as_view(), name="unread"),
20
+ path("settings/", views.SettingsView.as_view(), name="settings"),
21
+ ]
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from rest_framework import permissions, status
6
+ from rest_framework.request import Request
7
+ from rest_framework.response import Response
8
+ from rest_framework.views import APIView
9
+
10
+ from university.core.api.pagination import CursorPagination, PageNumberPagination
11
+ from university.core.api.schema import OpenApiParameter, extend_schema, paginated
12
+ from university.messaging import selectors, services
13
+ from university.messaging.api.v1 import serializers as s
14
+ from university.messaging.models import Participant
15
+
16
+
17
+ class MessagePagination(CursorPagination):
18
+ ordering = ("-created_at", "-id")
19
+
20
+
21
+ def _conversation(request: Request, participant: Participant) -> dict[str, Any]:
22
+ annotated = selectors.participations(request.user, "inbox").filter(pk=participant.pk).first() or next(
23
+ (
24
+ p
25
+ for f in ("requests", "archived")
26
+ for p in selectors.participations(request.user, f).filter(pk=participant.pk)
27
+ ),
28
+ participant,
29
+ )
30
+ return s.MessagingConversationSerializer(annotated, context={"viewer": request.user}).data
31
+
32
+
33
+ class ConversationListCreateView(APIView):
34
+ permission_classes = [permissions.IsAuthenticated]
35
+
36
+ @extend_schema(
37
+ parameters=[OpenApiParameter("folder", str, description="inbox (default), requests or archived")],
38
+ responses=paginated(s.MessagingConversationSerializer),
39
+ )
40
+ def get(self, request: Request) -> Response:
41
+ qs = selectors.participations(request.user, request.query_params.get("folder", "inbox"))
42
+ paginator = PageNumberPagination()
43
+ page = paginator.paginate_queryset(qs, request, view=self)
44
+ context = {"viewer": request.user}
45
+ return paginator.get_paginated_response(
46
+ s.MessagingConversationSerializer(page, many=True, context=context).data
47
+ )
48
+
49
+ @extend_schema(request=s.MessagingStartSerializer, responses={201: s.MessagingConversationSerializer})
50
+ def post(self, request: Request) -> Response:
51
+ serializer = s.MessagingStartSerializer(data=request.data)
52
+ serializer.is_valid(raise_exception=True)
53
+ conversation, _ = services.start_conversation(sender=request.user, **serializer.validated_data)
54
+ participant = services.membership(request.user, conversation.pk)
55
+ return Response(_conversation(request, participant), status=status.HTTP_201_CREATED)
56
+
57
+
58
+ class ConversationDetailView(APIView):
59
+ permission_classes = [permissions.IsAuthenticated]
60
+
61
+ @extend_schema(responses=s.MessagingConversationSerializer)
62
+ def get(self, request: Request, conversation_id: Any) -> Response:
63
+ return Response(_conversation(request, services.membership(request.user, conversation_id)))
64
+
65
+ @extend_schema(request=s.MessagingConversationUpdateSerializer, responses=s.MessagingConversationSerializer)
66
+ def patch(self, request: Request, conversation_id: Any) -> Response:
67
+ serializer = s.MessagingConversationUpdateSerializer(data=request.data)
68
+ serializer.is_valid(raise_exception=True)
69
+ data = serializer.validated_data
70
+ if "title" in data:
71
+ services.rename(user=request.user, conversation_id=conversation_id, title=data["title"])
72
+ participant = services.update_participation(
73
+ user=request.user, conversation_id=conversation_id, muted=data.get("muted"), archived=data.get("archived")
74
+ )
75
+ return Response(_conversation(request, participant))
76
+
77
+
78
+ class _RequestResponseView(APIView):
79
+ permission_classes = [permissions.IsAuthenticated]
80
+ accept = True
81
+
82
+ @extend_schema(request=None, responses=s.MessagingConversationSerializer)
83
+ def post(self, request: Request, conversation_id: Any) -> Response:
84
+ participant = services.respond_to_request(
85
+ user=request.user, conversation_id=conversation_id, accept=self.accept
86
+ )
87
+ return Response(s.MessagingConversationSerializer(participant, context={"viewer": request.user}).data)
88
+
89
+
90
+ class AcceptView(_RequestResponseView):
91
+ accept = True
92
+
93
+
94
+ class DeclineView(_RequestResponseView):
95
+ accept = False
96
+
97
+
98
+ class ReadView(APIView):
99
+ permission_classes = [permissions.IsAuthenticated]
100
+
101
+ @extend_schema(request=None, responses={204: None})
102
+ def post(self, request: Request, conversation_id: Any) -> Response:
103
+ services.mark_read(user=request.user, conversation_id=conversation_id)
104
+ return Response(status=status.HTTP_204_NO_CONTENT)
105
+
106
+
107
+ class LeaveView(APIView):
108
+ permission_classes = [permissions.IsAuthenticated]
109
+
110
+ @extend_schema(request=None, responses={204: None})
111
+ def post(self, request: Request, conversation_id: Any) -> Response:
112
+ services.leave(user=request.user, conversation_id=conversation_id)
113
+ return Response(status=status.HTTP_204_NO_CONTENT)
114
+
115
+
116
+ class ParticipantsView(APIView):
117
+ permission_classes = [permissions.IsAuthenticated]
118
+
119
+ @extend_schema(request=s.MessagingAddParticipantsSerializer, responses=s.MessagingConversationSerializer)
120
+ def post(self, request: Request, conversation_id: Any) -> Response:
121
+ serializer = s.MessagingAddParticipantsSerializer(data=request.data)
122
+ serializer.is_valid(raise_exception=True)
123
+ services.add_participants(user=request.user, conversation_id=conversation_id, **serializer.validated_data)
124
+ return Response(_conversation(request, services.membership(request.user, conversation_id)))
125
+
126
+
127
+ class MessageListCreateView(APIView):
128
+ permission_classes = [permissions.IsAuthenticated]
129
+
130
+ @property
131
+ def throttle_scope(self) -> str | None: # type: ignore[override]
132
+ return "messaging_send" if self.request.method == "POST" else None
133
+
134
+ @extend_schema(
135
+ parameters=[OpenApiParameter("cursor", str)], responses=paginated(s.MessagingMessageSerializer, style="cursor")
136
+ )
137
+ def get(self, request: Request, conversation_id: Any) -> Response:
138
+ services.membership(request.user, conversation_id)
139
+ paginator = MessagePagination()
140
+ page = paginator.paginate_queryset(selectors.messages(conversation_id), request, view=self)
141
+ data = s.MessagingMessageSerializer(page, many=True, context={"viewer": request.user}).data
142
+ return paginator.get_paginated_response(data)
143
+
144
+ @extend_schema(request=s.MessagingSendSerializer, responses={201: s.MessagingMessageSerializer})
145
+ def post(self, request: Request, conversation_id: Any) -> Response:
146
+ serializer = s.MessagingSendSerializer(data=request.data)
147
+ serializer.is_valid(raise_exception=True)
148
+ message = services.send_message(
149
+ sender=request.user, conversation_id=conversation_id, **serializer.validated_data
150
+ )
151
+ data = s.MessagingMessageSerializer(message, context={"viewer": request.user}).data
152
+ return Response(data, status=status.HTTP_201_CREATED)
153
+
154
+
155
+ class MessageDetailView(APIView):
156
+ permission_classes = [permissions.IsAuthenticated]
157
+
158
+ @extend_schema(request=s.MessagingEditSerializer, responses=s.MessagingMessageSerializer)
159
+ def patch(self, request: Request, message_id: Any) -> Response:
160
+ serializer = s.MessagingEditSerializer(data=request.data)
161
+ serializer.is_valid(raise_exception=True)
162
+ message = services.edit_message(
163
+ user=request.user, message_id=message_id, body=serializer.validated_data["body"]
164
+ )
165
+ return Response(s.MessagingMessageSerializer(message, context={"viewer": request.user}).data)
166
+
167
+ @extend_schema(responses=s.MessagingMessageSerializer)
168
+ def delete(self, request: Request, message_id: Any) -> Response:
169
+ message = services.delete_message(user=request.user, message_id=message_id)
170
+ return Response(s.MessagingMessageSerializer(message, context={"viewer": request.user}).data)
171
+
172
+
173
+ class UnreadView(APIView):
174
+ permission_classes = [permissions.IsAuthenticated]
175
+
176
+ @extend_schema(responses=s.MessagingUnreadSerializer)
177
+ def get(self, request: Request) -> Response:
178
+ return Response(selectors.unread_totals(request.user))
179
+
180
+
181
+ class SettingsView(APIView):
182
+ permission_classes = [permissions.IsAuthenticated]
183
+
184
+ @extend_schema(responses=s.MessagingSettingsSerializer)
185
+ def get(self, request: Request) -> Response:
186
+ return Response({"allow_requests": services.settings_for(request.user).allow_requests})
187
+
188
+ @extend_schema(request=s.MessagingSettingsSerializer, responses=s.MessagingSettingsSerializer)
189
+ def patch(self, request: Request) -> Response:
190
+ serializer = s.MessagingSettingsSerializer(data=request.data)
191
+ serializer.is_valid(raise_exception=True)
192
+ updated = services.update_settings(request.user, **serializer.validated_data)
193
+ return Response({"allow_requests": updated.allow_requests})
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ from collections.abc import Iterable
5
+ from typing import Any
6
+
7
+ from django.utils import timezone
8
+ from django.utils.translation import gettext_lazy as _
9
+
10
+ from university.core.declarations import RetentionPolicy
11
+ from university.core.hooks import Hook, HookRegistry
12
+ from university.core.modules import UniversityAppConfig
13
+ from university.messaging import __version__
14
+
15
+
16
+ class MessagingConfig(UniversityAppConfig):
17
+ name = "university.messaging"
18
+ label = "university_messaging"
19
+ verbose_name = _("University Messaging")
20
+ module_name = "messaging"
21
+ module_version = __version__
22
+ module_requires = ("core", "users", "permissions", "audit")
23
+ settings_namespace = "MESSAGING"
24
+ api_prefix = "messaging/"
25
+ api_urlconfs = {"v1": "university.messaging.api.v1.urls"}
26
+
27
+ def get_content_adapters(self) -> list[Any]:
28
+ from university.messaging.content import MessageAdapter
29
+
30
+ return [MessageAdapter()]
31
+
32
+ def get_throttle_rates(self) -> dict[str, str | None]:
33
+ return {
34
+ "messaging_new_conversation": "30/day",
35
+ "messaging_message": "120/minute",
36
+ "messaging_send": "120/minute",
37
+ }
38
+
39
+ def get_retention_policies(self) -> Iterable[RetentionPolicy]:
40
+ from university.core.retention import delete_queryset
41
+ from university.messaging.conf import messaging_settings
42
+ from university.messaging.models import Message, MessageStatus
43
+
44
+ def cutoff() -> dt.datetime | None:
45
+ days = messaging_settings.get("DELETED_RETENTION_DAYS")
46
+ return None if days is None else timezone.now() - dt.timedelta(days=int(days))
47
+
48
+ return [
49
+ RetentionPolicy(
50
+ name="messaging.deleted_messages",
51
+ description="Tombstones of messages their senders deleted.",
52
+ setting="MESSAGING.DELETED_RETENTION_DAYS",
53
+ cutoff=cutoff,
54
+ purge=lambda c, dry: delete_queryset(
55
+ Message.objects.filter(status=MessageStatus.DELETED, deleted_at__lt=c), dry
56
+ ),
57
+ )
58
+ ]
59
+
60
+ def register_hooks(self, hooks: HookRegistry) -> None:
61
+ from university.messaging import integrations
62
+
63
+ hooks.register(Hook.USER_EXPORT_DATA, integrations.export_data)
64
+ hooks.register(Hook.USER_PURGE, integrations.purge)
@@ -0,0 +1,22 @@
1
+ """Settings for the messaging module (``UNIVERSITY["MESSAGING"]``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from university.core.conf import ModuleSettings
8
+
9
+ DEFAULTS: dict[str, Any] = {
10
+ "MESSAGE_MAX_LENGTH": 4000,
11
+ "MAX_GROUP_SIZE": 50,
12
+ # Senders can edit a message for this long after sending it.
13
+ "EDIT_WINDOW_MINUTES": 15,
14
+ # Fernet keys (urlsafe base64, 32 bytes) encrypting message text at rest. The first key
15
+ # encrypts; all keys decrypt (rotation). Empty: a key is derived from SECURITY.HMAC_KEY.
16
+ "ENCRYPTION_KEYS": [],
17
+ "REQUIRE_VERIFIED_EMAIL": True,
18
+ # Deleted messages keep a tombstone ("This message was deleted") for this many days, then go.
19
+ "DELETED_RETENTION_DAYS": 30,
20
+ }
21
+
22
+ messaging_settings = ModuleSettings("MESSAGING", DEFAULTS)