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.
- university_messaging-0.1.0/.gitignore +42 -0
- university_messaging-0.1.0/LICENSE +21 -0
- university_messaging-0.1.0/PKG-INFO +68 -0
- university_messaging-0.1.0/README.md +44 -0
- university_messaging-0.1.0/pyproject.toml +41 -0
- university_messaging-0.1.0/src/university/messaging/__init__.py +3 -0
- university_messaging-0.1.0/src/university/messaging/admin.py +12 -0
- university_messaging-0.1.0/src/university/messaging/api/__init__.py +0 -0
- university_messaging-0.1.0/src/university/messaging/api/v1/__init__.py +0 -0
- university_messaging-0.1.0/src/university/messaging/api/v1/serializers.py +116 -0
- university_messaging-0.1.0/src/university/messaging/api/v1/urls.py +21 -0
- university_messaging-0.1.0/src/university/messaging/api/v1/views.py +193 -0
- university_messaging-0.1.0/src/university/messaging/apps.py +64 -0
- university_messaging-0.1.0/src/university/messaging/conf.py +22 -0
- university_messaging-0.1.0/src/university/messaging/content.py +84 -0
- university_messaging-0.1.0/src/university/messaging/crypto.py +40 -0
- university_messaging-0.1.0/src/university/messaging/events.py +10 -0
- university_messaging-0.1.0/src/university/messaging/integrations.py +27 -0
- university_messaging-0.1.0/src/university/messaging/migrations/0001_initial.py +88 -0
- university_messaging-0.1.0/src/university/messaging/migrations/__init__.py +0 -0
- university_messaging-0.1.0/src/university/messaging/models.py +101 -0
- university_messaging-0.1.0/src/university/messaging/selectors.py +51 -0
- university_messaging-0.1.0/src/university/messaging/services.py +319 -0
- university_messaging-0.1.0/tests/__init__.py +0 -0
- university_messaging-0.1.0/tests/conftest.py +1 -0
- university_messaging-0.1.0/tests/settings.py +14 -0
- university_messaging-0.1.0/tests/test_messaging.py +213 -0
- 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,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")
|
|
File without changes
|
|
File without changes
|
|
@@ -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)
|