From 7f4f37afa2bc36f169dd5a51d5325eaa42119acd Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:24:50 +0300 Subject: [PATCH] Add notification preferences for group chats and channels --- chats/admin.py | 229 ++++++++++++++---- chats/channel_events.py | 3 +- chats/channel_views.py | 7 + chats/consumers/chat.py | 35 ++- chats/consumers/event_types/ProjectEvent.py | 1 + .../0017_chat_notification_preferences.py | 114 +++++++++ chats/models.py | 44 ++++ chats/notification_views.py | 71 ++++++ chats/notifications.py | 71 ++++++ chats/program_channels.py | 8 +- chats/serializers.py | 2 + chats/services/messages.py | 10 + chats/tests/test_message_delivery.py | 7 +- chats/tests/test_notifications.py | 143 +++++++++++ chats/tests/test_program_channels.py | 28 ++- chats/urls.py | 24 ++ docs/modules/chats.md | 22 ++ templates/admin/chats/channel_publish.html | 4 +- templates/admin/chats/message_search.html | 5 +- .../admin/chats/notification_settings.html | 11 + 20 files changed, 785 insertions(+), 54 deletions(-) create mode 100644 chats/migrations/0017_chat_notification_preferences.py create mode 100644 chats/notification_views.py create mode 100644 chats/notifications.py create mode 100644 chats/tests/test_notifications.py create mode 100644 templates/admin/chats/notification_settings.html diff --git a/chats/admin.py b/chats/admin.py index 59c127cd..eb498cac 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -6,6 +6,7 @@ from channels.layers import get_channel_layer from django import forms from django.contrib import admin, messages +from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied from django.core.cache import cache from django.core.paginator import Paginator @@ -39,11 +40,17 @@ FileToMessage, ProgramChannel, ) +from chats.notifications import ( + Mode, + chat_mention_recipients, + notification_settings, + set_notification_mode, +) from chats.permissions import accessible_chats, can_publish_in_program_channel from chats.program_channels import mark_channel_read, publish_channel_message from chats.project_reads import mark_project_message_read, publish_project_message_read from chats.search import ChatMessageSearchForm, search_chat_messages -from chats.services.messages import delete_message, edit_message +from chats.services.messages import create_project_message, delete_message, edit_message from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ChatType, EventGroupType, EventType from files.models import UserFile @@ -293,7 +300,7 @@ class OwnMessageForm(forms.Form): ) -class ChannelPublicationForm(forms.Form): +class GroupMessageForm(forms.Form): text = forms.CharField( label="Текст публикации", required=False, strip=False, widget=forms.Textarea ) @@ -304,40 +311,47 @@ class ChannelPublicationForm(forms.Form): widget=forms.HiddenInput, error_messages={"invalid": "Некорректный номер публикации."}, ) + mentions = forms.ModelMultipleChoiceField( + label="Упомянуть участников", + queryset=get_user_model().objects.none(), + required=False, + error_messages={"invalid_choice": "Выберите участников этого чата."}, + ) - def __init__(self, user, *args, **kwargs): + def __init__(self, user, chat, *args, **kwargs): super().__init__(*args, **kwargs) + if isinstance(chat, ProjectChat): + self.fields["text"].label = "Текст сообщения" self.fields["files"].queryset = UserFile.objects.filter(user=user).order_by( "-datetime_uploaded" ) + self.fields["mentions"].queryset = ( + chat_mention_recipients(chat) + .exclude(pk=user.pk) + .order_by("first_name", "last_name", "pk") + ) + self.fields["mentions"].label_from_instance = ( + lambda member: member.get_full_name() + ) class ChannelUploadForm(forms.Form): file = forms.FileField(label="Файл") -@admin.register(ProgramChannel) -class ProgramChannelAdmin(ChatSearchAdmin): - list_display = ("program", "message_search_link") - list_display_links = None - cdn = CDN(storage=SelectelSwiftStorage()) - - def get_queryset(self, request): - return accessible_chats(request.user, ProgramChannel).select_related("program") - - def has_view_permission(self, request, obj=None): - if obj is None: - return accessible_chats(request.user, ProgramChannel).exists() - return accessible_chats(request.user, ProgramChannel).filter(pk=obj.pk).exists() +class NotificationSettingsForm(forms.Form): + mode = forms.ChoiceField( + label="Режим уведомлений", + choices=Mode.choices, + error_messages={ + "invalid_choice": "Выберите режим уведомлений.", + "required": "Выберите режим уведомлений.", + }, + ) - def has_add_permission(self, request): - return False - def has_change_permission(self, request, obj=None): - return False - - def has_delete_permission(self, request, obj=None): - return False +class GroupChatAdmin(ChatSearchAdmin): + cdn = CDN(storage=SelectelSwiftStorage()) def get_urls(self): return [ @@ -349,39 +363,69 @@ def get_urls(self): name=self.message_search_url_name + "_publish", ), path( - "/messages//read/", + "/notification-settings/", self.admin_site.admin_view( - require_http_methods(["POST"])(self.read_message) + require_http_methods(["GET", "POST"])(self.notification_settings_view) ), - name=self.message_search_url_name + "_read", + name=self.message_search_url_name + "_notifications", ), ] + super().get_urls() + def can_publish(self, user, chat): + return True + def extra_channel_context(self, request, chat): prefix = "admin:" + self.message_search_url_name return { - "channel_read_route": prefix + "_read", "channel_publish_url": ( reverse(prefix + "_publish", args=[chat.pk]) - if can_publish_in_program_channel(request.user, chat.program) + if self.can_publish(request.user, chat) else None ), + "notification_settings_url": reverse( + prefix + "_notifications", args=[chat.pk] + ), } - def change_own_message(self, request, object_id, message_id): + def notification_settings_view(self, request, object_id): chat = self.get_chat_for_member(request, object_id) - if not can_publish_in_program_channel(request.user, chat.program): - raise PermissionDenied("Публикации доступны менеджерам программы.") - return super().change_own_message(request, object_id, message_id) + settings = notification_settings(request.user, self.model, chat.pk) + form = NotificationSettingsForm( + request.POST if request.method == "POST" else None, initial=settings + ) + if request.method == "POST" and form.is_valid(): + set_notification_mode( + request.user, self.model, chat.pk, form.cleaned_data["mode"] + ) + self.message_user(request, "Настройки уведомлений сохранены.") + return redirect( + "admin:" + self.message_search_url_name + "_notifications", chat.pk + ) + return TemplateResponse( + request, + "admin/chats/notification_settings.html", + { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": "Настройки уведомлений", + "form": form, + "muted_until": settings["muted_until"], + "back_url": reverse( + "admin:" + self.message_search_url_name, args=[chat.pk] + ), + }, + status=400 if form.errors else 200, + ) def publish_message(self, request, object_id): chat = self.get_chat_for_member(request, object_id) - if not can_publish_in_program_channel(request.user, chat.program): + if not self.can_publish(request.user, chat): raise PermissionDenied("Публикации доступны менеджерам программы.") posted = request.method == "POST" uploading = posted and request.POST.get("action") == "upload" - form = ChannelPublicationForm( + form = GroupMessageForm( request.user, + chat, request.POST if posted and not uploading else None, initial={"client_message_id": uuid4()}, ) @@ -405,17 +449,38 @@ def publish_message(self, request, object_id): self.message_user(request, "Файл загружен и выбран для публикации.") if posted and not uploading and form.is_valid(): try: - publication, _ = publish_channel_message( - channel_id=chat.pk, - author=request.user, - text=form.cleaned_data["text"], - file_urls=[file.pk for file in form.cleaned_data["files"]], - client_message_id=form.cleaned_data["client_message_id"], - ) + with transaction.atomic(): + publication, created = self.save_publication( + chat.pk, + author=request.user, + text=form.cleaned_data["text"], + file_urls=[file.pk for file in form.cleaned_data["files"]], + client_message_id=form.cleaned_data["client_message_id"], + mentioned_user_ids=[ + user.pk for user in form.cleaned_data["mentions"] + ], + ) + if created and isinstance(chat, ProjectChat): + transaction.on_commit( + partial( + self.publish_change, + chat, + EventType.NEW_MESSAGE, + {"message": publication, "is_replay": False}, + ), + robust=True, + ) except MessageValidationException as error: form.add_error(None, str(error)) else: - self.message_user(request, "Публикация сохранена.") + self.message_user( + request, + ( + "Публикация сохранена." + if isinstance(chat, ProgramChannel) + else "Сообщение отправлено." + ), + ) return redirect( "admin:" + self.message_search_url_name + "_context", chat.pk, @@ -427,7 +492,14 @@ def publish_message(self, request, object_id): { **self.admin_site.each_context(request), "opts": self.model._meta, - "title": "Новая публикация в канале", + "title": ( + "Новая публикация в канале" + if isinstance(chat, ProgramChannel) + else "Новое сообщение" + ), + "send_label": ( + "Опубликовать" if isinstance(chat, ProgramChannel) else "Отправить" + ), "form": form, "upload_form": upload_form, "back_url": reverse( @@ -437,6 +509,58 @@ def publish_message(self, request, object_id): status=400 if form.errors or upload_form.errors else 200, ) + +@admin.register(ProgramChannel) +class ProgramChannelAdmin(GroupChatAdmin): + list_display = ("program", "message_search_link") + list_display_links = None + + def get_queryset(self, request): + return accessible_chats(request.user, ProgramChannel).select_related("program") + + def has_view_permission(self, request, obj=None): + if obj is None: + return accessible_chats(request.user, ProgramChannel).exists() + return accessible_chats(request.user, ProgramChannel).filter(pk=obj.pk).exists() + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def get_urls(self): + return [ + path( + "/messages//read/", + self.admin_site.admin_view( + require_http_methods(["POST"])(self.read_message) + ), + name=self.message_search_url_name + "_read", + ), + ] + super().get_urls() + + def extra_channel_context(self, request, chat): + return { + **super().extra_channel_context(request, chat), + "channel_read_route": "admin:" + self.message_search_url_name + "_read", + } + + def can_publish(self, user, chat): + return can_publish_in_program_channel(user, chat.program) + + def save_publication(self, chat_id, **data): + return publish_channel_message(channel_id=chat_id, **data) + + def change_own_message(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + if not self.can_publish(request.user, chat): + raise PermissionDenied("Публикации доступны менеджерам программы.") + return super().change_own_message(request, object_id, message_id) + def read_message(self, request, object_id, message_id): chat = self.get_chat_for_member(request, object_id) try: @@ -452,7 +576,7 @@ def read_message(self, request, object_id, message_id): @admin.register(ProjectChat) -class ChatAdmin(ChatSearchAdmin): +class ChatAdmin(GroupChatAdmin): list_display = ( "id", "project", @@ -466,6 +590,23 @@ class ChatAdmin(ChatSearchAdmin): "project", ) + def get_queryset(self, request): + if request.user.is_superuser: + return super().get_queryset(request) + return accessible_chats(request.user, ProjectChat).select_related("project") + + def has_view_permission(self, request, obj=None): + if request.user.is_superuser: + return super().has_view_permission(request, obj) + chats = accessible_chats(request.user, ProjectChat) + return (chats if obj is None else chats.filter(pk=obj.pk)).exists() + + def has_module_permission(self, request): + return super().has_module_permission(request) or self.has_view_permission(request) + + def save_publication(self, chat_id, **data): + return create_project_message(chat_id=chat_id, **data) + @admin.register(DirectChat) class DirectChatAdmin(ChatSearchAdmin): diff --git a/chats/channel_events.py b/chats/channel_events.py index d584140e..4aba1fc7 100644 --- a/chats/channel_events.py +++ b/chats/channel_events.py @@ -9,7 +9,7 @@ def channel_group(channel_id): return f"{EventGroupType.PROGRAM_CHANNEL}_{channel_id}" -def notify_channel_change(channel_id): +def notify_channel_change(channel_id, *, new_message_id=None): layer = get_channel_layer() if layer is None: return @@ -19,6 +19,7 @@ def notify_channel_change(channel_id): { "type": EventType.PROGRAM_CHANNEL_CHANGED, "content": {"channel_id": channel_id}, + "_new_message_id": new_message_id, }, ), robust=True, diff --git a/chats/channel_views.py b/chats/channel_views.py index a75e0b7a..7eb32e84 100644 --- a/chats/channel_views.py +++ b/chats/channel_views.py @@ -12,6 +12,7 @@ from chats.attachments import get_accessible_chat from chats.exceptions import MessageValidationException from chats.models import ProgramChannel, ProgramChannelMessage +from chats.notifications import MAX_MENTIONS from chats.pagination import MessageListPagination from chats.permissions import accessible_chats, can_publish_in_program_channel from chats.program_channels import ( @@ -68,6 +69,12 @@ class ChannelMessageInput(serializers.Serializer): client_message_id = serializers.UUIDField( required=False, allow_null=True, default=None ) + mentioned_user_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1, max_value=2**63 - 1), + required=False, + default=list, + max_length=MAX_MENTIONS, + ) class ChannelEditInput(serializers.Serializer): diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index c32b0b5e..76312e5f 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -27,7 +27,8 @@ from users.models import CustomUser from chats.consumers.event_types import DirectEvent, ProjectEvent from chats.utils import get_chat_and_user_ids_from_content -from chats.models import DirectChat, ProgramChannel, ProjectChat +from chats.models import DirectChat, ProgramChannel, ProgramChannelMessage, ProjectChat +from chats.notifications import should_notify @database_sync_to_async @@ -50,6 +51,18 @@ def can_access_program_channel(user, channel_id): return accessible_chats(user, ProgramChannel).filter(pk=channel_id).exists() +@database_sync_to_async +def channel_notification(user, channel_id, message_id): + message = ( + ProgramChannelMessage.objects.filter( + chat_id=channel_id, pk=message_id, is_deleted=False + ) + .values("author_id", "mentioned_user_ids") + .first() + ) + return bool(message and should_notify(user, ProgramChannel, channel_id, **message)) + + @database_sync_to_async def can_receive_direct_typing(user, chat_id): chat = accessible_chats(user, DirectChat).filter(pk=chat_id).first() @@ -274,6 +287,17 @@ async def _send_chat_event(self, event): content["message"], self.user.pk ) event = {**event, "content": {**content, "message": message}} + if event["type"] == EventType.NEW_MESSAGE: + notify = not content.get( + "is_replay" + ) and await database_sync_to_async(should_notify)( + self.user, + ProjectChat, + chat_id, + author_id=message["author"]["id"], + mentioned_user_ids=message.get("mentioned_user_ids", []), + ) + event["content"]["notify"] = notify # Служебный контекст нужен для проверки уже открытых сокетов, # клиентский формат сообщений остается прежним. await self.send( @@ -322,7 +346,14 @@ async def subscribe_program_channel(self, content): async def program_channel_changed(self, event): channel_id = event["content"]["channel_id"] if self.user and await can_access_program_channel(self.user, channel_id): - await self.send_json(event) + message_id = event.get("_new_message_id") + notify = message_id is not None and await channel_notification( + self.user, channel_id, message_id + ) + content = {"channel_id": channel_id, "notify": notify} + if notify: + content["message_id"] = message_id + await self.send_json({"type": event["type"], "content": content}) else: room_name = channel_group(channel_id) await self.channel_layer.group_discard(room_name, self.channel_name) diff --git a/chats/consumers/event_types/ProjectEvent.py b/chats/consumers/event_types/ProjectEvent.py index 6249f966..220b332c 100644 --- a/chats/consumers/event_types/ProjectEvent.py +++ b/chats/consumers/event_types/ProjectEvent.py @@ -21,6 +21,7 @@ async def process_new_message_event(self, event: Event, room_name: str): reply_to_id=event.content.get("reply_to"), file_urls=event.content.get("file_urls", []), client_message_id=event.content.get("client_message_id"), + mentioned_user_ids=event.content.get("mentioned_user_ids", []), ) content = { "chat_id": chat_id, diff --git a/chats/migrations/0017_chat_notification_preferences.py b/chats/migrations/0017_chat_notification_preferences.py new file mode 100644 index 00000000..b0f74d3b --- /dev/null +++ b/chats/migrations/0017_chat_notification_preferences.py @@ -0,0 +1,114 @@ +# Generated by Django 5.2.18 on 2026-11-13 19:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0016_direct_chat_privacy"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="programchannelmessage", + name="mentioned_user_ids", + field=models.JSONField(blank=True, default=list, editable=False), + ), + migrations.AddField( + model_name="projectchatmessage", + name="mentioned_user_ids", + field=models.JSONField(blank=True, default=list, editable=False), + ), + migrations.CreateModel( + name="ChatNotificationPreference", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "mode", + models.CharField( + choices=[ + ("all", "Все сообщения"), + ("mentions", "Только упоминания"), + ("mute_8h", "Без звука на 8 часов"), + ("muted", "Отключены до включения"), + ], + max_length=12, + verbose_name="Режим уведомлений", + ), + ), + ("muted_until", models.DateTimeField(null=True)), + ( + "program_channel", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="chats.programchannel", + ), + ), + ( + "project_chat", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="chats.projectchat", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.CheckConstraint( + condition=models.Q( + models.Q( + ("program_channel__isnull", True), + ("project_chat__isnull", False), + ), + models.Q( + ("program_channel__isnull", False), + ("project_chat__isnull", True), + ), + _connector="OR", + ), + name="chat_notification_has_one_chat", + ), + models.CheckConstraint( + condition=models.Q( + models.Q(("mode", "mute_8h"), ("muted_until__isnull", False)), + models.Q( + models.Q(("mode", "mute_8h"), _negated=True), + ("muted_until__isnull", True), + ), + _connector="OR", + ), + name="chat_notification_mute_has_expiry", + ), + models.UniqueConstraint( + fields=("user", "project_chat"), + name="unique_project_chat_notification_user", + ), + models.UniqueConstraint( + fields=("user", "program_channel"), + name="unique_channel_notification_user", + ), + ], + }, + ), + ] diff --git a/chats/models.py b/chats/models.py index 5d42b18c..37b0383c 100644 --- a/chats/models.py +++ b/chats/models.py @@ -318,6 +318,7 @@ class ProjectChatMessage(BaseMessage): blank=True, related_name="project_replies", ) + mentioned_user_ids = models.JSONField(default=list, blank=True, editable=False) def clean(self): # check that replied message is in the same chat @@ -452,6 +453,7 @@ class ProgramChannelMessage(BaseMessage): author = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, related_name="program_channel_posts" ) + mentioned_user_ids = models.JSONField(default=list, blank=True, editable=False) class Meta: verbose_name = "Публикация канала" @@ -483,6 +485,48 @@ class Meta: ] +class ChatNotificationPreference(models.Model): + class Mode(models.TextChoices): + ALL = "all", "Все сообщения" + MENTIONS = "mentions", "Только упоминания" + MUTE_8H = "mute_8h", "Без звука на 8 часов" + MUTED = "muted", "Отключены до включения" + + user = models.ForeignKey(User, on_delete=models.CASCADE) + project_chat = models.ForeignKey(ProjectChat, on_delete=models.CASCADE, null=True) + program_channel = models.ForeignKey( + ProgramChannel, on_delete=models.CASCADE, null=True + ) + mode = models.CharField("Режим уведомлений", max_length=12, choices=Mode.choices) + muted_until = models.DateTimeField(null=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + models.Q(project_chat__isnull=False, program_channel__isnull=True) + | models.Q(project_chat__isnull=True, program_channel__isnull=False) + ), + name="chat_notification_has_one_chat", + ), + models.CheckConstraint( + condition=( + models.Q(mode="mute_8h", muted_until__isnull=False) + | (~models.Q(mode="mute_8h") & models.Q(muted_until__isnull=True)) + ), + name="chat_notification_mute_has_expiry", + ), + models.UniqueConstraint( + fields=["user", "project_chat"], + name="unique_project_chat_notification_user", + ), + models.UniqueConstraint( + fields=["user", "program_channel"], + name="unique_channel_notification_user", + ), + ] + + class FileToMessage(models.Model): file = models.OneToOneField( UserFile, diff --git a/chats/notification_views.py b/chats/notification_views.py new file mode 100644 index 00000000..9ce2074d --- /dev/null +++ b/chats/notification_views.py @@ -0,0 +1,71 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import GenericAPIView, ListAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from chats.attachments import get_accessible_chat +from chats.models import ProgramChannel +from chats.notifications import ( + Mode, + chat_mention_recipients, + notification_settings, + set_notification_mode, +) +from chats.pagination import MessageListPagination +from chats.permissions import can_publish_in_program_channel +from users.serializers import UserChatSerializer + + +class NotificationModeInput(serializers.Serializer): + mode = serializers.ChoiceField( + choices=Mode.choices, + error_messages={ + "invalid_choice": "Выберите режим уведомлений.", + "required": "Выберите режим уведомлений.", + }, + ) + + +@method_decorator(never_cache, name="dispatch") +class ChatNotificationSettingsView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = NotificationModeInput + chat_model = None + + def get(self, request, id): + chat = get_accessible_chat(request.user, self.chat_model, id) + return Response(notification_settings(request.user, self.chat_model, chat.pk)) + + def put(self, request, id): + form = self.get_serializer(data=request.data) + form.is_valid(raise_exception=True) + return Response( + set_notification_mode( + request.user, self.chat_model, id, form.validated_data["mode"] + ) + ) + + +@method_decorator(never_cache, name="dispatch") +class ChatMentionRecipientsView(ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = UserChatSerializer + pagination_class = MessageListPagination + chat_model = None + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return self.serializer_class.Meta.model.objects.none() + chat = get_accessible_chat(self.request.user, self.chat_model, self.kwargs["id"]) + if isinstance(chat, ProgramChannel) and not can_publish_in_program_channel( + self.request.user, chat.program + ): + raise PermissionDenied("Упоминания в канале доступны менеджерам программы.") + return ( + chat_mention_recipients(chat) + .exclude(pk=self.request.user.pk) + .order_by("first_name", "last_name", "pk") + ) diff --git a/chats/notifications.py b/chats/notifications.py new file mode 100644 index 00000000..1aae9a67 --- /dev/null +++ b/chats/notifications.py @@ -0,0 +1,71 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.db.models import Q +from django.utils import timezone + +from chats.attachments import get_accessible_chat +from chats.exceptions import MessageValidationException +from chats.models import ChatNotificationPreference, ProjectChat + +MAX_MENTIONS = 20 +Mode = ChatNotificationPreference.Mode + + +def _chat_field(chat_model): + return "project_chat_id" if chat_model is ProjectChat else "program_channel_id" + + +def notification_settings(user, chat_model, chat_id): + preference = ChatNotificationPreference.objects.filter( + user=user, **{_chat_field(chat_model): chat_id} + ).first() + if preference is None or ( + preference.muted_until is not None and preference.muted_until <= timezone.now() + ): + return {"mode": Mode.ALL, "muted_until": None} + return {"mode": preference.mode, "muted_until": preference.muted_until} + + +def set_notification_mode(user, chat_model, chat_id, mode): + chat = get_accessible_chat(user, chat_model, chat_id) + if mode not in Mode.values: + raise MessageValidationException("Выберите режим уведомлений.") + muted_until = timezone.now() + timedelta(hours=8) if mode == Mode.MUTE_8H else None + ChatNotificationPreference.objects.update_or_create( + user=user, + **{_chat_field(chat_model): chat.pk}, + defaults={"mode": mode, "muted_until": muted_until}, + ) + return {"mode": mode, "muted_until": muted_until} + + +def chat_mention_recipients(chat): + users = get_user_model().objects + if isinstance(chat, ProjectChat): + return users.filter( + Q(pk=chat.project.leader_id) | Q(collaborations__project_id=chat.project_id) + ).distinct() + return users.filter( + Q(pk__in=chat.program.managers.values("pk")) + | Q(partner_program_profiles__partner_program_id=chat.program_id) + ).distinct() + + +def validate_mentions(chat, author, mentioned_user_ids): + if not isinstance(mentioned_user_ids, list) or len(mentioned_user_ids) > MAX_MENTIONS: + raise MessageValidationException("Можно упомянуть до 20 участников.") + if any(type(pk) is not int or not 0 < pk < 2**63 for pk in mentioned_user_ids): + raise MessageValidationException("Некорректный список упоминаний.") + ids = set(mentioned_user_ids) + ids.discard(author.pk) + if ids and chat_mention_recipients(chat).filter(pk__in=ids).count() != len(ids): + raise MessageValidationException("Упоминать можно только участников этого чата.") + return sorted(ids) + + +def should_notify(user, chat_model, chat_id, *, author_id, mentioned_user_ids): + if user.pk == author_id: + return False + mode = notification_settings(user, chat_model, chat_id)["mode"] + return mode == Mode.ALL or (mode == Mode.MENTIONS and user.pk in mentioned_user_ids) diff --git a/chats/program_channels.py b/chats/program_channels.py index 8c2083ec..2bd7d970 100644 --- a/chats/program_channels.py +++ b/chats/program_channels.py @@ -46,7 +46,9 @@ def _locked_channel(user, channel_id): @transaction.atomic -def publish_channel_message(*, channel_id, author, text, file_urls, client_message_id): +def publish_channel_message( + *, channel_id, author, text, file_urls, client_message_id, mentioned_user_ids=None +): channel = _locked_channel(author, channel_id) if channel is None or not can_publish_in_program_channel(author, channel.program): raise MessageValidationException("Публикации доступны менеджерам программы.") @@ -66,9 +68,11 @@ def publish_channel_message(*, channel_id, author, text, file_urls, client_messa reply_to_id=None, file_urls=file_urls, client_message_id=client_message_id, + mention_chat=channel, + mentioned_user_ids=mentioned_user_ids, ) if created: - notify_channel_change(channel.pk) + notify_channel_change(channel.pk, new_message_id=publication["id"]) return publication, created diff --git a/chats/serializers.py b/chats/serializers.py index eda5aa64..4c6d7d77 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -102,6 +102,7 @@ class Meta: "author", "text", "client_message_id", + "mentioned_user_ids", "files", "is_edited", "created_at", @@ -300,6 +301,7 @@ class Meta: "author", "text", "client_message_id", + "mentioned_user_ids", "files", "reply_to", "is_edited", diff --git a/chats/services/messages.py b/chats/services/messages.py index ad6569ef..a0aded1a 100644 --- a/chats/services/messages.py +++ b/chats/services/messages.py @@ -19,6 +19,7 @@ can_publish_in_program_channel, is_project_member, ) +from chats.notifications import validate_mentions from chats.serializers import ( DirectChatMessageListSerializer, ProgramChannelMessageSerializer, @@ -116,6 +117,8 @@ def _create_message( file_urls, client_message_id, validate_new_message=None, + mention_chat=None, + mentioned_user_ids=None, ): client_message_id = _normalize_client_message_id(client_message_id) if client_message_id is not None: @@ -143,6 +146,10 @@ def _create_message( if message_model is not ProgramChannelMessage: reply["reply_to"] = _get_reply(message_model, chat_id, reply_to_id) files = _get_files(file_urls, author) + if mention_chat is not None: + reply["mentioned_user_ids"] = validate_mentions( + mention_chat, author, [] if mentioned_user_ids is None else mentioned_user_ids + ) message = message_model.objects.create( chat_id=chat_id, author=author, @@ -228,6 +235,7 @@ def create_project_message( reply_to_id=None, file_urls=None, client_message_id=None, + mentioned_user_ids=None, ): try: chat = ( @@ -249,6 +257,8 @@ def create_project_message( reply_to_id=reply_to_id, file_urls=file_urls, client_message_id=client_message_id, + mention_chat=chat, + mentioned_user_ids=mentioned_user_ids, ) diff --git a/chats/tests/test_message_delivery.py b/chats/tests/test_message_delivery.py index 39ac83d7..6b483438 100644 --- a/chats/tests/test_message_delivery.py +++ b/chats/tests/test_message_delivery.py @@ -180,7 +180,12 @@ async def test_project_retry_is_not_broadcast_as_a_new_message(self): await communicator.send_json_to(data) first_response = await communicator.receive_json_from() - self.assertEqual(await recipient.receive_json_from(), first_response) + received = await recipient.receive_json_from() + self.assertEqual( + received["content"]["message"], first_response["content"]["message"] + ) + self.assertTrue(received["content"]["notify"]) + self.assertFalse(first_response["content"]["notify"]) await communicator.send_json_to(data) second_response = await communicator.receive_json_from() diff --git a/chats/tests/test_notifications.py b/chats/tests/test_notifications.py new file mode 100644 index 00000000..44ef16dc --- /dev/null +++ b/chats/tests/test_notifications.py @@ -0,0 +1,143 @@ +from datetime import timedelta +from unittest.mock import patch +from uuid import uuid4 + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from chats.models import ProgramChannel, ProjectChat +from chats.exceptions import MessageValidationException +from chats.notifications import should_notify +from chats.services.messages import create_project_message +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_program_channels import create_program +from chats.tests.test_project_reads import create_project_chat +from projects.models import Collaborator + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class ChatNotificationTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.manager = users.create(**TEST_USER1, is_staff=True, is_active=True) + cls.member = users.create(**TEST_USER2, is_active=True) + cls.outsider = users.create(**TEST_USER3, is_active=True) + cls.project_chat = create_project_chat(cls.manager, cls.member) + cls.program, cls.profile = create_program(cls.manager, cls.member) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.member) + + def test_modes_are_personal_and_temporary_mute_expires(self): + for kind, model, chat_id in ( + ("project-chat", ProjectChat, self.project_chat.pk), + ("program-channel", ProgramChannel, self.program.pk), + ): + with self.subTest(kind=kind): + url = reverse("chats:" + kind + "-notification-settings", args=[chat_id]) + clock = timezone.now() + decision = dict( + author_id=self.manager.pk, mentioned_user_ids=[self.member.pk] + ) + for mode, notify in (("mentions", True), ("muted", False), ("all", True)): + response = self.api.put( + url, {"mode": mode}, format="json", secure=True + ) + self.assertEqual(response.json(), {"mode": mode, "muted_until": None}) + self.assertIs( + should_notify(self.member, model, chat_id, **decision), notify + ) + with patch("chats.notifications.timezone.now", return_value=clock): + response = self.api.put( + url, {"mode": "mute_8h"}, format="json", secure=True + ) + self.assertEqual(response.json()["mode"], "mute_8h") + self.assertFalse( + should_notify(self.member, model, chat_id, **decision) + ) + self.assertTrue( + should_notify( + self.manager, + model, + chat_id, + author_id=self.member.pk, + mentioned_user_ids=[], + ) + ) + with patch( + "chats.notifications.timezone.now", + return_value=clock + timedelta(hours=8), + ): + self.assertEqual( + self.api.get(url, secure=True).json(), + {"mode": "all", "muted_until": None}, + ) + self.assertTrue( + should_notify(self.member, model, chat_id, **decision) + ) + + def test_admin_sends_mentions_and_uses_same_personal_preference(self): + self.client.force_login(self.manager) + chat_id = self.project_chat.pk + admin_name = "admin:chats_projectchat_message_search" + settings_url = reverse(admin_name + "_notifications", args=[chat_id]) + self.assertContains( + self.client.get(settings_url, secure=True), "Только упоминания" + ) + self.assertEqual( + self.client.post(settings_url, {"mode": "mentions"}, secure=True).status_code, + 302, + ) + api_url = reverse("chats:project-chat-notification-settings", args=[chat_id]) + self.api.force_authenticate(self.manager) + self.assertEqual(self.api.get(api_url, secure=True).json()["mode"], "mentions") + publish = reverse(admin_name + "_publish", args=[chat_id]) + response = self.client.post( + publish, + { + "text": "Объявление", + "mentions": [self.member.pk], + "client_message_id": str(uuid4()), + }, + secure=True, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + self.project_chat.messages.get().mentioned_user_ids, [self.member.pk] + ) + recipients_url = reverse("chats:project-chat-mention-recipients", args=[chat_id]) + self.assertEqual( + [ + user["id"] + for user in self.api.get(recipients_url, secure=True).json()["results"] + ], + [self.member.pk], + ) + Collaborator.objects.filter(project_id=chat_id, user=self.member).delete() + self.api.force_authenticate(self.member) + self.assertEqual(self.api.get(api_url, secure=True).status_code, 404) + self.assertEqual(self.api.get(recipients_url, secure=True).status_code, 404) + + def test_project_message_cannot_mention_a_user_outside_the_chat(self): + with self.assertRaises(MessageValidationException): + create_project_message( + chat_id=self.project_chat.pk, + author=self.manager, + text="Объявление", + mentioned_user_ids=[self.outsider.pk], + ) + self.assertFalse(self.project_chat.messages.exists()) + + def test_channel_recipient_list_is_for_publishers(self): + url = reverse("chats:program-channel-mention-recipients", args=[self.program.pk]) + self.assertEqual(self.api.get(url, secure=True).status_code, 403) + self.api.force_authenticate(self.manager) + self.assertEqual( + [user["id"] for user in self.api.get(url, secure=True).json()["results"]], + [self.member.pk], + ) diff --git a/chats/tests/test_program_channels.py b/chats/tests/test_program_channels.py index 27c822b2..300185cb 100644 --- a/chats/tests/test_program_channels.py +++ b/chats/tests/test_program_channels.py @@ -12,6 +12,7 @@ from rest_framework_simplejwt.tokens import AccessToken from chats.models import ProgramChannel +from chats.notifications import set_notification_mode from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 from chats.tests.test_attachment_download import STORAGE, upstream_file from files.models import UserFile @@ -269,9 +270,34 @@ async def test_channel_change_is_delivered_only_while_member_has_access(self): await socket.receive_json_from(timeout=5), { "type": "program_channel_changed", - "content": {"channel_id": self.program.pk}, + "content": { + "channel_id": self.program.pk, + "notify": True, + "message_id": response.json()["id"], + }, }, ) + await database_sync_to_async(set_notification_mode)( + self.member, ProgramChannel, self.program.pk, "mentions" + ) + post = database_sync_to_async(api.post) + ordinary = await post( + url, {"text": "Без упоминания"}, format="json", secure=True + ) + self.assertEqual(ordinary.status_code, 201) + self.assertFalse( + (await socket.receive_json_from(timeout=5))["content"]["notify"] + ) + mentioned = await post( + url, + {"text": "С упоминанием", "mentioned_user_ids": [self.member.pk]}, + format="json", + secure=True, + ) + self.assertEqual(mentioned.status_code, 201) + self.assertTrue( + (await socket.receive_json_from(timeout=5))["content"]["notify"] + ) await database_sync_to_async(self.profile.delete)() response = await database_sync_to_async(api.post)( url, {"text": "Следующая публикация"}, format="json", secure=True diff --git a/chats/urls.py b/chats/urls.py index b1ddcbcb..f9f00e32 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -9,6 +9,10 @@ ) from chats.direct_views import DirectBlockView, DirectPrivacyView, DirectSpamReportView from chats.models import DirectChat, ProgramChannel, ProjectChat +from chats.notification_views import ( + ChatMentionRecipientsView, + ChatNotificationSettingsView, +) from chats.serializers import ( DirectChatMessageListSerializer, ProgramChannelMessageSerializer, @@ -33,6 +37,26 @@ app_name = "chats" urlpatterns = [ + path( + "channels//notification-settings/", + ChatNotificationSettingsView.as_view(chat_model=ProgramChannel), + name="program-channel-notification-settings", + ), + path( + "channels//mention-recipients/", + ChatMentionRecipientsView.as_view(chat_model=ProgramChannel), + name="program-channel-mention-recipients", + ), + path( + "projects//notification-settings/", + ChatNotificationSettingsView.as_view(chat_model=ProjectChat), + name="project-chat-notification-settings", + ), + path( + "projects//mention-recipients/", + ChatMentionRecipientsView.as_view(chat_model=ProjectChat), + name="project-chat-mention-recipients", + ), path("channels/", ProgramChannelList.as_view(), name="program-channel-list"), path( "channels//", diff --git a/docs/modules/chats.md b/docs/modules/chats.md index ff753b8f..0d3ad78b 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -143,6 +143,28 @@ WebSocket посылает `program_channel_changed` с `content.channel_id` п `subscribe_program_channel` с `content.channel_id` и дождитесь подтверждения того же типа. +## Уведомления командных чатов и каналов + +Маршруты `/chats/projects//notification-settings/` и +`/chats/channels//notification-settings/` читают персональную настройку +через GET и сохраняют ее через PUT с полем `mode`. Ответ содержит `mode` +и `muted_until` в ISO 8601 или `null`. Режимы `all`, `mentions`, `mute_8h` +и `muted` соответствуют всем сообщениям, только упоминаниям, паузе на восемь +часов и отключению до ручного включения. После паузы действует `all`. + +GET по маршруту `mention-recipients/` в чате или канале возвращает +доступных для упоминания участников. В каналах этот список доступен менеджерам +программы. Клиент передает до 20 числовых ID в +`mentioned_user_ids` при отправке командного сообщения или публикации. +Сервер проверяет участие и сохраняет список вместе с сообщением. + +События `new_message` командного чата и `program_channel_changed` содержат +персональное поле `content.notify`. Для уведомления о публикации в канале +также приходит `content.message_id`. Клиент обновляет историю и непрочитанные +по событиям в любом режиме, а уведомление показывает при `notify: true`. +Повторы отправки, собственные сообщения, правки и удаления не создают нового +уведомления. Настройки и отправка с упоминаниями доступны и в Django-админке. + ## WebSocket WebSocket endpoint: diff --git a/templates/admin/chats/channel_publish.html b/templates/admin/chats/channel_publish.html index 73713ef2..f9581f48 100644 --- a/templates/admin/chats/channel_publish.html +++ b/templates/admin/chats/channel_publish.html @@ -1,7 +1,7 @@ {% extends "admin/base_site.html" %} {% block content %} -

К публикациям канала

+

К переписке

{% csrf_token %} {{ upload_form.as_p }} @@ -10,6 +10,6 @@ {% csrf_token %} {{ form.as_p }} - +
{% endblock %} diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html index c6c1886a..78874204 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -1,11 +1,14 @@ {% extends "admin/base_site.html" %} {% block content %} + {% if notification_settings_url %} +

Настройки уведомлений

+ {% endif %} {% if direct_privacy_url %}

Настройки личных сообщений

{% endif %} {% if channel_publish_url %} -

Опубликовать в канале

+

{% if channel_read_route %}Опубликовать в канале{% else %}Написать в чат{% endif %}

{% endif %}
{{ form.as_p }} diff --git a/templates/admin/chats/notification_settings.html b/templates/admin/chats/notification_settings.html new file mode 100644 index 00000000..59301366 --- /dev/null +++ b/templates/admin/chats/notification_settings.html @@ -0,0 +1,11 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

К переписке

+ {% if muted_until %}

Уведомления без звука до {{ muted_until|date:"d.m.Y H:i" }}.

{% endif %} + + {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %}