From 5d66485cce190e67e2ac342134e19c75f18437cf Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 19:49:15 +0300 Subject: [PATCH 01/16] Make chat message sends transactional and idempotent --- chats/consumers/chat.py | 16 +- chats/consumers/event_types/DirectEvent.py | 63 ++--- chats/consumers/event_types/ProjectEvent.py | 57 +--- chats/exceptions.py | 2 +- ...tchatmessage_client_message_id_and_more.py | 41 +++ chats/models.py | 32 ++- chats/serializers.py | 2 + chats/services/__init__.py | 6 + chats/services/messages.py | 199 ++++++++++++++ chats/tests/test_message_delivery.py | 247 ++++++++++++++++++ chats/utils.py | 107 +------- 11 files changed, 553 insertions(+), 219 deletions(-) create mode 100644 chats/migrations/0012_directchatmessage_client_message_id_and_more.py create mode 100644 chats/services/__init__.py create mode 100644 chats/services/messages.py create mode 100644 chats/tests/test_message_delivery.py diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index a93d615f..305d7f22 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -3,7 +3,6 @@ from json import JSONDecodeError from typing import Optional -from asgiref.sync import sync_to_async from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.conf import settings @@ -208,22 +207,11 @@ async def __process_typing_event(self, event: Event, room_name: str): event_data["_project_chat_id"] = event.content["chat_id"] if event.content["chat_type"] == ChatType.DIRECT: - # fixme: need to move this to func - chat_id, other_user = await get_chat_and_user_ids_from_content( + _, other_user = await get_chat_and_user_ids_from_content( event.content, self.user ) - # if chat_id == 17_7, then chat_id will be == 7_17 - chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) - - # check if chat exists - try: - await sync_to_async(DirectChat.objects.get)(pk=chat_id) - except DirectChat.DoesNotExist: - # if not, create such chat - await sync_to_async(DirectChat.create_from_two_users)( - self.user, other_user - ) + await database_sync_to_async(DirectChat.get_chat)(self.user, other_user) # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) diff --git a/chats/consumers/event_types/DirectEvent.py b/chats/consumers/event_types/DirectEvent.py index 37d95053..3fa43b9a 100644 --- a/chats/consumers/event_types/DirectEvent.py +++ b/chats/consumers/event_types/DirectEvent.py @@ -1,3 +1,5 @@ +from channels.db import database_sync_to_async + from chats.models import DirectChatMessage, DirectChat from chats.websockets_settings import Event, EventType from chats.exceptions import ( @@ -8,16 +10,12 @@ from django.core.cache import cache from chats.utils import ( get_user_channel_cache_key, - create_message, get_chat_and_user_ids_from_content, - match_files_and_messages, - orm_create, - orm_exists, orm_get, orm_save, - orm_set, ) from chats.serializers import DirectChatMessageListSerializer +from chats.services import create_direct_message class DirectEvent: @@ -27,53 +25,22 @@ def __init__(self, user, channel_layer, channel_name): self.channel_name = channel_name async def process_new_message_event(self, event: Event, room_name: str): - chat_id, other_user = await get_chat_and_user_ids_from_content( - event.content, self.user - ) - - # if chat_id == 17_7, then chat_id will be == 7_17 + _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) - # check if chat exists - if not await orm_exists(DirectChat.objects.filter(pk=chat_id)): - # if not, create such chat - chat = await orm_create(DirectChat.objects, pk=chat_id) - await orm_set(chat.users, [self.user, other_user]) - - reply_to_message = None - if event.content["reply_to"] is not None: - try: - reply_to_message = await orm_get( - DirectChatMessage.objects, pk=event.content["reply_to"] - ) - except DirectChatMessage.DoesNotExist: - reply_to_message = None - - msg = await create_message( - chat_id=chat_id, - chat_model=DirectChatMessage, + message_data, created = await database_sync_to_async(create_direct_message)( author=self.user, - text=event.content["text"], - reply_to=reply_to_message, - ) - - messages = { - "direct_message": msg, - "project_message": None, - } - await match_files_and_messages(event.content["file_urls"], messages) - - serialized_message = await orm_get( - DirectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( - "file_to_message__file" - ), - pk=msg.pk, + other_user=other_user, + text=event.content.get("text", ""), + reply_to_id=event.content.get("reply_to"), + file_urls=event.content.get("file_urls", []), + client_message_id=event.content.get("client_message_id"), ) - message_data = DirectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, + "is_replay": not created, } # send message to user's channel @@ -83,7 +50,7 @@ async def process_new_message_event(self, event: Event, room_name: str): await self.channel_layer.send(self.channel_name, event_data) - if other_user_channel: + if created and other_user_channel and other_user_channel != self.channel_name: await self.channel_layer.send(other_user_channel, event_data) async def process_read_message_event(self, event: Event, room_name: str): @@ -164,9 +131,9 @@ async def process_edit_message_event(self, event, room_name): await orm_save(msg, update_fields=["text", "is_edited"]) serialized_message = await orm_get( - DirectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( - "file_to_message__file" - ), + DirectChatMessage.objects.select_related( + "author", "reply_to__author" + ).prefetch_related("file_to_message__file"), pk=msg.pk, ) message_data = DirectChatMessageListSerializer(serialized_message).data diff --git a/chats/consumers/event_types/ProjectEvent.py b/chats/consumers/event_types/ProjectEvent.py index 96461184..d74b3368 100644 --- a/chats/consumers/event_types/ProjectEvent.py +++ b/chats/consumers/event_types/ProjectEvent.py @@ -1,11 +1,12 @@ +from channels.db import database_sync_to_async + from chats.models import ProjectChat, ProjectChatMessage from chats.utils import ( - create_message, - match_files_and_messages, orm_exists, orm_get, orm_save, ) +from chats.services import create_project_message from chats.websockets_settings import Event, EventType from chats.exceptions import ( WrongChatIdException, @@ -28,53 +29,18 @@ def __init__(self, user, channel_layer, channel_name): async def process_new_message_event(self, event: Event, room_name: str): chat_id = event.content["chat_id"] - chat = await orm_get( - ProjectChat.objects.select_related("project__leader"), - pk=chat_id, - ) - - # check that user is in this chat - is_member = chat.project.leader_id == self.user.id or await orm_exists( - Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) - ) - if not is_member: - raise UserNotInChatException( - f"User {self.user.id} is not in project chat {chat_id}" - ) - - reply_to_message = None - if event.content["reply_to"] is not None: - try: - reply_to_message = await orm_get( - ProjectChatMessage.objects, pk=event.content["reply_to"] - ) - except ProjectChatMessage.DoesNotExist: - reply_to_message = None - - msg = await create_message( + message_data, created = await database_sync_to_async(create_project_message)( chat_id=chat_id, - chat_model=ProjectChatMessage, author=self.user, - text=event.content["text"], - reply_to=reply_to_message, + text=event.content.get("text", ""), + reply_to_id=event.content.get("reply_to"), + file_urls=event.content.get("file_urls", []), + client_message_id=event.content.get("client_message_id"), ) - - messages = { - "direct_message": None, - "project_message": msg, - } - await match_files_and_messages(event.content["file_urls"], messages) - - serialized_message = await orm_get( - ProjectChatMessage.objects.select_related( - "author", "reply_to__author" - ).prefetch_related("file_to_message__file"), - pk=msg.pk, - ) - message_data = ProjectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, + "is_replay": not created, } event_data = { @@ -83,7 +49,10 @@ async def process_new_message_event(self, event: Event, room_name: str): "_project_chat_id": chat_id, } - await self.channel_layer.group_send(room_name, event_data) + if created: + await self.channel_layer.group_send(room_name, event_data) + else: + await self.channel_layer.send(self.channel_name, event_data) async def process_read_message_event(self, event: Event, room_name: str): msg = await orm_get(ProjectChatMessage.objects, pk=event.content["message_id"]) diff --git a/chats/exceptions.py b/chats/exceptions.py index fd9a2a80..ee09075d 100644 --- a/chats/exceptions.py +++ b/chats/exceptions.py @@ -23,5 +23,5 @@ class UserNotMessageAuthorException(ChatException): pass -class NonMatchingReplyChatIdException(ChatException): +class MessageValidationException(ChatException): pass diff --git a/chats/migrations/0012_directchatmessage_client_message_id_and_more.py b/chats/migrations/0012_directchatmessage_client_message_id_and_more.py new file mode 100644 index 00000000..9303db59 --- /dev/null +++ b/chats/migrations/0012_directchatmessage_client_message_id_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.18 on 2026-11-13 16:33 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0011_alter_filetomessage_direct_message_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="directchatmessage", + name="client_message_id", + field=models.UUIDField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name="projectchatmessage", + name="client_message_id", + field=models.UUIDField(blank=True, editable=False, null=True), + ), + migrations.AddConstraint( + model_name="directchatmessage", + constraint=models.UniqueConstraint( + condition=models.Q(("client_message_id__isnull", False)), + fields=("chat", "author", "client_message_id"), + name="unique_direct_chat_client_message", + ), + ), + migrations.AddConstraint( + model_name="projectchatmessage", + constraint=models.UniqueConstraint( + condition=models.Q(("client_message_id__isnull", False)), + fields=("chat", "author", "client_message_id"), + name="unique_project_chat_client_message", + ), + ), + ] diff --git a/chats/models.py b/chats/models.py index 3a84ddbd..90e1ca27 100644 --- a/chats/models.py +++ b/chats/models.py @@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError -from django.db import models +from django.db import models, transaction from files.models import UserFile from projects.models import Project @@ -137,6 +137,7 @@ def get_avatar(self, user): return other_user.avatar @classmethod + @transaction.atomic def get_chat(cls, user1, user2) -> "DirectChat": """ Returns chat between two users. @@ -150,12 +151,10 @@ def get_chat(cls, user1, user2) -> "DirectChat": """ pk = cls.get_chat_id_from_users(user1, user2) - try: - return cls.objects.get(pk=pk) - except cls.DoesNotExist: - chat = cls.objects.create(pk=pk) + chat, created = cls.objects.get_or_create(pk=pk) + if created: chat.users.set([user1, user2]) - return chat + return chat def get_last_messages(self, message_count): return self.messages.order_by("-created_at")[:message_count] @@ -163,12 +162,6 @@ def get_last_messages(self, message_count): def get_other_user(self, user) -> User: return self.users.exclude(pk=user.pk).first() - @classmethod - def create_from_two_users(cls, user1, user2): - chat = cls.objects.create(pk=cls.get_chat_id_from_users(user1, user2)) - chat.users.set([user1, user2]) - return chat - @classmethod def get_chat_id_from_users(cls, user1, user2): first_user = user1 if user1.pk < user2.pk else user2 @@ -198,6 +191,7 @@ class BaseMessage(models.Model): is_read = models.BooleanField(default=False) is_deleted = models.BooleanField(default=False) is_edited = models.BooleanField(default=False) + client_message_id = models.UUIDField(null=True, blank=True, editable=False) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): @@ -246,6 +240,13 @@ def __str__(self): class Meta: verbose_name = "Сообщение в чате проекта" verbose_name_plural = "Сообщения в чатах проектов" + constraints = [ + models.UniqueConstraint( + fields=["chat", "author", "client_message_id"], + condition=models.Q(client_message_id__isnull=False), + name="unique_project_chat_client_message", + ) + ] class DirectChatMessage(BaseMessage): @@ -284,6 +285,13 @@ def __str__(self): class Meta: verbose_name = "Сообщение в личном чате" verbose_name_plural = "Сообщения в личных чатах" + constraints = [ + models.UniqueConstraint( + fields=["chat", "author", "client_message_id"], + condition=models.Q(client_message_id__isnull=False), + name="unique_direct_chat_client_message", + ) + ] class FileToMessage(models.Model): diff --git a/chats/serializers.py b/chats/serializers.py index 27c8cc70..44110f6e 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -147,6 +147,7 @@ class Meta: "id", "author", "text", + "client_message_id", "reply_to", "files", "is_edited", @@ -196,6 +197,7 @@ class Meta: "id", "author", "text", + "client_message_id", "files", "reply_to", "is_edited", diff --git a/chats/services/__init__.py b/chats/services/__init__.py new file mode 100644 index 00000000..c3ec9741 --- /dev/null +++ b/chats/services/__init__.py @@ -0,0 +1,6 @@ +from chats.services.messages import ( + create_direct_message, + create_project_message, +) + +__all__ = ["create_direct_message", "create_project_message"] diff --git a/chats/services/messages.py b/chats/services/messages.py new file mode 100644 index 00000000..4fa74daa --- /dev/null +++ b/chats/services/messages.py @@ -0,0 +1,199 @@ +from uuid import UUID + +from django.db import transaction + +from chats.exceptions import MessageValidationException +from chats.models import ( + DirectChat, + DirectChatMessage, + FileToMessage, + ProjectChat, + ProjectChatMessage, +) +from chats.permissions import is_project_member +from chats.serializers import ( + DirectChatMessageListSerializer, + ProjectChatMessageListSerializer, +) +from files.models import UserFile + + +def _normalize_client_message_id(value): + if value in (None, ""): + return None + try: + return UUID(str(value)) + except (TypeError, ValueError, AttributeError) as error: + raise MessageValidationException( + "Некорректный идентификатор отправляемого сообщения." + ) from error + + +def _normalize_message(text, file_urls): + if not isinstance(text, str) or "\x00" in text: + raise MessageValidationException("Некорректный текст сообщения.") + if file_urls is None: + file_urls = [] + if not isinstance(file_urls, list) or any( + not isinstance(url, str) or not url or "\x00" in url for url in file_urls + ): + raise MessageValidationException("Некорректный список вложений.") + if not text.strip() and not file_urls: + raise MessageValidationException("Сообщение не может быть пустым.") + if len(text) > DirectChatMessage._meta.get_field("text").max_length: + raise MessageValidationException( + "Текст сообщения не должен превышать 8192 символа." + ) + return list(dict.fromkeys(file_urls)) + + +def _get_reply(message_model, chat_id, reply_to_id): + if reply_to_id is None: + return None + try: + return message_model.objects.get( + pk=reply_to_id, + chat_id=chat_id, + is_deleted=False, + ) + except (message_model.DoesNotExist, TypeError, ValueError) as error: + raise MessageValidationException( + "Нельзя ответить на сообщение из другого чата или удаленное сообщение." + ) from error + + +def _get_files(file_urls, author): + if not file_urls: + return [] + files = { + user_file.pk: user_file + for user_file in UserFile.objects.select_for_update() + .filter( + pk__in=file_urls, + user_id=author.pk, + ) + .order_by("pk") + } + if len(files) != len(file_urls): + raise MessageValidationException( + "Один или несколько файлов недоступны для отправки." + ) + if FileToMessage.objects.filter(file_id__in=file_urls).exists(): + raise MessageValidationException( + "Один или несколько файлов уже прикреплены к сообщению." + ) + return [files[url] for url in file_urls] + + +def _serialize_message(message_model, serializer_class, message_id): + message = ( + message_model.objects.select_related("author", "reply_to__author") + .prefetch_related("file_to_message__file") + .get(pk=message_id) + ) + return serializer_class(message).data + + +def _create_message( + *, + message_model, + serializer_class, + message_link_field, + chat_id, + author, + text, + reply_to_id, + file_urls, + client_message_id, +): + client_message_id = _normalize_client_message_id(client_message_id) + if client_message_id is not None: + existing_message = message_model.objects.filter( + chat_id=chat_id, + author=author, + client_message_id=client_message_id, + ).first() + if existing_message is not None: + if existing_message.is_deleted: + raise MessageValidationException("Сообщение уже удалено.") + return ( + _serialize_message( + message_model, + serializer_class, + existing_message.pk, + ), + False, + ) + + file_urls = _normalize_message(text, file_urls) + reply_to = _get_reply(message_model, chat_id, reply_to_id) + files = _get_files(file_urls, author) + message = message_model.objects.create( + chat_id=chat_id, + author=author, + text=text, + reply_to=reply_to, + client_message_id=client_message_id, + ) + for user_file in files: + FileToMessage.objects.create(file=user_file, **{message_link_field: message}) + return _serialize_message(message_model, serializer_class, message.pk), True + + +@transaction.atomic +def create_direct_message( + *, + author, + other_user, + text, + reply_to_id=None, + file_urls=None, + client_message_id=None, +): + chat = DirectChat.get_chat(author, other_user) + # Serialize sends in this chat before checking for a repeat or locking files. + DirectChat.objects.select_for_update().get(pk=chat.pk) + return _create_message( + message_model=DirectChatMessage, + serializer_class=DirectChatMessageListSerializer, + message_link_field="direct_message", + chat_id=chat.pk, + author=author, + text=text, + reply_to_id=reply_to_id, + file_urls=file_urls, + client_message_id=client_message_id, + ) + + +@transaction.atomic +def create_project_message( + *, + chat_id, + author, + text, + reply_to_id=None, + file_urls=None, + client_message_id=None, +): + try: + chat = ( + ProjectChat.objects.select_related("project") + .select_for_update(of=("self",)) + .get(pk=chat_id) + ) + except (ProjectChat.DoesNotExist, TypeError, ValueError) as error: + raise MessageValidationException("У вас нет доступа к чату проекта.") from error + if not is_project_member(author, chat.project): + raise MessageValidationException("У вас нет доступа к чату проекта.") + return _create_message( + message_model=ProjectChatMessage, + serializer_class=ProjectChatMessageListSerializer, + message_link_field="project_message", + chat_id=chat.pk, + author=author, + text=text, + reply_to_id=reply_to_id, + file_urls=file_urls, + client_message_id=client_message_id, + ) diff --git a/chats/tests/test_message_delivery.py b/chats/tests/test_message_delivery.py new file mode 100644 index 00000000..a3b68b67 --- /dev/null +++ b/chats/tests/test_message_delivery.py @@ -0,0 +1,247 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest.mock import patch +from uuid import uuid4 + +from channels.testing import WebsocketCommunicator +from django.contrib.auth import get_user_model +from django.db import IntegrityError, connections +from django.test import TransactionTestCase, override_settings, skipUnlessDBFeature + +from chats.consumers import ChatConsumer +from chats.models import DirectChat, DirectChatMessage, FileToMessage, ProjectChat +from chats.models import ProjectChatMessage +from chats.services import create_direct_message, create_project_message +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from files.models import UserFile +from projects.models import Collaborator, Project + + +class MessageDeliveryTests(TransactionTestCase): + reset_sequences = True + + def setUp(self): + super().setUp() + self.user = get_user_model().objects.create(**TEST_USER1) + + async def connect(self, user=None): + communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + communicator.scope["user"] = user or self.user + connected, _ = await communicator.connect(timeout=5) + self.assertTrue(connected) + return communicator + + @staticmethod + def direct_message(chat_id, **overrides): + content = { + "chat_type": "direct", + "chat_id": chat_id, + "text": "Тестовое сообщение", + "reply_to": None, + "file_urls": [], + "client_message_id": str(uuid4()), + } + content.update(overrides) + return {"type": "new_message", "content": content} + + async def test_direct_retry_returns_original_message_without_duplicate(self): + communicator = await self.connect() + data = self.direct_message("1_1") + + await communicator.send_json_to(data) + first_response = await communicator.receive_json_from() + await communicator.send_json_to(data) + second_response = await communicator.receive_json_from() + + self.assertEqual(DirectChatMessage.objects.count(), 1) + self.assertEqual( + first_response["content"]["message"]["id"], + second_response["content"]["message"]["id"], + ) + self.assertFalse(first_response["content"]["is_replay"]) + self.assertTrue(second_response["content"]["is_replay"]) + self.assertEqual( + second_response["content"]["message"]["client_message_id"], + data["content"]["client_message_id"], + ) + self.assertTrue(await communicator.receive_nothing()) + await communicator.disconnect() + + async def test_file_only_message_can_be_retried(self): + user_file = UserFile.objects.create( + link="https://cdn.example.test/chat-file.pdf", + user=self.user, + name="chat-file", + extension="pdf", + mime_type="application/pdf", + detected_mime_type="application/pdf", + size=128, + ) + communicator = await self.connect() + data = self.direct_message( + "1_1", + text="", + file_urls=[user_file.pk], + ) + + await communicator.send_json_to(data) + response = await communicator.receive_json_from() + await communicator.send_json_to(data) + replay = await communicator.receive_json_from() + + self.assertNotIn("error", response) + self.assertEqual(DirectChatMessage.objects.count(), 1) + self.assertEqual(FileToMessage.objects.count(), 1) + self.assertEqual(len(response["content"]["message"]["files"]), 1) + self.assertTrue(replay["content"]["is_replay"]) + self.assertEqual(replay["content"]["message"], response["content"]["message"]) + await communicator.disconnect() + + async def test_file_from_another_account_is_rejected(self): + other_user = get_user_model().objects.create(**TEST_USER2) + user_file = UserFile.objects.create( + link="https://cdn.example.test/foreign-file.pdf", + user=other_user, + name="foreign-file", + extension="pdf", + mime_type="application/pdf", + detected_mime_type="application/pdf", + size=128, + ) + communicator = await self.connect() + data = self.direct_message("1_1", file_urls=[user_file.pk]) + + await communicator.send_json_to(data) + response = await communicator.receive_json_from() + + self.assertIn("error", response) + self.assertEqual(DirectChatMessage.objects.count(), 0) + self.assertEqual(DirectChat.objects.count(), 0) + await communicator.disconnect() + + async def test_reply_to_another_chat_is_rejected(self): + get_user_model().objects.create(**TEST_USER2) + get_user_model().objects.create(**TEST_USER3) + communicator = await self.connect() + + await communicator.send_json_to(self.direct_message("1_2")) + first_response = await communicator.receive_json_from() + reply_id = first_response["content"]["message"]["id"] + await communicator.send_json_to(self.direct_message("1_3", reply_to=reply_id)) + response = await communicator.receive_json_from() + + self.assertIn("error", response) + self.assertEqual(DirectChatMessage.objects.count(), 1) + await communicator.disconnect() + + +class ProjectMessageDeliveryTests(TransactionTestCase): + reset_sequences = True + + def setUp(self): + super().setUp() + self.leader = get_user_model().objects.create(**TEST_USER1) + self.member = get_user_model().objects.create(**TEST_USER2) + self.project = Project.objects.create(leader=self.leader) + self.chat, _ = ProjectChat.objects.get_or_create( + id=self.project.pk, + defaults={"project": self.project}, + ) + Collaborator.objects.create( + user=self.member, + project=self.project, + role="User", + ) + + async def connect(self, user): + communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + communicator.scope["user"] = user + connected, _ = await communicator.connect(timeout=5) + self.assertTrue(connected) + return communicator + + @override_settings(RUNNING_TESTS=False) + async def test_project_retry_is_not_broadcast_as_a_new_message(self): + client_message_id = str(uuid4()) + data = { + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": str(self.chat.pk), + "text": "Сообщение проекта", + "reply_to": None, + "file_urls": [], + "client_message_id": client_message_id, + }, + } + communicator = await self.connect(self.member) + recipient = await self.connect(self.leader) + self.assertEqual((await communicator.receive_json_from())["type"], "set_online") + + await communicator.send_json_to(data) + first_response = await communicator.receive_json_from() + self.assertEqual(await recipient.receive_json_from(), first_response) + await communicator.send_json_to(data) + second_response = await communicator.receive_json_from() + + self.assertEqual(ProjectChatMessage.objects.count(), 1) + self.assertEqual( + first_response["content"]["message"]["id"], + second_response["content"]["message"]["id"], + ) + self.assertFalse(first_response["content"]["is_replay"]) + self.assertTrue(second_response["content"]["is_replay"]) + self.assertTrue(await recipient.receive_nothing()) + await communicator.disconnect() + await recipient.disconnect() + + @skipUnlessDBFeature("has_select_for_update") + def test_simultaneous_project_retries_keep_one_message_and_one_attachment(self): + user_file = UserFile.objects.create( + link="https://cdn.example.test/concurrent.pdf", user=self.member + ) + client_message_id = uuid4() + ready = Barrier(2) + + def send(): + try: + ready.wait(timeout=10) + return create_project_message( + chat_id=self.chat.pk, + author=self.member, + text="", + file_urls=[user_file.pk], + client_message_id=client_message_id, + ) + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = [pool.submit(send) for _ in range(2)] + (first, first_created), (second, second_created) = [ + result.result(timeout=10) for result in results + ] + + self.assertCountEqual([first_created, second_created], [True, False]) + self.assertEqual(first["id"], second["id"]) + self.assertEqual(ProjectChatMessage.objects.count(), 1) + self.assertEqual(FileToMessage.objects.count(), 1) + + def test_failed_attachment_write_rolls_back_new_direct_chat_and_message(self): + user_file = UserFile.objects.create( + link="https://cdn.example.test/rollback.pdf", user=self.member + ) + with ( + patch.object(FileToMessage.objects, "create", side_effect=IntegrityError), + self.assertRaises(IntegrityError), + ): + create_direct_message( + author=self.member, + other_user=self.leader, + text="", + file_urls=[user_file.pk], + client_message_id=uuid4(), + ) + self.assertFalse(DirectChat.objects.exists()) + self.assertFalse(DirectChatMessage.objects.exists()) + self.assertFalse(FileToMessage.objects.exists()) diff --git a/chats/utils.py b/chats/utils.py index e92ffc05..8d85a7a0 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -1,16 +1,10 @@ -from typing import Union, Type - from django.conf import settings from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError from chats.exceptions import ( - NonMatchingReplyChatIdException, WrongChatIdException, NonMatchingDirectChatIdException, ) -from chats.models import DirectChatMessage, ProjectChatMessage, FileToMessage -from files.models import UserFile User = get_user_model() @@ -21,12 +15,6 @@ async def orm_get(queryset, **kwargs): return await queryset.aget(**kwargs) -async def orm_create(manager, **kwargs): - if settings.RUNNING_TESTS: - return manager.create(**kwargs) - return await manager.acreate(**kwargs) - - async def orm_exists(queryset) -> bool: if settings.RUNNING_TESTS: return queryset.exists() @@ -40,78 +28,17 @@ async def orm_save(instance, update_fields=None): await instance.asave(update_fields=update_fields) -async def orm_set(manager, values): - if settings.RUNNING_TESTS: - manager.set(values) - return - await manager.aset(values) - - -def clean_message_text(text: str) -> str: - """ - Cleans message text. - - """ - - return text.strip() - - -def validate_message_text(text: str) -> bool: - """ - Validates message text. - - """ - # TODO: add bad word filter - return 0 < len(text) <= 8192 - - def get_user_channel_cache_key(user: User) -> str: return f"user_channel_{user.pk}" -async def create_message( - chat_id: Union[str, int], - chat_model: Union[Type[DirectChatMessage], Type[ProjectChatMessage]], - author: User, - text: str, - reply_to: int = None, -) -> Union[DirectChatMessage, ProjectChatMessage]: - """ - Creates message. - - Args: - chat_id: An integer representing chat id. - chat_model: A chat model. - author: A user instance. - text: A string representing message text. - reply_to: An integer representing message id to reply to. - - Returns: - A message instance. - - Raises: - NonMatchingReplyChatIdException: If reply_to message is not in chat. - """ - - try: - return await orm_create( - chat_model.objects, - chat_id=chat_id, - author=author, - text=text, - reply_to=reply_to, - ) - except ValidationError: - raise NonMatchingReplyChatIdException( - f"Message {reply_to} is not in chat {chat_id}" - ) - - async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str, User]: chat_id = content["chat_id"] # check if chat_id is in the format of _ try: user1_id, user2_id = map(int, chat_id.split("_")) - except ValueError: + except (AttributeError, ValueError): raise WrongChatIdException( f'Chat id "{chat_id}" is not in the format of' f" _, where user1_id < user2_id" @@ -119,10 +46,12 @@ async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str # check if user is a member of this chat and get other user if user1_id == current_user.id or user2_id == current_user.id: - other_user = await orm_get( - User.objects, - id=user1_id if user1_id != current_user.id else user2_id - ) + try: + other_user = await orm_get( + User.objects, id=user1_id if user1_id != current_user.id else user2_id + ) + except User.DoesNotExist as error: + raise WrongChatIdException("Собеседник не найден.") from error else: raise NonMatchingDirectChatIdException( f"User {current_user.id} is not a member of chat {chat_id}" @@ -130,28 +59,6 @@ async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str return chat_id, other_user -async def create_file_to_message( - direct_message: Union[str, None, DirectChatMessage], - project_message: Union[str, None, ProjectChatMessage], - file: str, -) -> FileToMessage: - return await orm_create( - FileToMessage.objects, - direct_message=direct_message, project_message=project_message, file=file - ) - - -async def match_files_and_messages(file_urls, messages): - for url in file_urls: - file = await orm_get(UserFile.objects, pk=url) - # implicitly matches a file and a message - await create_file_to_message( - direct_message=messages["direct_message"], - project_message=messages["project_message"], - file=file, - ) - - def get_all_files(messages): # looks like something bad - files = [] From 8b7fde104f81610556e3481d998cb964c6b281cd Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 20:02:28 +0300 Subject: [PATCH 02/16] Track project chat message reads per member --- chats/admin.py | 41 +++- chats/consumers/chat.py | 26 ++- chats/consumers/event_types/ProjectEvent.py | 64 ++---- .../0013_project_chat_message_reads.py | 55 +++++ chats/models.py | 16 ++ chats/project_reads.py | 122 ++++++++++ chats/serializers.py | 45 +++- chats/tests/test_project.py | 13 +- chats/tests/test_project_reads.py | 214 ++++++++++++++++++ chats/views.py | 42 ++-- users/models.py | 4 +- 11 files changed, 569 insertions(+), 73 deletions(-) create mode 100644 chats/migrations/0013_project_chat_message_reads.py create mode 100644 chats/project_reads.py create mode 100644 chats/tests/test_project_reads.py diff --git a/chats/admin.py b/chats/admin.py index 3609acda..4bc448b5 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -1,5 +1,10 @@ -from django.contrib import admin +from functools import partial +from asgiref.sync import async_to_sync +from django.contrib import admin, messages +from django.db import transaction + +from chats.exceptions import MessageValidationException from chats.models import ( ProjectChat, DirectChat, @@ -7,6 +12,7 @@ DirectChatMessage, FileToMessage, ) +from chats.project_reads import mark_project_message_read, publish_project_message_read @admin.display(description="Пользователи чата") @@ -38,6 +44,39 @@ class DirectChatAdmin(admin.ModelAdmin): class ProjectChatMessageAdmin(admin.ModelAdmin): list_display = ("id", "author", "chat", "created_at") list_display_links = ("id", "author", "chat") + exclude = ("is_read",) + actions = ("mark_read_for_me",) + + @admin.action(permissions=["view"], description="Отметить как прочитанные для себя") + def mark_read_for_me(self, request, queryset): + try: + with transaction.atomic(): + marked = 0 + for chat_id, message_id in queryset.order_by("pk").values_list( + "chat_id", "pk" + ): + author_id, read_count, created = mark_project_message_read( + chat_id=chat_id, message_id=message_id, user=request.user + ) + if created: + marked += 1 + transaction.on_commit( + partial( + async_to_sync(publish_project_message_read), + chat_id=chat_id, + message_id=message_id, + user_id=request.user.pk, + author_id=author_id, + read_count=read_count, + ), + robust=True, + ) + except MessageValidationException as error: + self.message_user(request, str(error), level=messages.ERROR) + else: + self.message_user( + request, f"Отмечено прочитанными: {marked}", level=messages.SUCCESS + ) @admin.register(DirectChatMessage) diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index 305d7f22..3fadc458 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -12,6 +12,7 @@ from chats.exceptions import ChatException from chats.models import BaseChat +from chats.project_reads import personalize_project_message_event from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ( Event, @@ -157,7 +158,10 @@ async def receive_json(self, content, **kwargs): self.user, self.channel_layer, self.channel_name ) - room_name = f"{EventGroupType.CHATS_RELATED}_{event.content.get('chat_id')}" + chat_id = event.content.get("chat_id") + if event.content["chat_type"] == ChatType.PROJECT: + chat_id = int(chat_id) + room_name = f"{EventGroupType.CHATS_RELATED}_{chat_id}" if event.content["chat_type"] == ChatType.PROJECT: await self._ensure_room_subscription(room_name) try: @@ -165,12 +169,7 @@ async def receive_json(self, content, **kwargs): except ChatException as e: await self.send_json({"error": str(e.get_error())}) except KeyError as e: - await self.send_json( - { - "error": f"Missing key (might be backend's fault," - f" but most likely you are missing this field): {e}" - } - ) + await self.send_json({"error": f"В запросе отсутствует поле {e}."}) elif event.type in [EventType.SET_ONLINE, EventType.SET_OFFLINE]: room_name = EventGroupType.GENERAL_EVENTS @@ -228,15 +227,24 @@ async def _send_chat_event(self, event): chat_id = event.get("_project_chat_id") if chat_id is not None: if not self.user or not await can_access_project_chat(self.user.pk, chat_id): - room_name = f"{EventGroupType.CHATS_RELATED}_{chat_id}" + room_name = f"{EventGroupType.CHATS_RELATED}_{int(chat_id)}" await self.channel_layer.group_discard(room_name, self.channel_name) self.joined_rooms.discard(room_name) return + audience = event.get("_project_read_audience") + if audience is not None and self.user.pk not in audience: + return + if event["type"] in (EventType.NEW_MESSAGE, EventType.EDIT_MESSAGE): + content = event["content"] + message = await database_sync_to_async(personalize_project_message_event)( + content["message"], self.user.pk + ) + event = {**event, "content": {**content, "message": message}} # Служебный контекст нужен для проверки уже открытых сокетов, # клиентский формат сообщений остается прежним. await self.send( json.dumps( - {key: value for key, value in event.items() if key != "_project_chat_id"} + {key: value for key, value in event.items() if not key.startswith("_")} ) ) diff --git a/chats/consumers/event_types/ProjectEvent.py b/chats/consumers/event_types/ProjectEvent.py index d74b3368..d41c02bb 100644 --- a/chats/consumers/event_types/ProjectEvent.py +++ b/chats/consumers/event_types/ProjectEvent.py @@ -1,6 +1,7 @@ from channels.db import database_sync_to_async from chats.models import ProjectChat, ProjectChatMessage +from chats.project_reads import mark_project_message_read, publish_project_message_read from chats.utils import ( orm_exists, orm_get, @@ -9,7 +10,6 @@ from chats.services import create_project_message from chats.websockets_settings import Event, EventType from chats.exceptions import ( - WrongChatIdException, UserNotInChatException, UserNotMessageAuthorException, UserIsNotAuthor, @@ -55,37 +55,20 @@ async def process_new_message_event(self, event: Event, room_name: str): await self.channel_layer.send(self.channel_name, event_data) async def process_read_message_event(self, event: Event, room_name: str): - msg = await orm_get(ProjectChatMessage.objects, pk=event.content["message_id"]) - chat = await orm_get( - ProjectChat.objects.select_related("project__leader"), - pk=msg.chat_id, - ) - # check that user is in this chat - is_member = chat.project.leader_id == self.user.id or await orm_exists( - Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) - ) - if not is_member: - raise UserNotInChatException( - f"User {self.user.id} is not in project chat {msg.chat_id}" - ) - if msg.chat_id != int(event.content["chat_id"]): - raise WrongChatIdException( - "Some of chat/message ids are wrong, you can't access this message" - ) - msg.is_read = True - await orm_save(msg, update_fields=["is_read"]) - await self.channel_layer.group_send( - room_name, - { - "type": EventType.READ_MESSAGE, - "content": { - "chat_id": event.content["chat_id"], - "chat_type": event.content["chat_type"], - "user_id": self.user.id, - "message_id": event.content["message_id"], - }, - "_project_chat_id": event.content["chat_id"], - }, + chat_id = event.content["chat_id"] + message_id = event.content["message_id"] + author_id, read_count, created = await database_sync_to_async( + mark_project_message_read + )(chat_id=chat_id, message_id=message_id, user=self.user) + if author_id == self.user.pk: + return + await publish_project_message_read( + chat_id=chat_id, + message_id=int(message_id), + user_id=self.user.id, + author_id=author_id, + read_count=read_count, + sender_channel=None if created else self.channel_name, ) async def process_delete_message_event(self, event: Event, room_name: str): @@ -150,13 +133,7 @@ async def process_edit_message_event(self, event, room_name): message.is_edited = True await orm_save(message, update_fields=["text", "is_edited"]) - serialized_message = await orm_get( - ProjectChatMessage.objects.select_related( - "author", "reply_to__author" - ).prefetch_related("file_to_message__file"), - pk=message.pk, - ) - message_data = ProjectChatMessageListSerializer(serialized_message).data + message_data = await database_sync_to_async(serialize_project_message)(message.pk) content = { "chat_id": chat_id, "message": message_data, @@ -169,3 +146,12 @@ async def process_edit_message_event(self, event, room_name): } await self.channel_layer.group_send(room_name, event_data) + + +def serialize_project_message(message_id): + message = ( + ProjectChatMessage.objects.select_related("author", "reply_to__author") + .prefetch_related("file_to_message__file") + .get(pk=message_id) + ) + return ProjectChatMessageListSerializer(message).data diff --git a/chats/migrations/0013_project_chat_message_reads.py b/chats/migrations/0013_project_chat_message_reads.py new file mode 100644 index 00000000..21d6a7cd --- /dev/null +++ b/chats/migrations/0013_project_chat_message_reads.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.18 on 2026-11-13 16:56 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0012_directchatmessage_client_message_id_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ProjectChatMessageRead", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "message", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="read_receipts", + to="chats.projectchatmessage", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Прочтение сообщения в чате проекта", + "verbose_name_plural": "Прочтения сообщений в чатах проектов", + "constraints": [ + models.UniqueConstraint( + fields=("message", "user"), + name="unique_project_chat_message_reader", + ) + ], + }, + ), + ] diff --git a/chats/models.py b/chats/models.py index 90e1ca27..0b620ceb 100644 --- a/chats/models.py +++ b/chats/models.py @@ -249,6 +249,22 @@ class Meta: ] +class ProjectChatMessageRead(models.Model): + message = models.ForeignKey( + ProjectChatMessage, on_delete=models.CASCADE, related_name="read_receipts" + ) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Прочтение сообщения в чате проекта" + verbose_name_plural = "Прочтения сообщений в чатах проектов" + constraints = [ + models.UniqueConstraint( + fields=["message", "user"], name="unique_project_chat_message_reader" + ) + ] + + class DirectChatMessage(BaseMessage): """ DirectChatMessage model diff --git a/chats/project_reads.py b/chats/project_reads.py new file mode 100644 index 00000000..a073310c --- /dev/null +++ b/chats/project_reads.py @@ -0,0 +1,122 @@ +from channels.layers import get_channel_layer +from django.db import transaction +from django.db.models import Count, Exists, OuterRef, Prefetch, Subquery, Value +from django.db.models.functions import Coalesce + +from chats.exceptions import MessageValidationException +from chats.models import ProjectChatMessage, ProjectChatMessageRead +from chats.permissions import is_project_member +from chats.websockets_settings import ChatType, EventGroupType, EventType + + +def unread_project_messages(user_id): + receipts = ProjectChatMessageRead.objects.filter( + message_id=OuterRef("pk"), user_id=user_id + ) + # The old shared flag cannot identify readers. Preserve it as a legacy baseline. + return ( + ProjectChatMessage.objects.filter(is_deleted=False, is_read=False) + .exclude(author_id=user_id) + .filter(~Exists(receipts)) + ) + + +def with_project_chat_unreads(queryset, user_id): + unread = unread_project_messages(user_id).filter(chat_id=OuterRef("pk")) + totals = unread.order_by().values("chat_id").annotate(total=Count("pk")) + return queryset.annotate( + unread_count=Coalesce(Subquery(totals.values("total")), Value(0)), + first_unread_message_id=Subquery( + unread.order_by("created_at", "pk").values("pk")[:1] + ), + ) + + +def with_project_message_reads(queryset, user_id, *, include_reply=False): + receipts = ProjectChatMessageRead.objects.filter(message_id=OuterRef("pk")) + totals = receipts.order_by().values("message_id").annotate(total=Count("pk")) + queryset = queryset.annotate( + read_count=Coalesce(Subquery(totals.values("total")), Value(0)), + read_by_me=Exists(receipts.filter(user_id=user_id)), + ) + if include_reply: + replies = with_project_message_reads( + ProjectChatMessage.objects.select_related("author"), user_id + ) + queryset = queryset.prefetch_related(Prefetch("reply_to", queryset=replies)) + return queryset + + +def project_message_is_read(*, legacy_read, is_author, read_by_me, read_count) -> bool: + return legacy_read or (read_count > 0 if is_author else read_by_me) + + +def personalize_project_message_event(message, user_id): + # New and edited messages are broadcast with a shared payload. Project the + # personal flags at the socket so an edit cannot reset somebody's read state. + message = dict(message) + message_parts = [message] + if message.get("reply_to") is not None: + message["reply_to"] = dict(message["reply_to"]) + message_parts.append(message["reply_to"]) + states = with_project_message_reads( + ProjectChatMessage.objects.filter(pk__in=[part["id"] for part in message_parts]), + user_id, + ).values("id", "author_id", "is_read", "read_by_me", "read_count") + states_by_id = {state["id"]: state for state in states} + for part in message_parts: + state = states_by_id.get(part["id"]) + if state is None: + continue + part["read_count"] = state["read_count"] + part["is_read"] = project_message_is_read( + legacy_read=state["is_read"], + is_author=state["author_id"] == user_id, + read_by_me=state["read_by_me"], + read_count=state["read_count"], + ) + return message + + +@transaction.atomic +def mark_project_message_read(*, chat_id, message_id, user): + if type(message_id) not in (int, str): + raise MessageValidationException("Некорректный идентификатор сообщения.") + try: + message = ( + ProjectChatMessage.objects.select_related("chat__project") + .select_for_update(of=("self",)) + .get(pk=message_id, chat_id=chat_id, is_deleted=False) + ) + except (ProjectChatMessage.DoesNotExist, TypeError, ValueError) as error: + raise MessageValidationException("Сообщение недоступно для прочтения.") from error + + if not is_project_member(user, message.chat.project): + raise MessageValidationException("У вас нет доступа к чату проекта.") + + if message.author_id == user.pk: + return message.author_id, 0, False + _, created = ProjectChatMessageRead.objects.get_or_create(message=message, user=user) + return message.author_id, message.read_receipts.count(), created + + +async def publish_project_message_read( + *, chat_id, message_id, user_id, author_id, read_count, sender_channel=None +): + event = { + "type": EventType.READ_MESSAGE, + "content": { + "chat_id": chat_id, + "chat_type": ChatType.PROJECT, + "user_id": user_id, + "message_id": message_id, + "read_count": read_count, + }, + "_project_chat_id": chat_id, + "_project_read_audience": [user_id, author_id], + } + layer = get_channel_layer() + if sender_channel is None: + await layer.group_send(f"{EventGroupType.CHATS_RELATED}_{int(chat_id)}", event) + else: + await layer.send(sender_channel, event) diff --git a/chats/serializers.py b/chats/serializers.py index 44110f6e..8a6c7abc 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -6,6 +6,7 @@ DirectChatMessage, ProjectChatMessage, ) +from chats.project_reads import project_message_is_read, with_project_message_reads from files.serializers import UserFileSerializer from users.serializers import UserListSerializer, UserDetailSerializer, UserChatSerializer @@ -60,6 +61,8 @@ class ProjectChatListSerializer(serializers.ModelSerializer): last_message = serializers.SerializerMethodField(read_only=True) name = serializers.SerializerMethodField(read_only=True) image_address = serializers.SerializerMethodField(read_only=True) + unread_count = serializers.IntegerField(read_only=True) + first_unread_message_id = serializers.IntegerField(read_only=True, allow_null=True) @classmethod def get_image_address(cls, chat: ProjectChat): @@ -69,19 +72,33 @@ def get_image_address(cls, chat: ProjectChat): def get_name(cls, chat: ProjectChat): return chat.project.name - @classmethod - def get_last_message(cls, chat: ProjectChat): - return ProjectChatMessageListSerializer(chat.get_last_message()).data + def get_last_message(self, chat: ProjectChat): + user_id = self.context["request"].user.pk + messages = chat.messages.filter(is_deleted=False).order_by("-created_at", "-pk") + message = with_project_message_reads( + messages, user_id, include_reply=True + ).first() + return ProjectChatMessageListSerializer(message, context=self.context).data class Meta: model = ProjectChat - fields = ["id", "project", "last_message", "image_address", "name"] + fields = [ + "id", + "project", + "last_message", + "image_address", + "name", + "unread_count", + "first_unread_message_id", + ] class ProjectChatDetailSerializer(serializers.ModelSerializer): users = serializers.SerializerMethodField(read_only=True) name = serializers.SerializerMethodField(read_only=True) image_address = serializers.SerializerMethodField(read_only=True) + unread_count = serializers.IntegerField(read_only=True) + first_unread_message_id = serializers.IntegerField(read_only=True, allow_null=True) @classmethod def get_image_address(cls, chat: ProjectChat): @@ -103,6 +120,8 @@ class Meta: "name", "image_address", "users", + "unread_count", + "first_unread_message_id", ] @@ -163,6 +182,17 @@ class ProjectChatMessageSerializer(serializers.ModelSerializer): """ author = UserDetailSerializer() + is_read = serializers.SerializerMethodField() + read_count = serializers.IntegerField(read_only=True, default=0) + + def get_is_read(self, message) -> bool: + request = self.context.get("request") + return project_message_is_read( + legacy_read=message.is_read, + is_author=request is not None and message.author_id == request.user.pk, + read_by_me=getattr(message, "read_by_me", False), + read_count=getattr(message, "read_count", 0), + ) class Meta: model = ProjectChatMessage @@ -173,18 +203,18 @@ class Meta: "reply_to", "is_edited", "is_read", + "read_count", "is_deleted", "created_at", ] -class ProjectChatMessageListSerializer(serializers.ModelSerializer): - author = UserDetailSerializer() +class ProjectChatMessageListSerializer(ProjectChatMessageSerializer): reply_to = ProjectChatMessageSerializer(allow_null=True) files = serializers.SerializerMethodField() @classmethod - def get_files(cls, message: DirectChatMessage): + def get_files(cls, message: ProjectChatMessage): data = [] for file_to_message in message.file_to_message.all(): file_data = UserFileSerializer(file_to_message.file).data @@ -202,6 +232,7 @@ class Meta: "reply_to", "is_edited", "is_read", + "read_count", "is_deleted", "created_at", ] diff --git a/chats/tests/test_project.py b/chats/tests/test_project.py index 68ee42dc..276e1667 100644 --- a/chats/tests/test_project.py +++ b/chats/tests/test_project.py @@ -3,7 +3,7 @@ from django.test import TransactionTestCase from chats.consumers import ChatConsumer -from chats.models import ProjectChat, ProjectChatMessage +from chats.models import ProjectChat, ProjectChatMessage, ProjectChatMessageRead from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 from chats.websockets_settings import EventType @@ -73,7 +73,14 @@ async def test_read_message_in_my_project_other_message(self): await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() project_message = ProjectChatMessage.objects.get(pk=1) - self.assertTrue(project_message.is_read) + self.assertFalse(project_message.is_read) + self.assertTrue( + ProjectChatMessageRead.objects.filter( + message=project_message, user=self.leader + ).exists() + ) + self.assertEqual(response["content"]["read_count"], 1) + await self.communicator.disconnect() async def test_read_message_in_other_project_other_message(self): await self.connect(self.user) @@ -88,6 +95,8 @@ async def test_read_message_in_other_project_other_message(self): response = await self.communicator.receive_json_from() project_message = ProjectChatMessage.objects.get(pk=1) self.assertFalse(project_message.is_read) + self.assertFalse(ProjectChatMessageRead.objects.exists()) + await self.communicator.disconnect() async def test_delete_message_in_my_project_my_message(self): await self.connect(self.user) diff --git a/chats/tests/test_project_reads.py b/chats/tests/test_project_reads.py new file mode 100644 index 00000000..eb77f5c2 --- /dev/null +++ b/chats/tests/test_project_reads.py @@ -0,0 +1,214 @@ +from channels.testing import WebsocketCommunicator +from django.contrib.auth import get_user_model +from django.test import TestCase, TransactionTestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from chats.consumers import ChatConsumer +from chats.models import ProjectChat, ProjectChatMessage, ProjectChatMessageRead +from chats.project_reads import mark_project_message_read +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from projects.models import Collaborator, Project + + +def create_project_chat(leader, *members): + project = Project.objects.create(leader=leader) + chat, _ = ProjectChat.objects.get_or_create( + pk=project.pk, defaults={"project": project} + ) + for member in members: + Collaborator.objects.create(project=project, user=member, role="User") + return chat + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class ProjectReadsAPITests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.leader = users.create(**TEST_USER1) + cls.reader = users.create( + **TEST_USER2, is_staff=True, is_superuser=True, is_active=True + ) + cls.other_reader = users.create(**TEST_USER3) + cls.chat = create_project_chat(cls.leader, cls.reader, cls.other_reader) + + def setUp(self): + self.api = APIClient() + + def get(self, user, route, **kwargs): + self.api.force_authenticate(user=user) + url = reverse("chats:" + route, kwargs=kwargs) + response = self.api.get(url, secure=True) + self.assertEqual(response.status_code, 200) + return response.json() + + def create_message(self, **overrides): + return ProjectChatMessage.objects.create( + **( + {"chat": self.chat, "author": self.leader, "text": "Сообщение"} + | overrides + ) + ) + + def test_list_detail_and_global_indicator_use_the_same_personal_unreads(self): + first = self.create_message() + latest = self.create_message() + self.create_message(is_read=True) + self.create_message(is_deleted=True) + self.create_message(author=self.reader) + mark_project_message_read( + chat_id=self.chat.pk, message_id=latest.pk, user=self.reader + ) + + detail = self.get(self.reader, "project-chat-detail", pk=self.chat.pk) + item = self.get(self.reader, "project-chat-list")[0] + for data in (detail, item): + self.assertEqual(data["unread_count"], 1) + self.assertEqual(data["first_unread_message_id"], first.pk) + self.assertTrue(self.get(self.reader, "has-chat-unreads")["has_unreads"]) + other_detail = self.get(self.other_reader, "project-chat-detail", pk=self.chat.pk) + self.assertEqual(other_detail["unread_count"], 3) + self.assertEqual(other_detail["first_unread_message_id"], first.pk) + + mark_project_message_read( + chat_id=self.chat.pk, message_id=first.pk, user=self.reader + ) + detail = self.get(self.reader, "project-chat-detail", pk=self.chat.pk) + self.assertEqual(detail["unread_count"], 0) + self.assertIsNone(detail["first_unread_message_id"]) + self.assertFalse(self.get(self.reader, "has-chat-unreads")["has_unreads"]) + self.assertTrue(self.get(self.other_reader, "has-chat-unreads")["has_unreads"]) + + def test_history_personalizes_read_flag_for_messages_and_replies(self): + original = self.create_message() + reply = self.create_message(reply_to=original) + mark_project_message_read( + chat_id=self.chat.pk, message_id=original.pk, user=self.reader + ) + + for user, is_read in ( + (self.leader, True), + (self.reader, True), + (self.other_reader, False), + ): + with self.subTest(user=user.pk): + data = self.get(user, "project-chat-messages", id=self.chat.pk) + by_id = {message["id"]: message for message in data["results"]} + self.assertFalse(by_id[reply.pk]["is_read"]) + for item in (by_id[original.pk], by_id[reply.pk]["reply_to"]): + self.assertIs(item["is_read"], is_read) + self.assertEqual(item["read_count"], 1) + + def test_admin_marks_only_for_the_current_project_member(self): + message = self.create_message() + self.client.force_login(self.reader) + url = reverse("admin:chats_projectchatmessage_changelist") + form = {"action": "mark_read_for_me", "_selected_action": [message.pk]} + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.client.post(url, form, secure=True) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(callbacks), 1) + self.assertTrue( + ProjectChatMessageRead.objects.filter( + message=message, user=self.reader + ).exists() + ) + + Collaborator.objects.filter(project=self.chat.project, user=self.reader).delete() + another = self.create_message() + response = self.client.post( + url, {**form, "_selected_action": [another.pk]}, secure=True + ) + self.assertEqual(response.status_code, 302) + self.assertFalse(ProjectChatMessageRead.objects.filter(message=another).exists()) + + +class ProjectReadEventsTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.author = users.create(**TEST_USER1) + self.reader = users.create(**TEST_USER2) + self.other_reader = users.create(**TEST_USER3) + self.chat = create_project_chat(self.author, self.reader, self.other_reader) + self.message = ProjectChatMessage.objects.create( + chat=self.chat, author=self.author, text="Сообщение" + ) + + async def connect(self, user): + socket = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + socket.scope["user"] = user + connected, _ = await socket.connect() + self.assertTrue(connected) + return socket + + async def receive_chat_event(self, socket): + while True: + response = await socket.receive_json_from() + if response.get("type") not in {"set_online", "set_offline"}: + return response + + def event(self, event_type="message_read", **overrides): + return { + "type": event_type, + "content": { + "chat_type": "project", + "chat_id": str(self.chat.pk), + "message_id": self.message.pk, + **overrides, + }, + } + + @override_settings(RUNNING_TESTS=False) + async def test_read_events_and_edits_preserve_the_other_members_unread_state(self): + author = await self.connect(self.author) + reader = await self.connect(self.reader) + other = await self.connect(self.other_reader) + try: + await reader.send_json_to(self.event()) + receipt = await self.receive_chat_event(reader) + self.assertEqual(receipt, await self.receive_chat_event(author)) + self.assertEqual(receipt["content"]["read_count"], 1) + self.assertTrue(await other.receive_nothing()) + + await reader.send_json_to(self.event()) + self.assertEqual(await self.receive_chat_event(reader), receipt) + self.assertTrue(await author.receive_nothing()) + + await author.send_json_to(self.event("edit_message", text="Исправлено")) + for socket, expected in ((author, True), (reader, True), (other, False)): + edit = await self.receive_chat_event(socket) + self.assertEqual(edit["type"], "edit_message") + self.assertIs(edit["content"]["message"]["is_read"], expected) + self.assertEqual(edit["content"]["message"]["read_count"], 1) + finally: + await other.disconnect() + await reader.disconnect() + await author.disconnect() + self.assertEqual(ProjectChatMessageRead.objects.count(), 1) + + async def test_read_rejects_deleted_messages_and_messages_from_another_chat(self): + another = create_project_chat(self.author, self.reader) + deleted = ProjectChatMessage.objects.create( + chat=self.chat, author=self.author, text="Удалено", is_deleted=True + ) + socket = await self.connect(self.reader) + try: + for event in ( + self.event(chat_id=str(another.pk)), + self.event(message_id=deleted.pk), + ): + await socket.send_json_to(event) + self.assertIn("error", await self.receive_chat_event(socket)) + finally: + await socket.disconnect() + self.assertFalse(ProjectChatMessageRead.objects.exists()) + + async def test_author_read_does_not_create_a_receipt_or_notify_members(self): + author = await self.connect(self.author) + try: + await author.send_json_to(self.event()) + self.assertTrue(await author.receive_nothing()) + finally: + await author.disconnect() + self.assertFalse(ProjectChatMessageRead.objects.exists()) diff --git a/chats/views.py b/chats/views.py index a0a43b33..cc758fa1 100644 --- a/chats/views.py +++ b/chats/views.py @@ -12,9 +12,14 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from chats.models import ProjectChat, DirectChat +from chats.models import ProjectChat, DirectChat, ProjectChatMessage from chats.pagination import MessageListPagination from chats.permissions import IsProjectChatMember, IsChatMember +from chats.project_reads import ( + unread_project_messages, + with_project_chat_unreads, + with_project_message_reads, +) from chats.serializers import ( DirectChatListSerializer, DirectChatMessageListSerializer, @@ -65,7 +70,11 @@ class ProjectChatList(ListAPIView): def get_queryset(self): user = self.request.user - return user.get_project_chats() + if not user.is_authenticated: + return ProjectChat.objects.none() + return with_project_chat_unreads( + user.get_project_chats().select_related("project"), user.pk + ) class ProjectChatDetail(RetrieveAPIView): @@ -73,6 +82,11 @@ class ProjectChatDetail(RetrieveAPIView): serializer_class = ProjectChatDetailSerializer permission_classes = [IsProjectChatMember] + def get_queryset(self): + return with_project_chat_unreads( + super().get_queryset().select_related("project"), self.request.user.pk + ) + class DirectChatDetail(RetrieveAPIView): queryset = DirectChat.objects.all() @@ -150,15 +164,17 @@ class ProjectChatMessageList(ListCreateAPIView): pagination_class = MessageListPagination def get_queryset(self): - try: - return ( - ProjectChat.objects.get(id=self.kwargs["id"]) - .messages.filter(is_deleted=False) - .order_by("-created_at") - .all() + messages = ( + ProjectChatMessage.objects.filter( + chat_id=self.kwargs.get("id"), is_deleted=False ) - except ProjectChat.DoesNotExist: - return ProjectChat.objects.none() + .select_related("author") + .prefetch_related("file_to_message__file") + .order_by("-created_at", "-pk") + ) + return with_project_message_reads( + messages, self.request.user.pk, include_reply=True + ) def post(self, request, *args, **kwargs): # TODO: try to create a message in a chat. If chat doesn't exist, create it and then create a message. @@ -216,7 +232,6 @@ def get(self, request, *args, **kwargs): user = request.user # get all user chats direct_messages = user.direct_chats.all().prefetch_related("messages") - project_chats = user.get_project_chats().prefetch_related("messages") has_direct_messages_unread = ( direct_messages.filter(messages__is_read=False) @@ -225,9 +240,8 @@ def get(self, request, *args, **kwargs): .exists() ) has_project_messages_unread = ( - project_chats.filter(messages__is_read=False) - .exclude(messages__is_deleted=True) - .distinct() + unread_project_messages(user.pk) + .filter(chat_id__in=user.get_project_chats().values("pk")) .exists() ) return Response( diff --git a/users/models.py b/users/models.py index c4035bb5..db736557 100644 --- a/users/models.py +++ b/users/models.py @@ -230,7 +230,9 @@ def get_project_chats(self) -> QuerySet: from chats.models import ProjectChat user_project_ids = self.collaborations.all().values_list("project_id", flat=True) - return ProjectChat.objects.filter(project__in=user_project_ids) + return ProjectChat.objects.filter( + models.Q(project__leader=self) | models.Q(project_id__in=user_project_ids) + ) def get_full_name(self) -> str: return f"{self.first_name} {self.last_name}" From 162fb8652e95e3eb96a508ee51b5c5b71cdd3e17 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 20:53:17 +0300 Subject: [PATCH 03/16] Search chat messages and attachment names --- chats/admin.py | 82 ++++++++++++++++- chats/search.py | 85 +++++++++++++++++ chats/serializers.py | 16 +++- chats/tests/test_search.py | 107 ++++++++++++++++++++++ chats/views.py | 71 +++++++++++--- core/search.py | 13 +++ templates/admin/chats/message_search.html | 39 ++++++++ users/filters.py | 10 +- 8 files changed, 396 insertions(+), 27 deletions(-) create mode 100644 chats/search.py create mode 100644 chats/tests/test_search.py create mode 100644 core/search.py create mode 100644 templates/admin/chats/message_search.html diff --git a/chats/admin.py b/chats/admin.py index 4bc448b5..a8eb6712 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -2,7 +2,13 @@ from asgiref.sync import async_to_sync from django.contrib import admin, messages +from django.core.exceptions import PermissionDenied +from django.core.paginator import Paginator from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.template.response import TemplateResponse +from django.urls import path, reverse +from django.utils.html import format_html from chats.exceptions import MessageValidationException from chats.models import ( @@ -13,6 +19,7 @@ FileToMessage, ) from chats.project_reads import mark_project_message_read, publish_project_message_read +from chats.search import ChatMessageSearchForm, accessible_chats, search_chat_messages @admin.display(description="Пользователи чата") @@ -25,9 +32,70 @@ def chat_message_count(obj): return obj.messages.count() +class ChatSearchAdmin(admin.ModelAdmin): + @property + def message_search_url_name(self): + return ( + f"{self.model._meta.app_label}_{self.model._meta.model_name}_message_search" + ) + + def get_urls(self): + converter = "int" if self.model is ProjectChat else "slug" + return [ + path( + "<" + converter + ":object_id>/message-search/", + self.admin_site.admin_view(self.message_search), + name=self.message_search_url_name, + ), + ] + super().get_urls() + + @admin.display(description="Поиск по переписке") + def message_search_link(self, chat): + url = reverse( + "admin:" + self.message_search_url_name, + args=[chat.pk], + current_app=self.admin_site.name, + ) + return format_html('Найти в чате', url) + + def message_search(self, request, object_id): + visible = accessible_chats(request.user, self.model) + chat = get_object_or_404(visible, pk=object_id) + if not self.has_view_or_change_permission(request, chat): + raise PermissionDenied + form = ChatMessageSearchForm(request.GET) + page = None + if form.is_valid(): + matches = ( + search_chat_messages(chat, form.cleaned_data) + .select_related("author") + .prefetch_related("file_to_message__file") + ) + page = Paginator(matches, 20).get_page(request.GET.get("page")) + request.current_app = self.admin_site.name + return TemplateResponse( + request, + "admin/chats/message_search.html", + { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": "Поиск по переписке", + "form": form, + "page": page, + }, + ) + + @admin.register(ProjectChat) -class ChatAdmin(admin.ModelAdmin): - list_display = ("id", "project", chat_users, chat_message_count, "created_at") +class ChatAdmin(ChatSearchAdmin): + list_display = ( + "id", + "project", + chat_users, + chat_message_count, + "created_at", + "message_search_link", + ) list_display_links = ( "id", "project", @@ -35,8 +103,14 @@ class ChatAdmin(admin.ModelAdmin): @admin.register(DirectChat) -class DirectChatAdmin(admin.ModelAdmin): - list_display = ("id", chat_users, chat_message_count, "created_at") +class DirectChatAdmin(ChatSearchAdmin): + list_display = ( + "id", + chat_users, + chat_message_count, + "created_at", + "message_search_link", + ) list_display_links = ("id",) diff --git a/chats/search.py b/chats/search.py new file mode 100644 index 00000000..a13ea65e --- /dev/null +++ b/chats/search.py @@ -0,0 +1,85 @@ +from django import forms +from django.db.models import Case, Exists, F, OuterRef, Q, TextField, Value, When +from django.db.models.functions import Concat + +from chats.models import DirectChat, ProjectChat +from core.search import unicode_literal_pattern +from files.models import UserFile + + +class ChatMessageSearchForm(forms.Form): + scope = forms.ChoiceField( + label="Где искать", + required=False, + choices=[ + ("all", "Сообщения и вложения"), + ("messages", "Только сообщения"), + ("files", "Только вложения"), + ], + error_messages={"invalid_choice": "Выберите, где искать."}, + ) + author_id = forms.IntegerField( + label="ID автора", + required=False, + min_value=1, + max_value=2**63 - 1, + error_messages={ + "invalid": "Укажите целое число.", + "min_value": "ID автора должен быть положительным числом.", + "max_value": "ID автора превышает допустимое значение.", + }, + ) + + def __init__(self, data): + super().__init__(data) + self.fields["q"] = forms.CharField( + label="Поисковый запрос", + required=False, + max_length=200, + error_messages={ + "max_length": "Введите не более 200 символов.", + "null_characters_not_allowed": "В запросе есть недопустимый символ.", + }, + ) + self.order_fields(["q", "scope", "author_id"]) + + +def accessible_chats(user, chat_model): + if not user.is_authenticated: + return chat_model.objects.none() + if chat_model is DirectChat: + return user.direct_chats.all() + return user.get_project_chats() + + +def search_chat_messages(chat, filters): + query = filters["q"] + scope = filters["scope"] + author_id = filters["author_id"] + messages = chat.messages.filter(is_deleted=False).order_by("-created_at", "-pk") + if author_id is not None: + messages = messages.filter(author_id=author_id) + if not query: + if scope == "files": + return messages.filter(Exists(_message_files(chat))) + return messages + pattern = unicode_literal_pattern(query) + if scope == "messages": + return messages.filter(text__regex=pattern) + + files = _message_files(chat).alias( + filename=Case( + When(extension="", then=F("name")), + default=Concat("name", Value("."), "extension", output_field=TextField()), + ) + ) + matches_file = Exists(files.filter(filename__regex=pattern)) + if scope == "files": + return messages.filter(matches_file) + return messages.filter(Q(text__regex=pattern) | matches_file) + + +def _message_files(chat): + if isinstance(chat, ProjectChat): + return UserFile.objects.filter(file_to_message__project_message_id=OuterRef("pk")) + return UserFile.objects.filter(file_to_message__direct_message_id=OuterRef("pk")) diff --git a/chats/serializers.py b/chats/serializers.py index 8a6c7abc..910b0353 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -125,7 +125,15 @@ class Meta: ] -class DirectChatMessageSerializer(serializers.ModelSerializer): +class ChatReplySerializer(serializers.ModelSerializer): + def get_attribute(self, message): + reply = super().get_attribute(message) + if reply is not None and (reply.is_deleted or reply.chat_id != message.chat_id): + return None + return reply + + +class DirectChatMessageSerializer(ChatReplySerializer): """Same as DirectChatMessageListSerializer but reply_to is an id, not an object. This is done to avoid circular imports. """ @@ -209,8 +217,12 @@ class Meta: ] +class ProjectChatReplySerializer(ProjectChatMessageSerializer, ChatReplySerializer): + pass + + class ProjectChatMessageListSerializer(ProjectChatMessageSerializer): - reply_to = ProjectChatMessageSerializer(allow_null=True) + reply_to = ProjectChatReplySerializer(allow_null=True) files = serializers.SerializerMethodField() @classmethod diff --git a/chats/tests/test_search.py b/chats/tests/test_search.py new file mode 100644 index 00000000..15aac89f --- /dev/null +++ b/chats/tests/test_search.py @@ -0,0 +1,107 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from chats.models import DirectChat, DirectChatMessage, FileToMessage, ProjectChatMessage +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_project_reads import create_project_chat +from files.models import UserFile + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class ChatSearchTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.member = users.create( + **TEST_USER1, is_staff=True, is_superuser=True, is_active=True + ) + cls.author = users.create(**TEST_USER2) + cls.outsider = users.create( + **TEST_USER3, is_staff=True, is_superuser=True, is_active=True + ) + cls.direct = DirectChat.get_chat(cls.member, cls.author) + cls.project = create_project_chat(cls.author, cls.member) + + for kind, model, chat in ( + ("direct", DirectChatMessage, cls.direct), + ("project", ProjectChatMessage, cls.project), + ): + cls.make_messages(kind, model, chat) + + @classmethod + def make_messages(cls, kind, model, chat): + attachments_message = model.objects.create( + chat=chat, author=cls.author, text="Материалы по проекту" + ) + text_message = model.objects.create( + chat=chat, author=cls.member, text="Обсудим План" + ) + deleted = model.objects.create( + chat=chat, author=cls.author, text="Удаленный План", is_deleted=True + ) + model.objects.filter(pk=text_message.pk).update(reply_to=deleted) + for index, message in enumerate( + (attachments_message, attachments_message, deleted) + ): + file = UserFile.objects.create( + link="https://cdn.example.test/{}-{}.pdf".format(kind, index), + user=cls.author, + name="План", + extension="pdf", + ) + FileToMessage.objects.create(file=file, **{f"{kind}_message": message}) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.member) + + def search(self, kind, **params): + chat = getattr(self, kind) + return self.api.get( + reverse("chats:" + kind + "-chat-messages", args=[chat.pk]), + params, + secure=True, + ) + + def test_search_scopes_author_filter_and_pagination(self): + for kind in ("direct", "project"): + with self.subTest(chat=kind): + response = self.search(kind, q=" план ", limit=1) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["count"], 2) + self.assertIsNotNone(data["next"]) + self.assertEqual(data["results"][0]["text"], "Обсудим План") + self.assertIsNone(data["results"][0]["reply_to"]) + + files = self.search(kind, q="план.PDF", scope="files").json() + self.assertEqual(files["count"], 1) + self.assertEqual(len(files["results"][0]["files"]), 2) + text = self.search(kind, q="ПЛАН", scope="messages").json() + self.assertEqual(text["count"], 1) + by_author = self.search(kind, author_id=self.author.pk).json() + self.assertEqual(by_author["count"], 1) + self.assertEqual(by_author["results"][0]["text"], "Материалы по проекту") + self.assertEqual(self.search(kind, q="План.*").json()["count"], 0) + + def test_invalid_filters_return_400_and_a_nonmember_cannot_search(self): + for params in ({"author_id": "-1"}, {"q": "x" * 201}, {"scope": "unknown"}): + with self.subTest(params=params): + self.assertEqual(self.search("project", **params).status_code, 400) + self.api.force_authenticate(self.outsider) + self.assertEqual(self.search("project", q="План").status_code, 403) + self.assertEqual(self.search("direct", q="План").status_code, 403) + + def test_admin_uses_same_search_and_restricts_access_to_chat_members(self): + self.client.force_login(self.member) + url = reverse("admin:chats_projectchat_message_search", args=[self.project.pk]) + response = self.client.get(url, {"q": "план.PDF", "scope": "files"}, secure=True) + self.assertContains(response, "Материалы по проекту") + self.assertNotContains(response, "Обсудим План") + self.assertNotContains(response, "Удаленный План") + self.assertContains(response, "Найдено сообщений: 1") + + self.client.force_login(self.outsider) + self.assertEqual(self.client.get(url, secure=True).status_code, 404) diff --git a/chats/views.py b/chats/views.py index cc758fa1..5acfae3a 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,4 +1,5 @@ from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.generics import ( @@ -10,9 +11,10 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from chats.models import ProjectChat, DirectChat, ProjectChatMessage +from chats.models import ProjectChat, DirectChat from chats.pagination import MessageListPagination from chats.permissions import IsProjectChatMember, IsChatMember from chats.project_reads import ( @@ -20,6 +22,7 @@ with_project_chat_unreads, with_project_message_reads, ) +from chats.search import ChatMessageSearchForm, accessible_chats, search_chat_messages from chats.serializers import ( DirectChatListSerializer, DirectChatMessageListSerializer, @@ -144,33 +147,75 @@ def get(self, request, *args, **kwargs) -> Response: ) -class DirectChatMessageList(ListCreateAPIView): +class ChatMessageHistory(ListCreateAPIView): + pagination_class = MessageListPagination + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + "q", + openapi.IN_QUERY, + description="Текст сообщения или имя файла", + type=openapi.TYPE_STRING, + max_length=200, + ), + openapi.Parameter( + "scope", + openapi.IN_QUERY, + description="Где искать", + type=openapi.TYPE_STRING, + enum=["all", "messages", "files"], + ), + openapi.Parameter( + "author_id", + openapi.IN_QUERY, + description="ID автора сообщения", + type=openapi.TYPE_INTEGER, + minimum=1, + ), + ] + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + user = self.request.user + if getattr(self, "swagger_fake_view", False): + return self.serializer_class.Meta.model.objects.none() + chat = get_object_or_404( + accessible_chats(user, self.chat_model), pk=self.kwargs["id"] + ) + form = ChatMessageSearchForm(self.request.query_params) + if not form.is_valid(): + raise ValidationError(form.errors) + return search_chat_messages(chat, form.cleaned_data) + + +class DirectChatMessageList(ChatMessageHistory): + chat_model = DirectChat serializer_class = DirectChatMessageListSerializer permission_classes = [IsAuthenticated, IsChatMember] - pagination_class = MessageListPagination def get_queryset(self): return ( - self.request.user.direct_chats.get(id=self.kwargs["id"]) - .messages.filter(is_deleted=False) - .order_by("-created_at") - .all() + super() + .get_queryset() + .select_related("author", "reply_to__author") + .prefetch_related("file_to_message__file") ) -class ProjectChatMessageList(ListCreateAPIView): +class ProjectChatMessageList(ChatMessageHistory): + chat_model = ProjectChat serializer_class = ProjectChatMessageListSerializer permission_classes = [IsProjectChatMember] - pagination_class = MessageListPagination def get_queryset(self): messages = ( - ProjectChatMessage.objects.filter( - chat_id=self.kwargs.get("id"), is_deleted=False - ) + super() + .get_queryset() .select_related("author") .prefetch_related("file_to_message__file") - .order_by("-created_at", "-pk") ) return with_project_message_reads( messages, self.request.user.pk, include_reply=True diff --git a/core/search.py b/core/search.py new file mode 100644 index 00000000..f49bf172 --- /dev/null +++ b/core/search.py @@ -0,0 +1,13 @@ +import re + + +def unicode_literal_pattern(value): + """Match literal text without relying on the database's Unicode case rules.""" + return "".join( + "(?:" + + "|".join( + re.escape(variant) for variant in sorted({char, char.lower(), char.upper()}) + ) + + ")" + for char in value + ) diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html new file mode 100644 index 00000000..c4334ff9 --- /dev/null +++ b/templates/admin/chats/message_search.html @@ -0,0 +1,39 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
+ {{ form.as_p }} + +
+ {% if page %} +

Найдено сообщений: {{ page.paginator.count }}

+ {% for message in page %} +
+

Сообщение №{{ message.pk }} · {{ message.author.get_full_name }}

+

{{ message.created_at|date:"d.m.Y H:i" }}

+

{{ message.text|linebreaksbr }}

+ {% if message.file_to_message.all %} +

Вложения:

+
    + {% for attachment in message.file_to_message.all %} +
  • {{ attachment.file.name }}{% if attachment.file.extension %}.{{ attachment.file.extension }}{% endif %}
  • + {% endfor %} +
+ {% endif %} +
+ {% empty %} +

Сообщения не найдены.

+ {% endfor %} + {% if page.has_other_pages %} + + {% endif %} + {% endif %} +{% endblock %} diff --git a/users/filters.py b/users/filters.py index d3401224..42bc0907 100644 --- a/users/filters.py +++ b/users/filters.py @@ -1,11 +1,10 @@ -import re - from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django_filters import rest_framework as filters from core.models import Specialization +from core.search import unicode_literal_pattern from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from users.utils import filter_age @@ -84,12 +83,7 @@ def fullname_literal_pattern(value): Явные варианты букв обходятся без изменения collation/данных; каждый символ экранируется, поэтому ввод пользователя не становится regex. """ - return "".join( - "(?:" - + "|".join(re.escape(v) for v in sorted({c, c.lower(), c.upper()})) - + ")" - for c in value - ) + return unicode_literal_pattern(value) @classmethod def filter_by_fullname(cls, queryset, name, value): From 8eb0a07d98d93b54ae99ce248aab81afe8ee01a6 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 21:09:04 +0300 Subject: [PATCH 04/16] Require chat membership for attachment downloads --- chats/admin.py | 36 ++- chats/attachments.py | 46 ++++ chats/models.py | 9 + chats/permissions.py | 10 + chats/search.py | 10 +- chats/serializers.py | 51 ++++- chats/tests/test_attachment_download.py | 266 ++++++++++++++++++++++ chats/urls.py | 12 + chats/utils.py | 13 -- chats/views.py | 61 +++-- files/downloads.py | 131 +++++++++++ files/service.py | 7 +- templates/admin/chats/message_search.html | 7 +- 13 files changed, 599 insertions(+), 60 deletions(-) create mode 100644 chats/attachments.py create mode 100644 chats/tests/test_attachment_download.py create mode 100644 files/downloads.py diff --git a/chats/admin.py b/chats/admin.py index a8eb6712..122160a1 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -5,11 +5,12 @@ from django.core.exceptions import PermissionDenied from django.core.paginator import Paginator from django.db import transaction -from django.shortcuts import get_object_or_404 from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils.html import format_html +from django.views.decorators.http import require_safe +from chats.attachments import download_chat_attachment, get_accessible_chat from chats.exceptions import MessageValidationException from chats.models import ( ProjectChat, @@ -19,7 +20,7 @@ FileToMessage, ) from chats.project_reads import mark_project_message_read, publish_project_message_read -from chats.search import ChatMessageSearchForm, accessible_chats, search_chat_messages +from chats.search import ChatMessageSearchForm, search_chat_messages @admin.display(description="Пользователи чата") @@ -40,13 +41,21 @@ def message_search_url_name(self): ) def get_urls(self): - converter = "int" if self.model is ProjectChat else "slug" + object_path = ( + "" if self.model is ProjectChat else "" + ) return [ path( - "<" + converter + ":object_id>/message-search/", + object_path + "/message-search/", self.admin_site.admin_view(self.message_search), name=self.message_search_url_name, ), + path( + object_path + + "/messages//files//download/", + self.admin_site.admin_view(require_safe(self.attachment_download)), + name=self.message_search_url_name + "_download", + ), ] + super().get_urls() @admin.display(description="Поиск по переписке") @@ -59,10 +68,7 @@ def message_search_link(self, chat): return format_html('Найти в чате', url) def message_search(self, request, object_id): - visible = accessible_chats(request.user, self.model) - chat = get_object_or_404(visible, pk=object_id) - if not self.has_view_or_change_permission(request, chat): - raise PermissionDenied + chat = self.get_chat_for_member(request, object_id) form = ChatMessageSearchForm(request.GET) page = None if form.is_valid(): @@ -82,9 +88,23 @@ def message_search(self, request, object_id): "title": "Поиск по переписке", "form": form, "page": page, + "chat": chat, + "attachment_download_route": "admin:" + + self.message_search_url_name + + "_download", }, ) + def get_chat_for_member(self, request, object_id): + chat = get_accessible_chat(request.user, self.model, object_id) + if not self.has_view_or_change_permission(request, chat): + raise PermissionDenied + return chat + + def attachment_download(self, request, object_id, message_id, attachment_id): + chat = self.get_chat_for_member(request, object_id) + return download_chat_attachment(request, chat, message_id, attachment_id) + @admin.register(ProjectChat) class ChatAdmin(ChatSearchAdmin): diff --git a/chats/attachments.py b/chats/attachments.py new file mode 100644 index 00000000..a20a1838 --- /dev/null +++ b/chats/attachments.py @@ -0,0 +1,46 @@ +from django.http import Http404 +from django.urls import reverse + +from chats.models import FileToMessage, ProjectChat +from chats.permissions import accessible_chats +from files.downloads import download_stored_file + + +def get_accessible_chat(user, model, chat_id): + chat = accessible_chats(user, model).filter(pk=chat_id).first() + if chat is None: + raise Http404("Чат недоступен.") + return chat + + +def chat_attachments(chat): + if isinstance(chat, ProjectChat): + return FileToMessage.objects.filter( + project_message__chat=chat, + project_message__is_deleted=False, + direct_message__isnull=True, + ) + return FileToMessage.objects.filter( + direct_message__chat=chat, + direct_message__is_deleted=False, + project_message__isnull=True, + ) + + +def attachment_download_url(attachment, message): + kind = "project" if attachment.project_message_id is not None else "direct" + return reverse( + "chats:" + kind + "-chat-attachment-download", + args=[message.chat_id, message.pk, attachment.download_id], + ) + + +def download_chat_attachment(request, chat, message_id, attachment_id): + message_field = ( + "project_message_id" if isinstance(chat, ProjectChat) else "direct_message_id" + ) + attachments = chat_attachments(chat).filter(**{message_field: message_id}) + for attachment in attachments.select_related("file"): + if attachment.download_id == attachment_id: + return download_stored_file(request, attachment.file) + raise Http404("Вложение недоступно.") diff --git a/chats/models.py b/chats/models.py index 0b620ceb..2262fc88 100644 --- a/chats/models.py +++ b/chats/models.py @@ -1,4 +1,5 @@ from abc import abstractmethod +from hashlib import sha256 from typing import List from django.contrib.auth import get_user_model @@ -331,6 +332,14 @@ class FileToMessage(models.Model): null=True, ) + @property + def download_id(self): + return sha256(self.file_id.encode()).hexdigest() + + @property + def is_linked_to_one_message(self): + return (self.direct_message_id is None) != (self.project_message_id is None) + def __str__(self): return f"FileToMessage<{self.file}>" diff --git a/chats/permissions.py b/chats/permissions.py index d004cd47..83cc789a 100644 --- a/chats/permissions.py +++ b/chats/permissions.py @@ -6,6 +6,16 @@ User = get_user_model() +def accessible_chats(user, chat_model): + from chats.models import DirectChat + + if not user.is_authenticated: + return chat_model.objects.none() + if chat_model is DirectChat: + return user.direct_chats.all() + return user.get_project_chats() + + def get_lookup_kwarg(request, view, *names): view_kwargs = getattr(view, "kwargs", {}) or {} parser_kwargs = (getattr(request, "parser_context", None) or {}).get("kwargs", {}) diff --git a/chats/search.py b/chats/search.py index a13ea65e..67bee0f8 100644 --- a/chats/search.py +++ b/chats/search.py @@ -2,7 +2,7 @@ from django.db.models import Case, Exists, F, OuterRef, Q, TextField, Value, When from django.db.models.functions import Concat -from chats.models import DirectChat, ProjectChat +from chats.models import ProjectChat from core.search import unicode_literal_pattern from files.models import UserFile @@ -44,14 +44,6 @@ def __init__(self, data): self.order_fields(["q", "scope", "author_id"]) -def accessible_chats(user, chat_model): - if not user.is_authenticated: - return chat_model.objects.none() - if chat_model is DirectChat: - return user.direct_chats.all() - return user.get_project_chats() - - def search_chat_messages(chat, filters): query = filters["q"] scope = filters["scope"] diff --git a/chats/serializers.py b/chats/serializers.py index 910b0353..79396048 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -1,7 +1,9 @@ from rest_framework import serializers +from chats.attachments import attachment_download_url from chats.models import ( DirectChat, + FileToMessage, ProjectChat, DirectChatMessage, ProjectChatMessage, @@ -11,6 +13,43 @@ from users.serializers import UserListSerializer, UserDetailSerializer, UserChatSerializer +class ChatAttachmentSerializer(serializers.ModelSerializer): + name = serializers.CharField(source="file.name", read_only=True) + extension = serializers.CharField(source="file.extension", read_only=True) + mime_type = serializers.CharField(source="file.mime_type", read_only=True) + size = serializers.IntegerField(source="file.size", read_only=True) + user = serializers.IntegerField( + source="file.user_id", read_only=True, allow_null=True + ) + datetime_uploaded = serializers.DateTimeField( + source="file.datetime_uploaded", read_only=True + ) + link = serializers.SerializerMethodField() + + def get_link(self, attachment) -> str: + message = self.context.get("message") or ( + attachment.project_message + if attachment.project_message_id is not None + else attachment.direct_message + ) + return attachment_download_url(attachment, message) + + class Meta: + model = FileToMessage + fields = UserFileSerializer.Meta.fields + + +def serialize_message_attachments(message): + if message.is_deleted: + return [] + attachments = [ + item for item in message.file_to_message.all() if item.is_linked_to_one_message + ] + return ChatAttachmentSerializer( + attachments, many=True, context={"message": message} + ).data + + class DirectChatListSerializer(serializers.ModelSerializer): last_message = serializers.SerializerMethodField() opponent = serializers.SerializerMethodField() @@ -162,11 +201,7 @@ class DirectChatMessageListSerializer(serializers.ModelSerializer): @classmethod def get_files(cls, message: DirectChatMessage): - data = [] - for file_to_message in message.file_to_message.all(): - file_data = UserFileSerializer(file_to_message.file).data - data.append(file_data) - return data + return serialize_message_attachments(message) class Meta: model = DirectChatMessage @@ -227,11 +262,7 @@ class ProjectChatMessageListSerializer(ProjectChatMessageSerializer): @classmethod def get_files(cls, message: ProjectChatMessage): - data = [] - for file_to_message in message.file_to_message.all(): - file_data = UserFileSerializer(file_to_message.file).data - data.append(file_data) - return data + return serialize_message_attachments(message) class Meta: model = ProjectChatMessage diff --git a/chats/tests/test_attachment_download.py b/chats/tests/test_attachment_download.py new file mode 100644 index 00000000..9f672c72 --- /dev/null +++ b/chats/tests/test_attachment_download.py @@ -0,0 +1,266 @@ +from unittest.mock import Mock, patch + +import requests +from django.contrib.auth import get_user_model +from django.test import AsyncRequestFactory, SimpleTestCase, TestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from chats.models import DirectChat, DirectChatMessage, FileToMessage, ProjectChatMessage +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_project_reads import create_project_chat +from files.downloads import download_stored_file +from files.models import UserFile +from projects.models import Collaborator + +STORAGE = "https://storage.example.test/v1/account/media/" +TOKEN = "test-storage-token" + + +def upstream_file(status=200): + upstream = Mock(status_code=status) + upstream.headers = {"Content-Length": "6"} + upstream.iter_content.return_value = iter([b"abc", b"def"]) + return upstream + + +@override_settings(ALLOWED_HOSTS=["testserver"], SELECTEL_SWIFT_URL=STORAGE) +class ChatAttachmentDownloadTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.author = users.create(**TEST_USER1, is_active=True) + cls.member = users.create( + **TEST_USER2, is_active=True, is_staff=True, is_superuser=True + ) + cls.outsider = users.create( + **TEST_USER3, is_active=True, is_staff=True, is_superuser=True + ) + direct = DirectChat.get_chat(cls.author, cls.member) + project = create_project_chat(cls.author, cls.member) + cls.project = project + cls.direct_message = DirectChatMessage.objects.create( + chat=direct, author=cls.author, text="Личное сообщение" + ) + cls.project_message = ProjectChatMessage.objects.create( + chat=project, author=cls.author, text="Сообщение проекта" + ) + cls.direct_attachment = cls.attach(cls.direct_message, "direct") + cls.project_attachment = cls.attach(cls.project_message, "project") + cls.other_chat = create_project_chat(cls.author, cls.member) + + @classmethod + def attach(cls, message, label): + file = UserFile.objects.create( + link=STORAGE + label + ".pdf", + user=cls.author, + name="План проекта", + extension="pdf", + mime_type="application/pdf", + ) + key = ( + "direct_message" + if isinstance(message, DirectChatMessage) + else "project_message" + ) + return FileToMessage.objects.create(file=file, **{key: message}) + + def setUp(self): + self.api = APIClient() + + def authenticate_member(self): + self.api.credentials( + HTTP_AUTHORIZATION=f"Bearer {AccessToken.for_user(self.member)}" + ) + + @staticmethod + def download_url(kind, message, attachment, chat_id=None): + return reverse( + "chats:" + kind + "-chat-attachment-download", + args=[chat_id or message.chat_id, message.pk, attachment.download_id], + ) + + def test_history_and_file_list_return_the_same_protected_link(self): + self.authenticate_member() + for kind, message, attachment in ( + ("direct", self.direct_message, self.direct_attachment), + ("project", self.project_message, self.project_attachment), + ): + with self.subTest(kind=kind): + args = [message.chat_id] + history = self.api.get( + reverse("chats:" + kind + "-chat-messages", args=args), secure=True + ).json() + files = self.api.get( + reverse("chats:" + kind + "-chat-files", args=args), secure=True + ).json() + link = self.download_url(kind, message, attachment) + self.assertEqual(history["results"][0]["files"][0]["link"], link) + self.assertEqual(files[0]["link"], link) + self.assertEqual(files[0]["name"], "План проекта") + self.assertNotIn(STORAGE, str(history) + str(files)) + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) + @patch("files.downloads.requests.get") + def test_members_can_download_through_api_and_admin(self, storage_get, _token): + self.authenticate_member() + self.client.force_login(self.member) + admin_url = reverse( + "admin:chats_projectchat_message_search_download", + args=[ + self.project.pk, + self.project_message.pk, + self.project_attachment.download_id, + ], + ) + search = self.client.get( + reverse("admin:chats_projectchat_message_search", args=[self.project.pk]), + secure=True, + ) + self.assertContains(search, admin_url) + for client, url, expected_file in ( + ( + self.api, + self.download_url("direct", self.direct_message, self.direct_attachment), + self.direct_attachment.file_id, + ), + ( + self.api, + self.download_url( + "project", self.project_message, self.project_attachment + ), + self.project_attachment.file_id, + ), + (self.client, admin_url, self.project_attachment.file_id), + ): + with self.subTest(url=url): + upstream = upstream_file() + storage_get.return_value = upstream + response = client.get(url, secure=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/octet-stream") + self.assertIn("filename*=utf-8''", response["Content-Disposition"]) + self.assertIn("no-store", response["Cache-Control"]) + self.assertEqual(response["Content-Length"], "6") + self.assertEqual(b"".join(response.streaming_content), b"abcdef") + upstream.close.assert_called_once() + storage_get.assert_called_with( + expected_file, + headers={"X-Auth-Token": TOKEN, "Accept-Encoding": "identity"}, + stream=True, + timeout=(5, 15), + allow_redirects=False, + ) + + upstream = upstream_file() + storage_get.return_value = upstream + response = self.api.head( + self.download_url("direct", self.direct_message, self.direct_attachment), + secure=True, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Length"], "6") + self.assertEqual(response.content, b"") + upstream.iter_content.assert_not_called() + upstream.close.assert_called_once() + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token") + @patch("files.downloads.requests.get") + def test_access_and_message_checks_run_before_storage(self, storage_get, token): + url = self.download_url("project", self.project_message, self.project_attachment) + self.assertEqual(self.api.get(url, secure=True).status_code, 401) + self.api.force_authenticate(self.outsider) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + direct_url = self.download_url( + "direct", self.direct_message, self.direct_attachment + ) + self.assertEqual(self.api.get(direct_url, secure=True).status_code, 404) + self.client.force_login(self.outsider) + admin_url = reverse( + "admin:chats_projectchat_message_search_download", + args=[ + self.project.pk, + self.project_message.pk, + self.project_attachment.download_id, + ], + ) + self.assertEqual(self.client.get(admin_url, secure=True).status_code, 404) + + self.api.force_authenticate(self.member) + wrong_chat = self.download_url( + "project", + self.project_message, + self.project_attachment, + chat_id=self.other_chat.pk, + ) + wrong_file = self.download_url( + "project", self.project_message, self.direct_attachment + ) + for unavailable in (wrong_chat, wrong_file): + self.assertEqual(self.api.get(unavailable, secure=True).status_code, 404) + Collaborator.objects.filter( + project=self.project.project, user=self.member + ).delete() + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + self.api.force_authenticate(self.author) + self.project_message.is_deleted = True + self.project_message.save(update_fields=["is_deleted"]) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + files_url = reverse("chats:project-chat-files", args=[self.project.pk]) + self.assertEqual(self.api.get(files_url, secure=True).json(), []) + token.assert_not_called() + storage_get.assert_not_called() + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) + @patch("files.downloads.requests.get") + def test_storage_errors_do_not_redirect_the_client(self, storage_get, _token): + self.authenticate_member() + url = self.download_url("direct", self.direct_message, self.direct_attachment) + for upstream_status, expected in ((404, 404), (302, 502), (503, 502)): + with self.subTest(upstream_status=upstream_status): + upstream = upstream_file(upstream_status) + upstream.headers["Location"] = "https://storage.example.test/redirect" + storage_get.return_value = upstream + response = self.api.get(url, secure=True) + self.assertEqual(response.status_code, expected) + self.assertNotIn("Location", response) + self.assertIn("no-store", response["Cache-Control"]) + upstream.close.assert_called_once() + storage_get.side_effect = requests.Timeout + self.assertEqual(self.api.get(url, secure=True).status_code, 502) + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token") + @patch("files.downloads.requests.get") + def test_download_uses_only_configured_storage(self, storage_get, token): + self.authenticate_member() + file = UserFile.objects.create( + link="https://other.example.test/files/file.pdf", + user=self.author, + ) + attachment = FileToMessage.objects.create( + file=file, direct_message=self.direct_message + ) + url = self.download_url("direct", self.direct_message, attachment) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + token.assert_not_called() + storage_get.assert_not_called() + + +@override_settings(SELECTEL_SWIFT_URL=STORAGE) +class AsyncAttachmentDownloadTests(SimpleTestCase): + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) + @patch("files.downloads.requests.get") + async def test_asgi_consumes_the_upstream_one_chunk_at_a_time( + self, storage_get, _token + ): + upstream = upstream_file() + storage_get.return_value = upstream + file = UserFile(link=STORAGE + "object", name="План", extension="pdf") + response = download_stored_file(AsyncRequestFactory().get("/"), file) + self.assertTrue(response.is_async) + self.assertEqual( + [chunk async for chunk in response.streaming_content], [b"abc", b"def"] + ) + response.close() + upstream.close.assert_called_once() diff --git a/chats/urls.py b/chats/urls.py index f670d400..ee4157d8 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,6 +1,8 @@ from django.urls import path +from chats.models import DirectChat, ProjectChat from chats.views import ( + ChatAttachmentDownload, DirectChatList, DirectChatMessageList, HasChatUnreadsView, @@ -27,6 +29,11 @@ DirectChatFileList.as_view(), name="direct-chat-files", ), + path( + "directs//messages//files//download/", + ChatAttachmentDownload.as_view(chat_model=DirectChat), + name="direct-chat-attachment-download", + ), path("projects/", ProjectChatList.as_view(), name="project-chat-list"), path("projects//", ProjectChatDetail.as_view(), name="project-chat-detail"), path( @@ -39,6 +46,11 @@ ProjectChatFileList.as_view(), name="project-chat-files", ), + path( + "projects//messages//files//download/", + ChatAttachmentDownload.as_view(chat_model=ProjectChat), + name="project-chat-attachment-download", + ), path( "has-unreads/", HasChatUnreadsView.as_view(), diff --git a/chats/utils.py b/chats/utils.py index 8d85a7a0..bda114ed 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -57,16 +57,3 @@ async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str f"User {current_user.id} is not a member of chat {chat_id}" ) return chat_id, other_user - - -def get_all_files(messages): - # looks like something bad - - files = [] - for message in messages: - if hasattr(message, "file_to_message"): - files_in_message = [ - file_to_message.file for file_to_message in message.file_to_message.all() - ] - files.extend(files_in_message) - - return files diff --git a/chats/views.py b/chats/views.py index 5acfae3a..f0cfcf90 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,5 +1,7 @@ from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache from rest_framework import status from rest_framework.generics import ( @@ -13,16 +15,22 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.exceptions import ValidationError from rest_framework.response import Response +from rest_framework.views import APIView +from chats.attachments import ( + chat_attachments, + download_chat_attachment, + get_accessible_chat, +) from chats.models import ProjectChat, DirectChat from chats.pagination import MessageListPagination -from chats.permissions import IsProjectChatMember, IsChatMember +from chats.permissions import IsProjectChatMember, IsChatMember, accessible_chats from chats.project_reads import ( unread_project_messages, with_project_chat_unreads, with_project_message_reads, ) -from chats.search import ChatMessageSearchForm, accessible_chats, search_chat_messages +from chats.search import ChatMessageSearchForm, search_chat_messages from chats.serializers import ( DirectChatListSerializer, DirectChatMessageListSerializer, @@ -30,10 +38,8 @@ ProjectChatMessageListSerializer, ProjectChatDetailSerializer, DirectChatDetailSerializer, + ChatAttachmentSerializer, ) -from chats.utils import get_all_files -from files.models import UserFile -from files.serializers import UserFileSerializer User = get_user_model() @@ -230,26 +236,45 @@ def post(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) -class DirectChatFileList(ListCreateAPIView): - serializer_class = UserFileSerializer +class ChatFileList(ListAPIView): + serializer_class = ChatAttachmentSerializer permission_classes = [IsAuthenticated] def get_queryset(self): - messages = self.request.user.direct_chats.get(id=self.kwargs["id"]).messages.all() + 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"]) + return ( + chat_attachments(chat) + .select_related("file", "direct_message", "project_message") + .order_by("file__datetime_uploaded", "pk") + ) - return get_all_files(messages) +class DirectChatFileList(ChatFileList): + chat_model = DirectChat -class ProjectChatFileList(ListCreateAPIView): - serializer_class = UserFileSerializer - permission_classes = [IsProjectChatMember] - def get_queryset(self): - try: - messages = ProjectChat.objects.get(id=self.kwargs["id"]).messages.all() - return get_all_files(messages) - except ProjectChat.DoesNotExist: - return UserFile.objects.none() +class ProjectChatFileList(ChatFileList): + chat_model = ProjectChat + + +@method_decorator(never_cache, name="dispatch") +class ChatAttachmentDownload(APIView): + permission_classes = [IsAuthenticated] + chat_model = None + + @swagger_auto_schema( + produces=["application/octet-stream"], + responses={ + 200: openapi.Response("Файл", schema=openapi.Schema(type=openapi.TYPE_FILE)), + 404: "Вложение недоступно", + 502: "Хранилище недоступно", + }, + ) + def get(self, request, id, message_id, attachment_id): + chat = get_accessible_chat(request.user, self.chat_model, id) + return download_chat_attachment(request, chat, message_id, attachment_id) class HasChatUnreadsView(GenericAPIView): diff --git a/files/downloads.py b/files/downloads.py new file mode 100644 index 00000000..8c526990 --- /dev/null +++ b/files/downloads.py @@ -0,0 +1,131 @@ +from urllib.parse import unquote, urlsplit + +import requests +from asgiref.sync import sync_to_async +from django.conf import settings +from django.core.handlers.asgi import ASGIRequest +from django.http import Http404, HttpResponse, StreamingHttpResponse +from django.utils.http import content_disposition_header + +from files.exceptions import SelectelUploadError +from files.service import SelectelSwiftStorage + + +def is_storage_url(link): + try: + base = urlsplit(settings.SELECTEL_SWIFT_URL) + target = urlsplit(link) + path = unquote(target.path) + prefix = unquote(base.path).rstrip("/") + "/" + return ( + target.scheme == base.scheme == "https" + and target.netloc.lower() == base.netloc.lower() + and not target.username + and not target.password + and not target.query + and not target.fragment + and path.startswith(prefix) + and len(path) > len(prefix) + and "\\" not in path + and "%" not in path + and not any(ord(char) < 32 or ord(char) == 127 for char in path) + and all(segment not in ("", ".", "..") for segment in path[1:].split("/")) + ) + except ValueError: + return False + + +def download_filename(file): + name = file.name + (f".{file.extension}" if file.extension else "") + name = "".join( + char if char not in "/\\" and ord(char) >= 32 and ord(char) != 127 else "_" + for char in name + ).strip(" .") + return name or "Вложение" + + +class DownloadBody: + def __init__(self, upstream): + self.upstream = upstream + self.chunks = upstream.iter_content(chunk_size=64 * 1024) + + def __iter__(self): + return iter(self.chunks) + + async def iter_async(self): + # Django buffers synchronous iterators under ASGI. Pull one chunk at a time. + next_chunk = sync_to_async(next, thread_sensitive=False) + while (chunk := await next_chunk(self.chunks, None)) is not None: + yield chunk + + def close(self): + self.upstream.close() + + +def download_stored_file(request, file): + if not is_storage_url(file.link): + raise Http404("Вложение недоступно.") + try: + upstream = requests.get( + file.link, + headers={ + "X-Auth-Token": SelectelSwiftStorage._get_auth_token(), + "Accept-Encoding": "identity", + }, + stream=True, + timeout=(5, 15), + allow_redirects=False, + ) + except (requests.RequestException, SelectelUploadError, KeyError): + return storage_unavailable() + + if upstream.status_code != 200: + upstream.close() + if upstream.status_code == 404: + raise Http404("Файл не найден в хранилище.") + return storage_unavailable() + + raw_request = getattr(request, "_request", request) + content_type = "application/octet-stream" + if raw_request.method == "HEAD": + upstream.close() + response = HttpResponse(content_type=content_type) + else: + body = DownloadBody(upstream) + if isinstance(raw_request, ASGIRequest): + body = AsyncDownloadBody(body) + response = StreamingHttpResponse(body, content_type=content_type) + response["Content-Disposition"] = content_disposition_header( + as_attachment=True, filename=download_filename(file) + ) + response["Cache-Control"] = "private, no-store" + response["X-Content-Type-Options"] = "nosniff" + length = upstream.headers.get("Content-Length", "") + if ( + length.isascii() + and length.isdecimal() + and not upstream.headers.get("Content-Encoding") + ): + response["Content-Length"] = length + return response + + +class AsyncDownloadBody: + def __init__(self, body): + self.body = body + + def __aiter__(self): + return self.body.iter_async() + + def close(self): + self.body.close() + + +def storage_unavailable(): + response = HttpResponse( + "Не удалось скачать файл. Повторите попытку.", + status=502, + content_type="text/plain; charset=utf-8", + ) + response["Cache-Control"] = "private, no-store" + return response diff --git a/files/service.py b/files/service.py index 1304af6c..7a045105 100644 --- a/files/service.py +++ b/files/service.py @@ -130,7 +130,12 @@ def _get_auth_token(): } } } - response = requests.post(settings.SELECTEL_AUTH_TOKEN_URL, json=data) + response = requests.post( + settings.SELECTEL_AUTH_TOKEN_URL, + json=data, + timeout=(5, 15), + allow_redirects=False, + ) if response.status_code not in [200, 201]: raise SelectelUploadError( "Couldn't generate a token for Selectel Swift API (selcdn)" diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html index c4334ff9..91299c7f 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -16,7 +16,12 @@

Сообщение №{{ message.pk }} · {{ message.author.get_full_name }}

Вложения:

    {% for attachment in message.file_to_message.all %} -
  • {{ attachment.file.name }}{% if attachment.file.extension %}.{{ attachment.file.extension }}{% endif %}
  • +
  • + {{ attachment.file.name }}{% if attachment.file.extension %}.{{ attachment.file.extension }}{% endif %} + {% if attachment.is_linked_to_one_message %} + Скачать + {% endif %} +
  • {% endfor %}
{% endif %} From a8e5d839f47b519fcd1f857616e0db96b25a41e2 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 21:15:09 +0300 Subject: [PATCH 05/16] Load chat history around a selected message --- chats/admin.py | 42 +++++++ chats/message_context.py | 98 ++++++++++++++++ chats/tests/test_message_context.py | 127 +++++++++++++++++++++ chats/urls.py | 19 +++ chats/views.py | 55 +++++++++ templates/admin/chats/_message.html | 22 ++++ templates/admin/chats/message_context.html | 17 +++ templates/admin/chats/message_search.html | 19 +-- 8 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 chats/message_context.py create mode 100644 chats/tests/test_message_context.py create mode 100644 templates/admin/chats/_message.html create mode 100644 templates/admin/chats/message_context.html diff --git a/chats/admin.py b/chats/admin.py index 122160a1..53968fba 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -12,6 +12,7 @@ from chats.attachments import download_chat_attachment, get_accessible_chat from chats.exceptions import MessageValidationException +from chats.message_context import MessageContextForm, get_message_context from chats.models import ( ProjectChat, DirectChat, @@ -50,6 +51,11 @@ def get_urls(self): self.admin_site.admin_view(self.message_search), name=self.message_search_url_name, ), + path( + object_path + "/messages//context/", + self.admin_site.admin_view(require_safe(self.message_context)), + name=self.message_search_url_name + "_context", + ), path( object_path + "/messages//files//download/", @@ -89,6 +95,9 @@ def message_search(self, request, object_id): "form": form, "page": page, "chat": chat, + "message_context_route": "admin:" + + self.message_search_url_name + + "_context", "attachment_download_route": "admin:" + self.message_search_url_name + "_download", @@ -105,6 +114,39 @@ def attachment_download(self, request, object_id, message_id, attachment_id): chat = self.get_chat_for_member(request, object_id) return download_chat_attachment(request, chat, message_id, attachment_id) + def message_context(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + form = MessageContextForm(request.GET) + context = None + if form.is_valid(): + queryset = chat.messages.select_related("author").prefetch_related( + "file_to_message__file" + ) + context = get_message_context(chat, queryset, message_id, **form.cleaned_data) + request.current_app = self.admin_site.name + search_route = "admin:" + self.message_search_url_name + return TemplateResponse( + request, + "admin/chats/message_context.html", + { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": "Сообщение в переписке", + "chat": chat, + "context_form": form, + "context": context, + "anchor_id": ( + message_id + if context and form.cleaned_data["direction"] == "around" + else None + ), + "search_route": search_route, + "message_context_route": search_route + "_context", + "attachment_download_route": search_route + "_download", + }, + status=200 if context is not None else 400, + ) + @admin.register(ProjectChat) class ChatAdmin(ChatSearchAdmin): diff --git a/chats/message_context.py b/chats/message_context.py new file mode 100644 index 00000000..b43e9b49 --- /dev/null +++ b/chats/message_context.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass + +from django import forms +from django.db.models import Q +from django.http import Http404 + +DEFAULT_CONTEXT_LIMIT = 20 +MAX_CONTEXT_LIMIT = 50 + + +class MessageContextForm(forms.Form): + direction = forms.ChoiceField( + required=False, + choices=[ + ("around", "Вокруг сообщения"), + ("older", "Более ранние"), + ("newer", "Более новые"), + ], + error_messages={"invalid_choice": "Выберите направление загрузки сообщений."}, + ) + limit = forms.IntegerField( + required=False, + min_value=1, + max_value=MAX_CONTEXT_LIMIT, + error_messages={ + "invalid": "Укажите целое число сообщений.", + "min_value": "Укажите хотя бы одно сообщение.", + "max_value": "Можно загрузить не более %(limit_value)s сообщений с каждой стороны.", + }, + ) + + def clean_direction(self): + return self.cleaned_data["direction"] or "around" + + def clean_limit(self): + return self.cleaned_data["limit"] or DEFAULT_CONTEXT_LIMIT + + +@dataclass +class MessageContext: + messages: list + older_than: int | None = None + newer_than: int | None = None + + +def before(message): + return Q(created_at__lt=message.created_at) | Q( + created_at=message.created_at, pk__lt=message.pk + ) + + +def after(message): + return Q(created_at__gt=message.created_at) | Q( + created_at=message.created_at, pk__gt=message.pk + ) + + +def get_message_context(chat, queryset, message_id, *, direction, limit): + """Return a chronological slice of one chat using (created_at, id) boundaries.""" + anchor = ( + chat.messages.only("id", "created_at", "is_deleted").filter(pk=message_id).first() + ) + if anchor is None or (direction == "around" and anchor.is_deleted): + raise Http404("Сообщение недоступно.") + + visible = chat.messages.filter(is_deleted=False) + ids = [] + if direction in ("around", "older"): + ids.extend( + visible.filter(before(anchor)) + .order_by("-created_at", "-pk") + .values_list("pk", flat=True)[:limit] + ) + if direction == "around": + ids.append(anchor.pk) + if direction in ("around", "newer"): + ids.extend( + visible.filter(after(anchor)) + .order_by("created_at", "pk") + .values_list("pk", flat=True)[:limit] + ) + + messages = list( + queryset.filter(chat=chat, pk__in=ids, is_deleted=False).order_by( + "created_at", "pk" + ) + ) + if direction == "around" and not any(message.pk == anchor.pk for message in messages): + raise Http404("Сообщение недоступно.") + if not messages: + return MessageContext(messages) + + first, last = messages[0], messages[-1] + has_older = visible.filter(before(first)).exists() + has_newer = visible.filter(after(last)).exists() + return MessageContext( + messages, first.pk if has_older else None, last.pk if has_newer else None + ) diff --git a/chats/tests/test_message_context.py b/chats/tests/test_message_context.py new file mode 100644 index 00000000..7aa6e25d --- /dev/null +++ b/chats/tests/test_message_context.py @@ -0,0 +1,127 @@ +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 DirectChat, ProjectChatMessageRead +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_project_reads import create_project_chat + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class MessageContextTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.author = users.create(**TEST_USER1) + cls.member = users.create( + **TEST_USER2, is_active=True, is_staff=True, is_superuser=True + ) + cls.outsider = users.create( + **TEST_USER3, is_active=True, is_staff=True, is_superuser=True + ) + cls.direct = DirectChat.get_chat(cls.author, cls.member) + cls.project = create_project_chat(cls.author, cls.member) + cls.other_project = create_project_chat(cls.author, cls.member) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.member) + + def create_messages(self, chat): + messages = [ + chat.messages.create(author=self.author, text=f"Сообщение {index}") + for index in range(8) + ] + chat.messages.update(created_at=timezone.now()) + return messages + + def get_context(self, kind, message, **params): + chat = getattr(self, kind) + url = reverse( + "chats:" + kind + "-chat-message-context", args=[chat.pk, message.pk] + ) + return self.api.get(url, params, secure=True) + + def test_context_and_continuation_use_stable_boundaries(self): + for kind in ("direct", "project"): + with self.subTest(kind=kind): + messages = self.create_messages(getattr(self, kind)) + messages[2].is_deleted = True + messages[2].save(update_fields=["is_deleted"]) + response = self.get_context(kind, messages[4], limit=1) + self.assertEqual(response.status_code, 200) + self.assertIn("no-store", response["Cache-Control"]) + data = response.json() + self.assertEqual( + [item["id"] for item in data["results"]], + [messages[index].pk for index in (3, 4, 5)], + ) + self.assertEqual(data["older_than"], messages[3].pk) + self.assertEqual(data["newer_than"], messages[5].pk) + + # A deleted boundary can continue scrolling, but its content stays hidden. + messages[3].is_deleted = True + messages[3].save(update_fields=["is_deleted"]) + self.assertEqual(self.get_context(kind, messages[3]).status_code, 404) + older = self.get_context( + kind, messages[3], direction="older", limit=2 + ).json() + self.assertEqual( + [item["id"] for item in older["results"]], + [messages[index].pk for index in (0, 1)], + ) + self.assertIsNone(older["older_than"]) + newer = self.get_context( + kind, messages[5], direction="newer", limit=2 + ).json() + self.assertEqual( + [item["id"] for item in newer["results"]], + [messages[index].pk for index in (6, 7)], + ) + self.assertIsNone(newer["newer_than"]) + self.assertEqual( + self.get_context(kind, messages[7], direction="newer").json(), + {"results": [], "older_than": None, "newer_than": None}, + ) + self.assertFalse(ProjectChatMessageRead.objects.exists()) + + def test_context_rejects_nonmembers_foreign_anchors_and_invalid_options(self): + message = self.project.messages.create(author=self.author, text="Проект") + foreign = self.other_project.messages.create( + author=self.author, text="Другой проект" + ) + self.assertEqual(self.get_context("project", foreign).status_code, 404) + for params in ({"limit": 0}, {"limit": 51}, {"direction": "invalid"}): + self.assertEqual( + self.get_context("project", message, **params).status_code, 400 + ) + self.api.force_authenticate(self.outsider) + self.assertEqual(self.get_context("project", message).status_code, 404) + + def test_admin_can_open_the_search_hit_in_context(self): + messages = self.create_messages(self.project) + target = messages[4] + self.client.force_login(self.member) + search_url = reverse( + "admin:chats_projectchat_message_search", args=[self.project.pk] + ) + context_url = reverse( + "admin:chats_projectchat_message_search_context", + args=[self.project.pk, target.pk], + ) + search = self.client.get(search_url, {"q": target.text}, secure=True) + self.assertContains(search, context_url + "#selected-message") + context = self.client.get(context_url, {"limit": 1}, secure=True) + self.assertContains(context, 'id="selected-message"') + for index in (3, 4, 5): + self.assertContains(context, messages[index].text) + self.assertNotContains(context, messages[2].text) + self.assertContains(context, "direction=newer") + self.assertContains(context, "direction=older") + self.assertEqual( + self.client.get(context_url, {"limit": 51}, secure=True).status_code, 400 + ) + self.client.force_login(self.outsider) + self.assertEqual(self.client.get(context_url, secure=True).status_code, 404) diff --git a/chats/urls.py b/chats/urls.py index ee4157d8..8708149d 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,8 +1,13 @@ from django.urls import path from chats.models import DirectChat, ProjectChat +from chats.serializers import ( + DirectChatMessageListSerializer, + ProjectChatMessageListSerializer, +) from chats.views import ( ChatAttachmentDownload, + ChatMessageContext, DirectChatList, DirectChatMessageList, HasChatUnreadsView, @@ -29,6 +34,13 @@ DirectChatFileList.as_view(), name="direct-chat-files", ), + path( + "directs//messages//context/", + ChatMessageContext.as_view( + chat_model=DirectChat, serializer_class=DirectChatMessageListSerializer + ), + name="direct-chat-message-context", + ), path( "directs//messages//files//download/", ChatAttachmentDownload.as_view(chat_model=DirectChat), @@ -46,6 +58,13 @@ ProjectChatFileList.as_view(), name="project-chat-files", ), + path( + "projects//messages//context/", + ChatMessageContext.as_view( + chat_model=ProjectChat, serializer_class=ProjectChatMessageListSerializer + ), + name="project-chat-message-context", + ), path( "projects//messages//files//download/", ChatAttachmentDownload.as_view(chat_model=ProjectChat), diff --git a/chats/views.py b/chats/views.py index f0cfcf90..bc8746a6 100644 --- a/chats/views.py +++ b/chats/views.py @@ -22,6 +22,11 @@ download_chat_attachment, get_accessible_chat, ) +from chats.message_context import ( + MAX_CONTEXT_LIMIT, + MessageContextForm, + get_message_context, +) from chats.models import ProjectChat, DirectChat from chats.pagination import MessageListPagination from chats.permissions import IsProjectChatMember, IsChatMember, accessible_chats @@ -236,6 +241,56 @@ def post(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) +@method_decorator(never_cache, name="dispatch") +class ChatMessageContext(GenericAPIView): + permission_classes = [IsAuthenticated] + chat_model = None + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + "direction", + openapi.IN_QUERY, + description="Направление загрузки", + type=openapi.TYPE_STRING, + enum=["around", "older", "newer"], + default="around", + ), + openapi.Parameter( + "limit", + openapi.IN_QUERY, + description="Количество сообщений с каждой стороны", + type=openapi.TYPE_INTEGER, + minimum=1, + maximum=MAX_CONTEXT_LIMIT, + ), + ], + responses={200: "Сообщения по порядку, older_than и newer_than для продолжения"}, + ) + def get(self, request, id, message_id): + chat = get_accessible_chat(request.user, self.chat_model, id) + form = MessageContextForm(request.query_params) + if not form.is_valid(): + raise ValidationError(form.errors) + queryset = chat.messages.select_related("author").prefetch_related( + "file_to_message__file" + ) + if isinstance(chat, ProjectChat): + queryset = with_project_message_reads( + queryset, request.user.pk, include_reply=True + ) + else: + queryset = queryset.select_related("reply_to__author") + context = get_message_context(chat, queryset, message_id, **form.cleaned_data) + return Response( + { + "results": self.get_serializer(context.messages, many=True).data, + "older_than": context.older_than, + "newer_than": context.newer_than, + } + ) + + class ChatFileList(ListAPIView): serializer_class = ChatAttachmentSerializer permission_classes = [IsAuthenticated] diff --git a/templates/admin/chats/_message.html b/templates/admin/chats/_message.html new file mode 100644 index 00000000..814e4f4c --- /dev/null +++ b/templates/admin/chats/_message.html @@ -0,0 +1,22 @@ + +

Сообщение №{{ message.pk }} · {{ message.author.get_full_name }}

+ {% if message.pk == anchor_id %}

Выбранное сообщение

{% endif %} +

{{ message.created_at|date:"d.m.Y H:i" }}

+

{{ message.text|linebreaksbr }}

+ {% if show_context_link %} +

Показать в переписке

+ {% endif %} + {% if message.file_to_message.all %} +

Вложения:

+
    + {% for attachment in message.file_to_message.all %} + {% if attachment.is_linked_to_one_message %} +
  • + {{ attachment.file.name }}{% if attachment.file.extension %}.{{ attachment.file.extension }}{% endif %} + Скачать +
  • + {% endif %} + {% endfor %} +
+ {% endif %} + diff --git a/templates/admin/chats/message_context.html b/templates/admin/chats/message_context.html new file mode 100644 index 00000000..e5e846b2 --- /dev/null +++ b/templates/admin/chats/message_context.html @@ -0,0 +1,17 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

К поиску по переписке

+ {{ context_form.errors }} + {% if context.older_than %} +

Более ранние сообщения

+ {% endif %} + {% for message in context.messages %} + {% include "admin/chats/_message.html" %} + {% empty %} + {% if context %}

Сообщений в этом направлении больше нет.

{% endif %} + {% endfor %} + {% if context.newer_than %} +

Более новые сообщения

+ {% endif %} +{% endblock %} diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html index 91299c7f..0b86b9db 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -8,24 +8,7 @@ {% if page %}

Найдено сообщений: {{ page.paginator.count }}

{% for message in page %} -
-

Сообщение №{{ message.pk }} · {{ message.author.get_full_name }}

-

{{ message.created_at|date:"d.m.Y H:i" }}

-

{{ message.text|linebreaksbr }}

- {% if message.file_to_message.all %} -

Вложения:

-
    - {% for attachment in message.file_to_message.all %} -
  • - {{ attachment.file.name }}{% if attachment.file.extension %}.{{ attachment.file.extension }}{% endif %} - {% if attachment.is_linked_to_one_message %} - Скачать - {% endif %} -
  • - {% endfor %} -
- {% endif %} -
+ {% include "admin/chats/_message.html" with show_context_link=True %} {% empty %}

Сообщения не найдены.

{% endfor %} From 3e6efbc6adcd94f7a786e9189cc0dd8c289b3519 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 21:35:33 +0300 Subject: [PATCH 06/16] Recover chat messages after reconnect --- chats/admin.py | 144 +++++++++++++- chats/consumers/event_types/DirectEvent.py | 71 +++---- chats/consumers/event_types/ProjectEvent.py | 96 ++------- chats/migrations/0014_chat_message_sync.py | 47 +++++ chats/models.py | 64 +++++- chats/services/messages.py | 55 +++++- chats/tests/test_message_changes.py | 203 ++++++++++++++++++++ chats/tests/test_message_delivery.py | 27 +++ chats/urls.py | 15 ++ chats/views.py | 102 +++++++++- templates/admin/chats/_message.html | 3 + templates/admin/chats/message_change.html | 18 ++ 12 files changed, 714 insertions(+), 131 deletions(-) create mode 100644 chats/migrations/0014_chat_message_sync.py create mode 100644 chats/tests/test_message_changes.py create mode 100644 templates/admin/chats/message_change.html diff --git a/chats/admin.py b/chats/admin.py index 53968fba..746bf64a 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -1,14 +1,18 @@ from functools import partial from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django import forms from django.contrib import admin, messages from django.core.exceptions import PermissionDenied +from django.core.cache import cache from django.core.paginator import Paginator from django.db import transaction +from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils.html import format_html -from django.views.decorators.http import require_safe +from django.views.decorators.http import require_http_methods, require_safe from chats.attachments import download_chat_attachment, get_accessible_chat from chats.exceptions import MessageValidationException @@ -20,8 +24,12 @@ DirectChatMessage, FileToMessage, ) +from chats.permissions import accessible_chats 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.utils import get_user_channel_cache_key +from chats.websockets_settings import ChatType, EventGroupType, EventType @admin.display(description="Пользователи чата") @@ -56,6 +64,13 @@ def get_urls(self): self.admin_site.admin_view(require_safe(self.message_context)), name=self.message_search_url_name + "_context", ), + path( + object_path + "/messages//change/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.change_own_message) + ), + name=self.message_search_url_name + "_change", + ), path( object_path + "/messages//files//download/", @@ -98,6 +113,9 @@ def message_search(self, request, object_id): "message_context_route": "admin:" + self.message_search_url_name + "_context", + "message_change_route": "admin:" + + self.message_search_url_name + + "_change", "attachment_download_route": "admin:" + self.message_search_url_name + "_download", @@ -114,6 +132,91 @@ def attachment_download(self, request, object_id, message_id, attachment_id): chat = self.get_chat_for_member(request, object_id) return download_chat_attachment(request, chat, message_id, attachment_id) + def change_own_message(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + message = get_object_or_404( + chat.messages, pk=message_id, author=request.user, is_deleted=False + ) + form = OwnMessageForm( + request.POST if request.method == "POST" else None, + initial={"text": message.text}, + ) + search_route = "admin:" + self.message_search_url_name + if request.method == "POST" and form.is_valid(): + change = dict( + chat_model=self.model, + chat_id=chat.pk, + message_id=message.pk, + author=request.user, + ) + try: + with transaction.atomic(): + if form.cleaned_data["action"] == "delete": + delete_message(**change) + event_type = EventType.DELETE_MESSAGE + content = {"message_id": message.pk} + else: + event_type = EventType.EDIT_MESSAGE + content = { + "message": edit_message( + **change, text=form.cleaned_data["text"] + ) + } + transaction.on_commit( + partial(self.publish_change, chat, event_type, content), + robust=True, + ) + except MessageValidationException as error: + form.add_error(None, str(error)) + else: + if event_type == EventType.DELETE_MESSAGE: + self.message_user(request, "Сообщение удалено.") + return redirect(search_route, chat.pk) + self.message_user(request, "Сообщение сохранено.") + return redirect(search_route + "_context", chat.pk, message.pk) + request.current_app = self.admin_site.name + return TemplateResponse( + request, + "admin/chats/message_change.html", + { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": "Изменение сообщения", + "chat": chat, + "message": message, + "form": form, + "message_context_route": search_route + "_context", + }, + status=400 if form.errors else 200, + ) + + def publish_change(self, chat, event_type, content): + layer = get_channel_layer() + if layer is None: + return + if isinstance(chat, ProjectChat): + async_to_sync(layer.group_send)( + f"{EventGroupType.CHATS_RELATED}_{chat.pk}", + { + "type": event_type, + "content": { + "chat_id": chat.pk, + "chat_type": ChatType.PROJECT, + **content, + }, + "_project_chat_id": chat.pk, + }, + ) + else: + event = { + "type": event_type, + "content": {"chat_id": chat.pk, "chat_type": ChatType.DIRECT, **content}, + } + for user in chat.users.all(): + channel = cache.get(get_user_channel_cache_key(user)) + if channel: + async_to_sync(layer.send)(channel, event) + def message_context(self, request, object_id, message_id): chat = self.get_chat_for_member(request, object_id) form = MessageContextForm(request.GET) @@ -142,12 +245,27 @@ def message_context(self, request, object_id, message_id): ), "search_route": search_route, "message_context_route": search_route + "_context", + "message_change_route": search_route + "_change", "attachment_download_route": search_route + "_download", }, status=200 if context is not None else 400, ) +class OwnMessageForm(forms.Form): + text = forms.CharField( + label="Текст сообщения", required=False, strip=False, widget=forms.Textarea + ) + action = forms.ChoiceField( + choices=[("edit", "Сохранить"), ("delete", "Удалить")], + widget=forms.HiddenInput, + error_messages={ + "required": "Выберите действие.", + "invalid_choice": "Выберите доступное действие.", + }, + ) + + @admin.register(ProjectChat) class ChatAdmin(ChatSearchAdmin): list_display = ( @@ -176,8 +294,28 @@ class DirectChatAdmin(ChatSearchAdmin): list_display_links = ("id",) +class ChatMessageAdmin(admin.ModelAdmin): + def get_queryset(self, request): + queryset = super().get_queryset(request) + if request.user.is_superuser: + return queryset + chat_model = self.model._meta.get_field("chat").related_model + return queryset.filter( + chat_id__in=accessible_chats(request.user, chat_model).values("pk") + ) + + def has_add_permission(self, request): + return request.user.is_superuser and super().has_add_permission(request) + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser and super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser and super().has_delete_permission(request, obj) + + @admin.register(ProjectChatMessage) -class ProjectChatMessageAdmin(admin.ModelAdmin): +class ProjectChatMessageAdmin(ChatMessageAdmin): list_display = ("id", "author", "chat", "created_at") list_display_links = ("id", "author", "chat") exclude = ("is_read",) @@ -216,7 +354,7 @@ def mark_read_for_me(self, request, queryset): @admin.register(DirectChatMessage) -class DirectChatMessageAdmin(admin.ModelAdmin): +class DirectChatMessageAdmin(ChatMessageAdmin): list_display = ("id", "author", "chat", "created_at") list_display_links = ("id", "author", "chat") diff --git a/chats/consumers/event_types/DirectEvent.py b/chats/consumers/event_types/DirectEvent.py index 3fa43b9a..f234e625 100644 --- a/chats/consumers/event_types/DirectEvent.py +++ b/chats/consumers/event_types/DirectEvent.py @@ -1,12 +1,8 @@ from channels.db import database_sync_to_async from chats.models import DirectChatMessage, DirectChat -from chats.websockets_settings import Event, EventType -from chats.exceptions import ( - WrongChatIdException, - UserNotMessageAuthorException, - UserIsNotAuthor, -) +from chats.websockets_settings import ChatType, Event, EventType +from chats.exceptions import WrongChatIdException from django.core.cache import cache from chats.utils import ( get_user_channel_cache_key, @@ -14,8 +10,7 @@ orm_get, orm_save, ) -from chats.serializers import DirectChatMessageListSerializer -from chats.services import create_direct_message +from chats.services.messages import create_direct_message, delete_message, edit_message class DirectEvent: @@ -81,18 +76,13 @@ async def process_read_message_event(self, event: Event, room_name: str): await self.channel_layer.send(other_user_channel, json_thingy) async def process_delete_message_event(self, event: Event, room_name: str): - message_id = event.content["message_id"] - - message = await orm_get(DirectChatMessage.objects, pk=message_id) - - if self.user.id != message.author_id: - raise UserIsNotAuthor(f"User {self.user.id} is not author {message.text}") - - message.is_deleted = True - await orm_save(message, update_fields=["is_deleted"]) - - chat_id, other_user = await get_chat_and_user_ids_from_content( - event.content, self.user + _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) + chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) + message_id = await database_sync_to_async(delete_message)( + chat_model=DirectChat, + chat_id=chat_id, + message_id=event.content["message_id"], + author=self.user, ) # send message to user's channel @@ -101,42 +91,25 @@ async def process_delete_message_event(self, event: Event, room_name: str): "type": EventType.DELETE_MESSAGE, "content": { "message_id": message_id, + "chat_id": chat_id, + "chat_type": ChatType.DIRECT, }, } await self.channel_layer.send(self.channel_name, content) - if other_user_channel is None: - return - - await self.channel_layer.send(other_user_channel, content) + if other_user_channel and other_user_channel != self.channel_name: + await self.channel_layer.send(other_user_channel, content) async def process_edit_message_event(self, event, room_name): - chat_id, other_user = await get_chat_and_user_ids_from_content( - event.content, self.user - ) - # if chat_id == 17_7, then chat_id will be == 7_17 + _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) - - # check if chat exists ( this raises exception if not ) - await orm_get(DirectChat.objects, pk=chat_id) - - msg = await orm_get(DirectChatMessage.objects, pk=event.content["message_id"]) - - if msg.author_id != self.user.id: - raise UserNotMessageAuthorException( - f"User {self.user.id} is not author of message {msg.id}" - ) - msg.text = event.content["text"] - msg.is_edited = True - await orm_save(msg, update_fields=["text", "is_edited"]) - - serialized_message = await orm_get( - DirectChatMessage.objects.select_related( - "author", "reply_to__author" - ).prefetch_related("file_to_message__file"), - pk=msg.pk, + message_data = await database_sync_to_async(edit_message)( + chat_model=DirectChat, + chat_id=chat_id, + message_id=event.content["message_id"], + author=self.user, + text=event.content["text"], ) - message_data = DirectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, @@ -147,7 +120,7 @@ async def process_edit_message_event(self, event, room_name): event_data = {"type": EventType.EDIT_MESSAGE, "content": content} - if other_user_channel: + if other_user_channel and other_user_channel != self.channel_name: await self.channel_layer.send(other_user_channel, event_data) await self.channel_layer.send(self.channel_name, event_data) diff --git a/chats/consumers/event_types/ProjectEvent.py b/chats/consumers/event_types/ProjectEvent.py index d41c02bb..6249f966 100644 --- a/chats/consumers/event_types/ProjectEvent.py +++ b/chats/consumers/event_types/ProjectEvent.py @@ -1,24 +1,9 @@ from channels.db import database_sync_to_async -from chats.models import ProjectChat, ProjectChatMessage +from chats.models import ProjectChat from chats.project_reads import mark_project_message_read, publish_project_message_read -from chats.utils import ( - orm_exists, - orm_get, - orm_save, -) -from chats.services import create_project_message -from chats.websockets_settings import Event, EventType -from chats.exceptions import ( - UserNotInChatException, - UserNotMessageAuthorException, - UserIsNotAuthor, -) - -from chats.serializers import ( - ProjectChatMessageListSerializer, -) -from projects.models import Collaborator +from chats.services.messages import create_project_message, delete_message, edit_message +from chats.websockets_settings import ChatType, Event, EventType class ProjectEvent: @@ -73,67 +58,35 @@ async def process_read_message_event(self, event: Event, room_name: str): async def process_delete_message_event(self, event: Event, room_name: str): chat_id = event.content["chat_id"] - chat = await orm_get( - ProjectChat.objects.select_related("project__leader"), - pk=chat_id, - ) - - # check that user is in this chat - is_member = chat.project.leader_id == self.user.id or await orm_exists( - Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) - ) - if not is_member: - raise UserNotInChatException( - f"User {self.user.id} is not in project chat {chat_id}" - ) - - message = await orm_get( - ProjectChatMessage.objects, pk=event.content["message_id"] + message_id = await database_sync_to_async(delete_message)( + chat_model=ProjectChat, + chat_id=chat_id, + message_id=event.content["message_id"], + author=self.user, ) - if self.user.id != message.author_id: - raise UserIsNotAuthor(f"User {self.user.id} is not author {chat_id}") - message.is_deleted = True - await orm_save(message, update_fields=["is_deleted"]) - await self.channel_layer.group_send( room_name, { "type": EventType.DELETE_MESSAGE, - "content": {"message_id": event.content["message_id"]}, - "_project_chat_id": event.content["chat_id"], + "content": { + "message_id": message_id, + "chat_id": chat_id, + "chat_type": ChatType.PROJECT, + }, + "_project_chat_id": chat_id, }, ) async def process_edit_message_event(self, event, room_name): chat_id = event.content["chat_id"] - chat = await orm_get( - ProjectChat.objects.select_related("project__leader"), - pk=chat_id, - ) - - # check that user is in this chat - is_member = chat.project.leader_id == self.user.id or await orm_exists( - Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) - ) - if not is_member: - raise UserNotInChatException( - f"User {self.user.id} is not in project chat {chat_id}" - ) - - message = await orm_get( - ProjectChatMessage.objects, pk=event.content["message_id"] + message_data = await database_sync_to_async(edit_message)( + chat_model=ProjectChat, + chat_id=chat_id, + message_id=event.content["message_id"], + author=self.user, + text=event.content["text"], ) - - if message.author_id != self.user.id: - raise UserNotMessageAuthorException( - f"User {self.user.id} is not author of message {message.id}" - ) - message.text = event.content["text"] - message.is_edited = True - await orm_save(message, update_fields=["text", "is_edited"]) - - message_data = await database_sync_to_async(serialize_project_message)(message.pk) content = { "chat_id": chat_id, "message": message_data, @@ -146,12 +99,3 @@ async def process_edit_message_event(self, event, room_name): } await self.channel_layer.group_send(room_name, event_data) - - -def serialize_project_message(message_id): - message = ( - ProjectChatMessage.objects.select_related("author", "reply_to__author") - .prefetch_related("file_to_message__file") - .get(pk=message_id) - ) - return ProjectChatMessageListSerializer(message).data diff --git a/chats/migrations/0014_chat_message_sync.py b/chats/migrations/0014_chat_message_sync.py new file mode 100644 index 00000000..181723fb --- /dev/null +++ b/chats/migrations/0014_chat_message_sync.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.18 on 2026-11-13 18:24 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0013_project_chat_message_reads"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="directchat", + name="message_revision", + field=models.PositiveBigIntegerField(db_default=0, default=0, editable=False), + ), + migrations.AddField( + model_name="directchatmessage", + name="sync_revision", + field=models.PositiveBigIntegerField(editable=False, null=True), + ), + migrations.AddField( + model_name="projectchat", + name="message_revision", + field=models.PositiveBigIntegerField(db_default=0, default=0, editable=False), + ), + migrations.AddField( + model_name="projectchatmessage", + name="sync_revision", + field=models.PositiveBigIntegerField(editable=False, null=True), + ), + migrations.AddIndex( + model_name="directchatmessage", + index=models.Index( + fields=["chat", "sync_revision"], name="direct_chat_sync_idx" + ), + ), + migrations.AddIndex( + model_name="projectchatmessage", + index=models.Index( + fields=["chat", "sync_revision"], name="project_chat_sync_idx" + ), + ), + ] diff --git a/chats/models.py b/chats/models.py index 2262fc88..b577b041 100644 --- a/chats/models.py +++ b/chats/models.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError -from django.db import models, transaction +from django.db import models, router, transaction from files.models import UserFile from projects.models import Project @@ -21,6 +21,28 @@ class BaseChat(models.Model): """ created_at = models.DateTimeField(auto_now_add=True) + message_revision = models.PositiveBigIntegerField( + default=0, db_default=0, editable=False + ) + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + if not self._state.adding and update_fields is None: + # A chat settings save must not overwrite a newer message cursor. + update_fields = { + field.attname + for field in self._meta.concrete_fields + if not field.primary_key + and field.name != "message_revision" + and field.attname not in self.get_deferred_fields() + } + return super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) def get_last_message(self): return self.messages.last() @@ -194,6 +216,40 @@ class BaseMessage(models.Model): is_edited = models.BooleanField(default=False) client_message_id = models.UUIDField(null=True, blank=True, editable=False) created_at = models.DateTimeField(auto_now_add=True) + sync_revision = models.PositiveBigIntegerField(null=True, editable=False) + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + if update_fields is not None and not { + "text", + "is_deleted", + "is_edited", + "reply_to", + "reply_to_id", + }.intersection(update_fields): + return super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) + chat_model = self._meta.get_field("chat").related_model + using = using or router.db_for_write(type(self), instance=self) + with transaction.atomic(using=using): + chats = chat_model.objects.using(using) + # Serialize the revision with the message write in the same transaction. + chat = chats.select_for_update().get(pk=self.chat_id) + self.sync_revision = chat.message_revision + 1 + chats.filter(pk=chat.pk).update(message_revision=self.sync_revision) + if update_fields is not None: + update_fields = {*update_fields, "sync_revision"} + return super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) def __str__(self): return f"Message<{self.pk}>" @@ -241,6 +297,9 @@ def __str__(self): class Meta: verbose_name = "Сообщение в чате проекта" verbose_name_plural = "Сообщения в чатах проектов" + indexes = [ + models.Index(fields=["chat", "sync_revision"], name="project_chat_sync_idx") + ] constraints = [ models.UniqueConstraint( fields=["chat", "author", "client_message_id"], @@ -302,6 +361,9 @@ def __str__(self): class Meta: verbose_name = "Сообщение в личном чате" verbose_name_plural = "Сообщения в личных чатах" + indexes = [ + models.Index(fields=["chat", "sync_revision"], name="direct_chat_sync_idx") + ] constraints = [ models.UniqueConstraint( fields=["chat", "author", "client_message_id"], diff --git a/chats/services/messages.py b/chats/services/messages.py index 4fa74daa..cf11628d 100644 --- a/chats/services/messages.py +++ b/chats/services/messages.py @@ -10,7 +10,7 @@ ProjectChat, ProjectChatMessage, ) -from chats.permissions import is_project_member +from chats.permissions import accessible_chats, is_project_member from chats.serializers import ( DirectChatMessageListSerializer, ProjectChatMessageListSerializer, @@ -197,3 +197,56 @@ def create_project_message( file_urls=file_urls, client_message_id=client_message_id, ) + + +def _get_message_for_change(chat_model, chat_id, message_id, author): + if type(message_id) not in (str, int): + raise MessageValidationException("Некорректный идентификатор сообщения.") + chat = ( + chat_model.objects.select_for_update() + .filter(pk=chat_id, pk__in=accessible_chats(author, chat_model).values("pk")) + .first() + ) + if chat is None: + raise MessageValidationException("Чат недоступен.") + try: + message = chat.messages.filter(pk=message_id, author=author).first() + except (TypeError, ValueError): + message = None + if message is None: + raise MessageValidationException("Сообщение недоступно для изменения.") + return message + + +@transaction.atomic +def edit_message(*, chat_model, chat_id, message_id, author, text): + message = _get_message_for_change(chat_model, chat_id, message_id, author) + if message.is_deleted: + raise MessageValidationException("Сообщение уже удалено.") + if not isinstance(text, str) or "\x00" in text: + raise MessageValidationException("Некорректный текст сообщения.") + if len(text) > message._meta.get_field("text").max_length: + raise MessageValidationException( + "Текст сообщения не должен превышать 8192 символа." + ) + if not text.strip() and not message.file_to_message.exists(): + raise MessageValidationException("Сообщение не может быть пустым.") + if message.text != text or not message.is_edited: + message.text = text + message.is_edited = True + message.save(update_fields=["text", "is_edited"]) + serializer_class = ( + DirectChatMessageListSerializer + if chat_model is DirectChat + else ProjectChatMessageListSerializer + ) + return _serialize_message(type(message), serializer_class, message.pk) + + +@transaction.atomic +def delete_message(*, chat_model, chat_id, message_id, author): + message = _get_message_for_change(chat_model, chat_id, message_id, author) + if not message.is_deleted: + message.is_deleted = True + message.save(update_fields=["is_deleted"]) + return message.pk diff --git a/chats/tests/test_message_changes.py b/chats/tests/test_message_changes.py new file mode 100644 index 00000000..e63169e1 --- /dev/null +++ b/chats/tests/test_message_changes.py @@ -0,0 +1,203 @@ +from uuid import uuid4 + +from channels.db import database_sync_to_async +from channels.testing import WebsocketCommunicator +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import TestCase, TransactionTestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from chats.consumers import ChatConsumer +from chats.models import DirectChat, ProjectChatMessageRead +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_project_reads import create_project_chat +from projects.models import Collaborator + + +def chat_url(kind, chat, suffix): + name = f"{kind}-chat-{suffix}" + return reverse("chats:" + name, args=[chat.pk]) + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class MessageChangesTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.author = users.create(**TEST_USER1) + cls.member = users.create(**TEST_USER2) + cls.outsider = users.create(**TEST_USER3) + cls.direct = DirectChat.get_chat(cls.author, cls.member) + cls.project = create_project_chat(cls.author, cls.member) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.member) + + def changes(self, kind, chat, **params): + return self.api.get(chat_url(kind, chat, "message-changes"), params, secure=True) + + def test_pages_include_current_state_and_personal_project_reads(self): + for kind in ("direct", "project"): + with self.subTest(kind=kind): + chat = getattr(self, kind) + old = chat.messages.create(author=self.author, text="История") + deleted = chat.messages.create(author=self.author, text="Будет удалено") + history = self.api.get(chat_url(kind, chat, "messages"), secure=True) + cursor = history.json()["sync_cursor"] + + new = chat.messages.create(author=self.author, text="Новое") + if kind == "project": + ProjectChatMessageRead.objects.create(message=new, user=self.member) + old.text = "Исправленная история" + old.save(update_fields=["text"]) + deleted.is_deleted = True + deleted.save(update_fields=["is_deleted"]) + + first = self.changes(kind, chat, after=cursor, limit=2) + self.assertEqual(first.status_code, 200) + self.assertIn("no-store", first["Cache-Control"]) + page = first.json() + self.assertTrue(page["has_more"]) + self.assertEqual( + [item["message_id"] for item in page["results"]], [new.pk, old.pk] + ) + self.assertEqual( + page["results"][0]["message"]["is_read"], kind == "project" + ) + self.assertEqual( + page["results"][1]["message"]["text"], "Исправленная история" + ) + final = self.changes(kind, chat, after=page["next_cursor"]).json() + self.assertEqual( + final["results"], [{"type": "delete", "message_id": deleted.pk}] + ) + self.assertFalse(final["has_more"]) + self.assertEqual( + self.changes(kind, chat, after=final["next_cursor"]).json(), + { + "results": [], + "next_cursor": final["next_cursor"], + "has_more": False, + }, + ) + self.assertEqual(ProjectChatMessageRead.objects.count(), 1) + + def test_validation_and_project_membership_use_current_state(self): + for kind in ("direct", "project"): + chat = getattr(self, kind) + with self.subTest(kind=kind): + self.api.force_authenticate(self.member) + for params in ({}, {"after": -1}, {"after": 0, "limit": 101}): + self.assertEqual(self.changes(kind, chat, **params).status_code, 400) + self.assertEqual(self.changes(kind, chat, after=1).status_code, 409) + self.api.force_authenticate(self.outsider) + self.assertEqual(self.changes(kind, chat, after=0).status_code, 404) + Collaborator.objects.filter(project_id=self.project.pk, user=self.member).delete() + self.api.force_authenticate(self.member) + self.assertEqual(self.changes("project", self.project, after=0).status_code, 404) + + def test_saving_chat_metadata_preserves_cursor(self): + stale = DirectChat.objects.get(pk=self.direct.pk) + self.direct.messages.create(author=self.author, text="Новое") + stale.save() + self.assertEqual( + self.changes("direct", self.direct, after=0).json()["next_cursor"], 1 + ) + + def test_admin_organizer_edits_and_deletes_own_message(self): + self.author.is_staff = True + self.author.is_active = True + self.author.save(update_fields=["is_staff", "is_active"]) + self.author.user_permissions.add( + Permission.objects.get( + content_type__app_label="chats", codename="view_projectchat" + ) + ) + self.client.force_login(self.author) + own = self.project.messages.create(author=self.author, text="До правки") + other = self.project.messages.create(author=self.member, text="Чужое сообщение") + route = "admin:chats_projectchat_message_search_change" + own_url = reverse(route, args=[self.project.pk, own.pk]) + other_url = reverse(route, args=[self.project.pk, other.pk]) + history = self.api.get(chat_url("project", self.project, "messages"), secure=True) + cursor = history.json()["sync_cursor"] + self.assertContains(self.client.get(own_url, secure=True), "До правки") + self.assertEqual(self.client.get(other_url, secure=True).status_code, 404) + invalid = self.client.post(own_url, {"action": "edit", "text": ""}, secure=True) + self.assertContains(invalid, "Сообщение не может быть пустым.", status_code=400) + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.client.post( + own_url, {"action": "edit", "text": "После правки"}, secure=True + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(callbacks), 1) + changed = self.changes("project", self.project, after=cursor).json() + self.assertEqual(changed["results"][0]["message"]["text"], "После правки") + self.assertEqual( + self.client.post(own_url, {"action": "delete"}, secure=True).status_code, 302 + ) + removed = self.changes( + "project", self.project, after=changed["next_cursor"] + ).json() + self.assertEqual(removed["results"], [{"type": "delete", "message_id": own.pk}]) + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class ReconnectTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.author = users.create(**TEST_USER1) + self.member = users.create(**TEST_USER2) + self.chat = create_project_chat(self.author, self.member) + self.old = self.chat.messages.create(author=self.author, text="Первое") + self.api = APIClient() + self.api.force_authenticate(self.member) + + async def test_disconnected_member_can_recover_websocket_writes(self): + history = await database_sync_to_async(self.api.get)( + chat_url("project", self.chat, "messages"), secure=True + ) + cursor = history.json()["sync_cursor"] + writer = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + writer.scope["user"] = self.author + connected, _ = await writer.connect(timeout=5) + self.assertTrue(connected) + + async def send(kind, **content): + await writer.send_json_to( + { + "type": kind, + "content": { + "chat_type": "project", + "chat_id": self.chat.pk, + **content, + }, + } + ) + response = await writer.receive_json_from(timeout=5) + self.assertEqual(response["type"], kind) + return response["content"] + + try: + send_params = {"text": "Новое", "client_message_id": str(uuid4())} + new = await send("new_message", **send_params) + replay = await send("new_message", **send_params) + self.assertTrue(replay["is_replay"]) + await send("edit_message", message_id=self.old.pk, text="Правка") + await send("delete_message", message_id=new["message"]["id"]) + finally: + await writer.disconnect() + + changes = await database_sync_to_async(self.api.get)( + chat_url("project", self.chat, "message-changes"), + {"after": cursor}, + secure=True, + ) + self.assertEqual(changes.status_code, 200) + data = changes.json() + edited, removed = data["results"] + self.assertEqual(edited["message"]["text"], "Правка") + self.assertEqual(removed, {"type": "delete", "message_id": new["message"]["id"]}) + self.assertEqual(data["next_cursor"], cursor + 3) diff --git a/chats/tests/test_message_delivery.py b/chats/tests/test_message_delivery.py index a3b68b67..39ac83d7 100644 --- a/chats/tests/test_message_delivery.py +++ b/chats/tests/test_message_delivery.py @@ -226,6 +226,33 @@ def send(): self.assertEqual(first["id"], second["id"]) self.assertEqual(ProjectChatMessage.objects.count(), 1) self.assertEqual(FileToMessage.objects.count(), 1) + self.chat.refresh_from_db() + self.assertEqual(self.chat.message_revision, 1) + self.assertEqual(ProjectChatMessage.objects.get().sync_revision, 1) + + @skipUnlessDBFeature("has_select_for_update") + def test_simultaneous_distinct_messages_have_distinct_sync_revisions(self): + ready = Barrier(2) + + def send(text): + try: + ready.wait(timeout=10) + message, _ = create_project_message( + chat_id=self.chat.pk, author=self.member, text=text + ) + return message["id"] + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + requests = [pool.submit(send, text) for text in ("Первое", "Второе")] + sent = [request.result(timeout=10) for request in requests] + + messages = self.chat.messages.order_by("sync_revision") + self.assertCountEqual(messages.values_list("pk", flat=True), sent) + self.assertEqual(list(messages.values_list("sync_revision", flat=True)), [1, 2]) + self.chat.refresh_from_db() + self.assertEqual(self.chat.message_revision, 2) def test_failed_attachment_write_rolls_back_new_direct_chat_and_message(self): user_file = UserFile.objects.create( diff --git a/chats/urls.py b/chats/urls.py index 8708149d..bf736b91 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -7,6 +7,7 @@ ) from chats.views import ( ChatAttachmentDownload, + ChatMessageChanges, ChatMessageContext, DirectChatList, DirectChatMessageList, @@ -34,6 +35,13 @@ DirectChatFileList.as_view(), name="direct-chat-files", ), + path( + "directs//messages/changes/", + ChatMessageChanges.as_view( + chat_model=DirectChat, serializer_class=DirectChatMessageListSerializer + ), + name="direct-chat-message-changes", + ), path( "directs//messages//context/", ChatMessageContext.as_view( @@ -58,6 +66,13 @@ ProjectChatFileList.as_view(), name="project-chat-files", ), + path( + "projects//messages/changes/", + ChatMessageChanges.as_view( + chat_model=ProjectChat, serializer_class=ProjectChatMessageListSerializer + ), + name="project-chat-message-changes", + ), path( "projects//messages//context/", ChatMessageContext.as_view( diff --git a/chats/views.py b/chats/views.py index bc8746a6..cb7239c4 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,3 +1,4 @@ +from django import forms from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator @@ -187,7 +188,10 @@ class ChatMessageHistory(ListCreateAPIView): ] ) def get(self, request, *args, **kwargs): - return super().get(request, *args, **kwargs) + response = super().get(request, *args, **kwargs) + if hasattr(self, "sync_cursor"): + response.data["sync_cursor"] = self.sync_cursor + return response def get_queryset(self): user = self.request.user @@ -199,6 +203,12 @@ def get_queryset(self): form = ChatMessageSearchForm(self.request.query_params) if not form.is_valid(): raise ValidationError(form.errors) + if ( + not form.cleaned_data["q"] + and form.cleaned_data["author_id"] is None + and form.cleaned_data["scope"] != "files" + ): + self.sync_cursor = chat.message_revision return search_chat_messages(chat, form.cleaned_data) @@ -291,6 +301,96 @@ def get(self, request, id, message_id): ) +MAX_SYNC_LIMIT = 100 +MAX_SYNC_CURSOR = 2**63 - 1 + + +class ChatMessageChangesForm(forms.Form): + after = forms.IntegerField(min_value=0, max_value=MAX_SYNC_CURSOR) + limit = forms.IntegerField(required=False, min_value=1, max_value=MAX_SYNC_LIMIT) + + +@method_decorator(never_cache, name="dispatch") +class ChatMessageChanges(GenericAPIView): + permission_classes = [IsAuthenticated] + chat_model = None + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + "after", + openapi.IN_QUERY, + description="Курсор из sync_cursor истории или next_cursor изменений", + type=openapi.TYPE_INTEGER, + required=True, + minimum=0, + ), + openapi.Parameter( + "limit", + openapi.IN_QUERY, + description="Количество изменений на странице", + type=openapi.TYPE_INTEGER, + minimum=1, + maximum=MAX_SYNC_LIMIT, + default=MAX_SYNC_LIMIT, + ), + ], + responses={ + 200: "Изменения, next_cursor и has_more", + 409: "Курсор устарел. Нужно заново загрузить историю.", + }, + ) + def get(self, request, id): + chat = get_accessible_chat(request.user, self.chat_model, id) + form = ChatMessageChangesForm(request.query_params) + if not form.is_valid(): + raise ValidationError(form.errors) + after = form.cleaned_data["after"] + limit = form.cleaned_data["limit"] or MAX_SYNC_LIMIT + high_water = chat.message_revision + if after > high_water: + return Response( + {"detail": "Курсор недействителен. Загрузите историю чата заново."}, + status=status.HTTP_409_CONFLICT, + ) + + changed = ( + chat.messages.filter(sync_revision__gt=after, sync_revision__lte=high_water) + .order_by("sync_revision") + .select_related("author") + .prefetch_related("file_to_message__file") + ) + if isinstance(chat, ProjectChat): + changed = with_project_message_reads( + changed, request.user.pk, include_reply=True + ) + else: + changed = changed.select_related("reply_to__author") + page = list(changed[: limit + 1]) + has_more = len(page) > limit + results = [] + for message in page[:limit]: + if message.is_deleted: + results.append({"type": "delete", "message_id": message.pk}) + else: + results.append( + { + "type": "upsert", + "message_id": message.pk, + "message": self.get_serializer(message).data, + } + ) + # A message may move past this watermark if edited during the read. + # That newer revision will be fetched on the following request. + return Response( + { + "results": results, + "next_cursor": page[limit - 1].sync_revision if has_more else high_water, + "has_more": has_more, + } + ) + + class ChatFileList(ListAPIView): serializer_class = ChatAttachmentSerializer permission_classes = [IsAuthenticated] diff --git a/templates/admin/chats/_message.html b/templates/admin/chats/_message.html index 814e4f4c..4c294ce1 100644 --- a/templates/admin/chats/_message.html +++ b/templates/admin/chats/_message.html @@ -6,6 +6,9 @@

Сообщение №{{ message.pk }} · {{ message.author.get_full_name }} {% if show_context_link %}

Показать в переписке

{% endif %} + {% if message.author_id == request.user.pk and message_change_route %} +

Изменить или удалить

+ {% endif %} {% if message.file_to_message.all %}

Вложения:

    diff --git a/templates/admin/chats/message_change.html b/templates/admin/chats/message_change.html new file mode 100644 index 00000000..55544d89 --- /dev/null +++ b/templates/admin/chats/message_change.html @@ -0,0 +1,18 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

    К сообщению в переписке

    +
    + {% csrf_token %} + {{ form.non_field_errors }} + {{ form.action.errors }} +

    {{ form.text.label_tag }} {{ form.text }}

    + {{ form.text.errors }} + +
    +
    + {% csrf_token %} +

    Сообщение будет удалено из переписки у всех участников чата.

    + +
    +{% endblock %} From 4ce7a7831f6f5221b758de0dac360c4c5ba4f0eb Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 21:47:19 +0300 Subject: [PATCH 07/16] Enforce chat access for previews and socket delivery --- chats/admin.py | 1 + chats/consumers/chat.py | 142 +++++++++++---------- chats/consumers/event_types/DirectEvent.py | 63 +++++---- chats/models.py | 8 +- chats/permissions.py | 6 + chats/search.py | 2 +- chats/serializers.py | 10 +- chats/services/messages.py | 29 ++++- chats/tests/test_permissions.py | 118 ++++++++++++++++- chats/utils.py | 13 -- chats/views.py | 23 ++-- core/auth/middleware.py | 75 +---------- 12 files changed, 289 insertions(+), 201 deletions(-) diff --git a/chats/admin.py b/chats/admin.py index 746bf64a..bab1f592 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -211,6 +211,7 @@ def publish_change(self, chat, event_type, content): event = { "type": event_type, "content": {"chat_id": chat.pk, "chat_type": ChatType.DIRECT, **content}, + "_direct_chat_id": chat.pk, } for user in chat.users.all(): channel = cache.get(get_user_channel_cache_key(user)) diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index 3fadc458..bd1b5bb9 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -7,12 +7,12 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.conf import settings from django.core.cache import cache -from django.db.models import Q from django.utils import timezone from chats.exceptions import ChatException -from chats.models import BaseChat +from chats.permissions import accessible_chats, direct_chat_is_accessible from chats.project_reads import personalize_project_message_event +from chats.services.messages import get_direct_chat_for_participants from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ( Event, @@ -22,41 +22,26 @@ ) from core.constants import ONE_DAY_IN_SECONDS, ONE_WEEK_IN_SECONDS from core.utils import get_user_online_cache_key, get_users_online_cache_key -from projects.models import Collaborator 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, ProjectChat +from chats.models import ProjectChat @database_sync_to_async -def get_user_project_ids(user_id: int) -> list[int]: - return list( - Collaborator.objects.filter(user_id=user_id).values_list("project", flat=True) - ) +def get_user_project_ids(user) -> list[int]: + return list(accessible_chats(user, ProjectChat).values_list("pk", flat=True)) @database_sync_to_async -def can_access_project_chat(user_id, chat_id): - try: - chat_id = int(chat_id) - except (TypeError, ValueError): - return False - return ( - ProjectChat.objects.filter(pk=chat_id) - .filter(Q(project__leader_id=user_id) | Q(project__collaborator__user_id=user_id)) - .exists() - ) +def can_access_project_chat(user, chat_id): + return accessible_chats(user, ProjectChat).filter(pk=chat_id).exists() class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.room_name: str = "" self.user: Optional[CustomUser] = None - self.chat_type = None - self.chat: Optional[BaseChat] = None - self.event = None self.joined_rooms: set[str] = set() async def connect(self): @@ -72,17 +57,11 @@ async def connect(self): ) if not settings.RUNNING_TESTS: - # get all projects that user is a member of - project_ids_list = await get_user_project_ids(self.user.id) + project_ids_list = await get_user_project_ids(self.user) for project_id in project_ids_list: - # FIXME: if a user is a leader but not a collaborator, this doesn't work - # upd: it seems not possible to be a leader without being a collaborator - # join room for each project - - # It's currently not possible to do this in a single call, - - # so we have to do it in a loop (e.g. that's O(N) calls to layer backend, redis cache that would be) - - room_name = f"{EventGroupType.CHATS_RELATED}_{project_id}" - await self.channel_layer.group_add(room_name, self.channel_name) - self.joined_rooms.add(room_name) + await self._ensure_room_subscription( + f"{EventGroupType.CHATS_RELATED}_{project_id}" + ) # set user online user_cache_key = get_user_online_cache_key(self.user) @@ -120,6 +99,16 @@ async def disconnect(self, close_code): # don't need to proccess logic for disconnect # if we are not logged in! return + for room_name in self.joined_rooms: + await self.channel_layer.group_discard(room_name, self.channel_name) + self.joined_rooms.clear() + await self.channel_layer.group_discard( + EventGroupType.GENERAL_EVENTS, self.channel_name + ) + user_channel_key = get_user_channel_cache_key(self.user) + if cache.get(user_channel_key) != self.channel_name: + return + cache.delete(user_channel_key) online_users = cache.get(get_users_online_cache_key(), set()) online_users.discard(self.user.id) cache.set(get_users_online_cache_key(), online_users) @@ -135,7 +124,9 @@ async def disconnect(self, close_code): async def receive_json(self, content, **kwargs): """Receive message from WebSocket in JSON format""" - # todo reply_to key is not required + if not isinstance(content, dict) or not isinstance(content.get("type"), str): + await self.send_json({"error": "Некорректный формат события."}) + return event = Event(type=content["type"], content=content.get("content")) # two event types - related to group chat and related to leave/connect @@ -146,26 +137,33 @@ async def receive_json(self, content, **kwargs): EventType.DELETE_MESSAGE, EventType.EDIT_MESSAGE, ]: - if event.content["chat_type"] == ChatType.DIRECT: - self.event = DirectEvent(self.user, self.channel_layer, self.channel_name) - elif event.content["chat_type"] == ChatType.PROJECT: - if not await can_access_project_chat( - self.user.pk, event.content.get("chat_id") - ): + if not isinstance(event.content, dict): + await self.send_json({"error": "Некорректные данные события."}) + return + chat_type = event.content.get("chat_type") + chat_id = event.content.get("chat_id") + room_name = "" + if chat_type == ChatType.DIRECT: + handler = DirectEvent(self.user, self.channel_layer, self.channel_name) + elif chat_type == ChatType.PROJECT: + try: + if type(chat_id) not in (int, str): + raise ValueError + project_id = int(chat_id) + except ValueError: + await self.send_json({"error": "Некорректный идентификатор чата."}) + return + if not await can_access_project_chat(self.user, project_id): await self.send_json({"error": "У вас нет доступа к чату проекта."}) return - self.event = ProjectEvent( - self.user, self.channel_layer, self.channel_name - ) - - chat_id = event.content.get("chat_id") - if event.content["chat_type"] == ChatType.PROJECT: - chat_id = int(chat_id) - room_name = f"{EventGroupType.CHATS_RELATED}_{chat_id}" - if event.content["chat_type"] == ChatType.PROJECT: + handler = ProjectEvent(self.user, self.channel_layer, self.channel_name) + room_name = f"{EventGroupType.CHATS_RELATED}_{project_id}" await self._ensure_room_subscription(room_name) + else: + await self.send_json({"error": "Неизвестный тип чата."}) + return try: - await self.__process_chat_related_event(event, room_name) + await self.__process_chat_related_event(handler, event, room_name) except ChatException as e: await self.send_json({"error": str(e.get_error())}) except KeyError as e: @@ -175,20 +173,19 @@ async def receive_json(self, content, **kwargs): room_name = EventGroupType.GENERAL_EVENTS await self.__process_general_event(event, room_name) else: - return self.disconnect(400) + await self.send_json({"error": "Неизвестный тип события."}) - async def __process_chat_related_event(self, event, room_name): - self.event: DirectEvent | ProjectEvent + async def __process_chat_related_event(self, handler, event, room_name): if event.type == EventType.NEW_MESSAGE: - await self.event.process_new_message_event(event, room_name) + await handler.process_new_message_event(event, room_name) elif event.type == EventType.TYPING: await self.__process_typing_event(event, room_name) elif event.type == EventType.READ_MESSAGE: - await self.event.process_read_message_event(event, room_name) + await handler.process_read_message_event(event, room_name) elif event.type == EventType.DELETE_MESSAGE: - await self.event.process_delete_message_event(event, room_name) + await handler.process_delete_message_event(event, room_name) elif event.type == EventType.EDIT_MESSAGE: - await self.event.process_edit_message_event(event, room_name) + await handler.process_edit_message_event(event, room_name) async def __process_typing_event(self, event: Event, room_name: str): """Send typing event to room group.""" @@ -204,29 +201,37 @@ async def __process_typing_event(self, event: Event, room_name: str): if event.content["chat_type"] == ChatType.PROJECT: event_data["_project_chat_id"] = event.content["chat_id"] + await self.channel_layer.group_send(room_name, event_data) - if event.content["chat_type"] == ChatType.DIRECT: + else: _, other_user = await get_chat_and_user_ids_from_content( event.content, self.user ) - await database_sync_to_async(DirectChat.get_chat)(self.user, other_user) + chat = await database_sync_to_async(get_direct_chat_for_participants)( + self.user, other_user + ) + event_data["content"]["chat_id"] = chat.pk + event_data["_direct_chat_id"] = chat.pk # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - if other_user_channel: + if other_user_channel and other_user_channel != self.channel_name: await self.channel_layer.send(other_user_channel, event_data) - await self.channel_layer.group_send( - room_name, - event_data, - ) - async def _send_chat_event(self, event): + direct_chat_id = event.get("_direct_chat_id") + if direct_chat_id is not None and ( + not self.user + or not await database_sync_to_async(direct_chat_is_accessible)( + self.user, direct_chat_id + ) + ): + return chat_id = event.get("_project_chat_id") if chat_id is not None: - if not self.user or not await can_access_project_chat(self.user.pk, chat_id): + if not self.user or not await can_access_project_chat(self.user, chat_id): room_name = f"{EventGroupType.CHATS_RELATED}_{int(chat_id)}" await self.channel_layer.group_discard(room_name, self.channel_name) self.joined_rooms.discard(room_name) @@ -285,7 +290,7 @@ async def __process_general_event(self, event: Event, room_name: str): elif event.type == EventType.SET_OFFLINE: cache.delete(cache_key) users_online_list = cache.get_or_set(users_online_list_key, set()) - users_online_list.remove(self.user.pk) + users_online_list.discard(self.user.pk) cache.set(users_online_list_key, users_online_list, ONE_DAY_IN_SECONDS) # sent everyone online event that user X is offline @@ -301,6 +306,5 @@ async def __process_general_event(self, event: Event, room_name: str): async def decode_json(self, text_data) -> dict: try: return json.loads(text_data) - except JSONDecodeError as error: - await self.disconnect(400) - raise error + except JSONDecodeError: + return {} diff --git a/chats/consumers/event_types/DirectEvent.py b/chats/consumers/event_types/DirectEvent.py index f234e625..9220fd92 100644 --- a/chats/consumers/event_types/DirectEvent.py +++ b/chats/consumers/event_types/DirectEvent.py @@ -1,16 +1,18 @@ from channels.db import database_sync_to_async -from chats.models import DirectChatMessage, DirectChat +from chats.models import DirectChat from chats.websockets_settings import ChatType, Event, EventType -from chats.exceptions import WrongChatIdException from django.core.cache import cache from chats.utils import ( get_user_channel_cache_key, get_chat_and_user_ids_from_content, - orm_get, - orm_save, ) -from chats.services.messages import create_direct_message, delete_message, edit_message +from chats.services.messages import ( + create_direct_message, + delete_message, + edit_message, + mark_direct_message_read, +) class DirectEvent: @@ -41,7 +43,11 @@ async def process_new_message_event(self, event: Event, room_name: str): # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - event_data = {"type": EventType.NEW_MESSAGE, "content": content} + event_data = { + "type": EventType.NEW_MESSAGE, + "content": content, + "_direct_chat_id": chat_id, + } await self.channel_layer.send(self.channel_name, event_data) @@ -49,31 +55,29 @@ async def process_new_message_event(self, event: Event, room_name: str): await self.channel_layer.send(other_user_channel, event_data) async def process_read_message_event(self, event: Event, room_name: str): - chat_id, other_user = await get_chat_and_user_ids_from_content( - event.content, self.user + _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) + chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) + message_id = await database_sync_to_async(mark_direct_message_read)( + chat_id=chat_id, + message_id=event.content["message_id"], + user=self.user, + author=other_user, ) - msg = await orm_get(DirectChatMessage.objects, pk=event.content["message_id"]) - if msg.chat_id != chat_id or msg.author_id != other_user.id: - raise WrongChatIdException( - "Some of chat/message ids are wrong, you can't access this message" - ) - msg.is_read = True - await orm_save(msg, update_fields=["is_read"]) # send 2 events to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - json_thingy = { + event_data = { "type": EventType.READ_MESSAGE, "content": { - "chat_id": event.content["chat_id"], - "chat_type": event.content["chat_type"], + "chat_id": chat_id, + "chat_type": ChatType.DIRECT, "user_id": self.user.id, - "message_id": event.content["message_id"], + "message_id": message_id, }, + "_direct_chat_id": chat_id, } - await self.channel_layer.send(self.channel_name, json_thingy) - if other_user_channel is None: - return - await self.channel_layer.send(other_user_channel, json_thingy) + await self.channel_layer.send(self.channel_name, event_data) + if other_user_channel and other_user_channel != self.channel_name: + await self.channel_layer.send(other_user_channel, event_data) async def process_delete_message_event(self, event: Event, room_name: str): _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) @@ -87,18 +91,19 @@ async def process_delete_message_event(self, event: Event, room_name: str): # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - content = { + event_data = { "type": EventType.DELETE_MESSAGE, "content": { "message_id": message_id, "chat_id": chat_id, "chat_type": ChatType.DIRECT, }, + "_direct_chat_id": chat_id, } - await self.channel_layer.send(self.channel_name, content) + await self.channel_layer.send(self.channel_name, event_data) if other_user_channel and other_user_channel != self.channel_name: - await self.channel_layer.send(other_user_channel, content) + await self.channel_layer.send(other_user_channel, event_data) async def process_edit_message_event(self, event, room_name): _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) @@ -118,7 +123,11 @@ async def process_edit_message_event(self, event, room_name): # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - event_data = {"type": EventType.EDIT_MESSAGE, "content": content} + event_data = { + "type": EventType.EDIT_MESSAGE, + "content": content, + "_direct_chat_id": chat_id, + } if other_user_channel and other_user_channel != self.channel_name: await self.channel_layer.send(other_user_channel, event_data) diff --git a/chats/models.py b/chats/models.py index b577b041..22509ffe 100644 --- a/chats/models.py +++ b/chats/models.py @@ -44,8 +44,8 @@ def save( update_fields=update_fields, ) - def get_last_message(self): - return self.messages.last() + def visible_messages(self): + return self.messages.filter(is_deleted=False).order_by("-created_at", "-pk") def get_users_str(self): """Returns string of users separated by a comma, who are in chat @@ -122,7 +122,7 @@ def get_avatar(self, user): return self.project.image_address def get_last_messages(self, message_count) -> List["BaseMessage"]: - return self.messages.order_by("-created_at")[:message_count] + return self.visible_messages()[:message_count] def __str__(self): return f"ProjectChat<{self.project.id}> - {self.project.name}" @@ -180,7 +180,7 @@ def get_chat(cls, user1, user2) -> "DirectChat": return chat def get_last_messages(self, message_count): - return self.messages.order_by("-created_at")[:message_count] + return self.visible_messages()[:message_count] def get_other_user(self, user) -> User: return self.users.exclude(pk=user.pk).first() diff --git a/chats/permissions.py b/chats/permissions.py index 83cc789a..1f621c28 100644 --- a/chats/permissions.py +++ b/chats/permissions.py @@ -16,6 +16,12 @@ def accessible_chats(user, chat_model): return user.get_project_chats() +def direct_chat_is_accessible(user, chat_id): + from chats.models import DirectChat + + return accessible_chats(user, DirectChat).filter(pk=chat_id).exists() + + def get_lookup_kwarg(request, view, *names): view_kwargs = getattr(view, "kwargs", {}) or {} parser_kwargs = (getattr(request, "parser_context", None) or {}).get("kwargs", {}) diff --git a/chats/search.py b/chats/search.py index 67bee0f8..7b910a52 100644 --- a/chats/search.py +++ b/chats/search.py @@ -48,7 +48,7 @@ def search_chat_messages(chat, filters): query = filters["q"] scope = filters["scope"] author_id = filters["author_id"] - messages = chat.messages.filter(is_deleted=False).order_by("-created_at", "-pk") + messages = chat.visible_messages() if author_id is not None: messages = messages.filter(author_id=author_id) if not query: diff --git a/chats/serializers.py b/chats/serializers.py index 79396048..b91e5d38 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -72,7 +72,13 @@ def get_image_address(self, chat: DirectChat): @classmethod def get_last_message(cls, chat: DirectChat): - return DirectChatMessageListSerializer(chat.get_last_message()).data + message = ( + chat.visible_messages() + .select_related("author", "reply_to__author") + .prefetch_related("file_to_message__file") + .first() + ) + return DirectChatMessageListSerializer(message).data class Meta: model = DirectChat @@ -113,7 +119,7 @@ def get_name(cls, chat: ProjectChat): def get_last_message(self, chat: ProjectChat): user_id = self.context["request"].user.pk - messages = chat.messages.filter(is_deleted=False).order_by("-created_at", "-pk") + messages = chat.visible_messages() message = with_project_message_reads( messages, user_id, include_reply=True ).first() diff --git a/chats/services/messages.py b/chats/services/messages.py index cf11628d..32b75661 100644 --- a/chats/services/messages.py +++ b/chats/services/messages.py @@ -150,7 +150,7 @@ def create_direct_message( file_urls=None, client_message_id=None, ): - chat = DirectChat.get_chat(author, other_user) + chat = get_direct_chat_for_participants(author, other_user) # Serialize sends in this chat before checking for a repeat or locking files. DirectChat.objects.select_for_update().get(pk=chat.pk) return _create_message( @@ -166,6 +166,33 @@ def create_direct_message( ) +@transaction.atomic +def get_direct_chat_for_participants(user, other_user): + chat = DirectChat.get_chat(user, other_user) + participants = set(chat.users.values_list("pk", flat=True)) + if participants != {user.pk, other_user.pk}: + raise MessageValidationException("Чат недоступен.") + return chat + + +def mark_direct_message_read(*, chat_id, message_id, user, author): + if type(message_id) not in (str, int): + raise MessageValidationException("Некорректный идентификатор сообщения.") + try: + messages = DirectChatMessage.objects.filter( + pk=message_id, + chat_id=chat_id, + chat__users=user, + author=author, + is_deleted=False, + ).filter(chat__users=author) + if user.pk != author.pk and messages.update(is_read=True): + return int(message_id) + except (TypeError, ValueError): + pass + raise MessageValidationException("Сообщение недоступно для прочтения.") + + @transaction.atomic def create_project_message( *, diff --git a/chats/tests/test_permissions.py b/chats/tests/test_permissions.py index 525971e1..f558b9bb 100644 --- a/chats/tests/test_permissions.py +++ b/chats/tests/test_permissions.py @@ -1,11 +1,17 @@ +from channels.db import database_sync_to_async +from channels.testing import WebsocketCommunicator from django.contrib.auth import get_user_model -from django.test import TestCase, override_settings -from rest_framework.test import APIRequestFactory, force_authenticate +from django.test import TestCase, TransactionTestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient, APIRequestFactory, force_authenticate +from rest_framework_simplejwt.tokens import AccessToken -from chats.models import ProjectChat +from chats.models import DirectChat, ProjectChat from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_project_reads import create_project_chat from chats.views import ProjectChatDetail from projects.models import Collaborator, Project +from procollab.asgi import application @override_settings(ALLOWED_HOSTS=["testserver", "dev.procollab.ru", "127.0.0.1"]) @@ -71,3 +77,109 @@ def test_project_chat_detail_is_forbidden_for_outsider(self): response = ProjectChatDetail.as_view()(request, pk=self.chat.id) self.assertEqual(response.status_code, 403) + + def test_direct_chat_preview_and_unreads_use_visible_incoming_messages(self): + chat = DirectChat.get_chat(self.leader, self.collaborator) + chat.messages.create(author=self.leader, text="Свое сообщение") + unread = chat.messages.create(author=self.collaborator, text="Новое сообщение") + chat.messages.create( + author=self.collaborator, text="Удаленное сообщение", is_deleted=True + ) + api = APIClient() + api.force_authenticate(self.leader) + listing_url = reverse("chats:direct-chat-list") + unreads_url = reverse("chats:has-chat-unreads") + + preview = api.get(listing_url, secure=True).json()[0]["last_message"] + self.assertEqual(preview["id"], unread.pk) + self.assertTrue(api.get(unreads_url, secure=True).json()["has_unreads"]) + unread.is_deleted = True + unread.save(update_fields=["is_deleted"]) + preview = api.get(listing_url, secure=True).json()[0]["last_message"] + self.assertEqual(preview["text"], "Свое сообщение") + self.assertFalse(api.get(unreads_url, secure=True).json()["has_unreads"]) + + +@override_settings(RUNNING_TESTS=False) +class ChatSocketAccessTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.leader = users.create(**TEST_USER1, is_active=True) + self.member = users.create(**TEST_USER2, is_active=True) + self.chat = create_project_chat(self.leader, self.member) + Collaborator.objects.filter(project_id=self.chat.pk, user=self.leader).delete() + + def open_chat(self, user): + return WebsocketCommunicator( + application, + "/ws/chat/", + subprotocols=["Bearer", str(AccessToken.for_user(user))], + ) + + async def receive_chat_event(self, socket): + while True: + event = await socket.receive_json_from(timeout=5) + if event.get("type") not in {"set_online", "set_offline"}: + return event + + async def send_project_message(self, socket, text): + await socket.send_json_to( + { + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": self.chat.pk, + "text": text, + }, + } + ) + + async def test_leader_receives_updates_and_removed_member_stops_receiving_them(self): + leader = self.open_chat(self.leader) + member = self.open_chat(self.member) + self.assertEqual(await leader.connect(), (True, "Bearer")) + self.assertEqual(await member.connect(), (True, "Bearer")) + try: + await self.send_project_message(member, "Первое сообщение") + self.assertEqual( + (await self.receive_chat_event(leader))["content"]["message"]["text"], + "Первое сообщение", + ) + self.assertEqual( + (await self.receive_chat_event(member))["type"], "new_message" + ) + await database_sync_to_async( + Collaborator.objects.filter( + project_id=self.chat.pk, user=self.member + ).delete + )() + await self.send_project_message(leader, "Сообщение после изменения состава") + self.assertEqual( + (await self.receive_chat_event(leader))["type"], "new_message" + ) + self.assertTrue(await member.receive_nothing()) + finally: + await member.disconnect() + await leader.disconnect() + + async def test_inactive_account_cannot_open_chat_socket(self): + await database_sync_to_async( + get_user_model().objects.filter(pk=self.member.pk).update + )(is_active=False) + socket = self.open_chat(self.member) + connected, _ = await socket.connect() + self.assertFalse(connected) + await socket.disconnect() + + async def test_connection_stays_available_after_invalid_event(self): + socket = self.open_chat(self.leader) + self.assertEqual(await socket.connect(), (True, "Bearer")) + try: + await socket.send_to(text_data="{") + self.assertIn("error", await self.receive_chat_event(socket)) + await self.send_project_message(socket, "Сообщение проекта") + self.assertEqual( + (await self.receive_chat_event(socket))["type"], "new_message" + ) + finally: + await socket.disconnect() diff --git a/chats/utils.py b/chats/utils.py index bda114ed..c31d9198 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -15,19 +15,6 @@ async def orm_get(queryset, **kwargs): return await queryset.aget(**kwargs) -async def orm_exists(queryset) -> bool: - if settings.RUNNING_TESTS: - return queryset.exists() - return await queryset.aexists() - - -async def orm_save(instance, update_fields=None): - if settings.RUNNING_TESTS: - instance.save(update_fields=update_fields) - return - await instance.asave(update_fields=update_fields) - - def get_user_channel_cache_key(user: User) -> str: return f"user_channel_{user.pk}" diff --git a/chats/views.py b/chats/views.py index cb7239c4..5c27a8c7 100644 --- a/chats/views.py +++ b/chats/views.py @@ -23,12 +23,13 @@ download_chat_attachment, get_accessible_chat, ) +from chats.exceptions import MessageValidationException from chats.message_context import ( MAX_CONTEXT_LIMIT, MessageContextForm, get_message_context, ) -from chats.models import ProjectChat, DirectChat +from chats.models import DirectChat, DirectChatMessage, ProjectChat from chats.pagination import MessageListPagination from chats.permissions import IsProjectChatMember, IsChatMember, accessible_chats from chats.project_reads import ( @@ -46,6 +47,7 @@ DirectChatDetailSerializer, ChatAttachmentSerializer, ) +from chats.services.messages import get_direct_chat_for_participants User = get_user_model() @@ -126,9 +128,8 @@ def get(self, request, *args, **kwargs) -> Response: else: opponent = user1 context = {"opponent": opponent} - data = DirectChatDetailSerializer( - DirectChat.get_chat(user1, user2), context=context - ).data + chat = get_direct_chat_for_participants(user1, user2) + data = DirectChatDetailSerializer(chat, context=context).data data["name"] = f"{opponent.first_name} {opponent.last_name}" data["image_address"] = opponent.avatar @@ -157,6 +158,8 @@ def get(self, request, *args, **kwargs) -> Response: status=status.HTTP_404_NOT_FOUND, data={"detail": "Direct chat does not exist"}, ) + except MessageValidationException as error: + return Response({"detail": str(error)}, status=status.HTTP_404_NOT_FOUND) class ChatMessageHistory(ListCreateAPIView): @@ -455,13 +458,13 @@ class HasChatUnreadsView(GenericAPIView): ) def get(self, request, *args, **kwargs): user = request.user - # get all user chats - direct_messages = user.direct_chats.all().prefetch_related("messages") - has_direct_messages_unread = ( - direct_messages.filter(messages__is_read=False) - .exclude(messages__is_deleted=True) - .distinct() + DirectChatMessage.objects.filter( + chat_id__in=user.direct_chats.values("pk"), + is_read=False, + is_deleted=False, + ) + .exclude(author=user) .exists() ) has_project_messages_unread = ( diff --git a/core/auth/middleware.py b/core/auth/middleware.py index 247baeea..6f77dc74 100644 --- a/core/auth/middleware.py +++ b/core/auth/middleware.py @@ -1,71 +1,6 @@ -import jwt from channels.db import database_sync_to_async -from django.conf import settings -from django.contrib.auth import get_user_model -from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import AuthenticationFailed - -User = get_user_model() - - -class TokenAuthentication: - """ - Simple token based authentication. - - Clients should authenticate by passing the token key in the query parameters. - For example: - - ?token=401f7ac837da42b97f613d789819ff93537bee6a - """ - - model = None - - def get_model(self): - if self.model is not None: - return self.model - from rest_framework.authtoken.models import Token - - return Token - - """ - A custom token model may be used, but must have the following properties. - - * key -- The string identifying the token - * user -- The user to which the token belongs - """ - - def authenticate_credentials(self, key): - model = self.get_model() - try: - token = model.objects.select_related("user").get(key=key) - except model.DoesNotExist: - raise AuthenticationFailed(_("Invalid token.")) - - if not token.user.is_active: - raise AuthenticationFailed(_("User inactive or deleted.")) - - return token.user - - def authenticate(self, token): - """ - Returns a `User` if a correct username and password have been supplied - Args: - token: token key - - Returns: - User: A user instance. - """ - try: - user_id = jwt.decode( - jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"] - )["user_id"] - except jwt.exceptions.DecodeError: - raise AuthenticationFailed(_("Invalid token.")) - except jwt.exceptions.ExpiredSignatureError: - raise AuthenticationFailed(_("Token expired.")) - - user = User.objects.get(pk=user_id) - return user +from rest_framework_simplejwt.authentication import JWTAuthentication @database_sync_to_async @@ -84,13 +19,11 @@ def get_user(scope): "TokenAuthMiddleware." ) token = scope["token"] - user = None try: - auth = TokenAuthentication() - user = auth.authenticate(token) + auth = JWTAuthentication() + return auth.get_user(auth.get_validated_token(token)) except AuthenticationFailed: - pass - return user or AnonymousUser() + return AnonymousUser() class TokenAuthMiddleware: From c235e367631b92583dab4b8867ff3f77a6b4776a Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:05:33 +0300 Subject: [PATCH 08/16] Add program channels to the chat backend --- chats/admin.py | 185 +++++++++++- chats/apps.py | 3 + chats/attachments.py | 46 +-- chats/channel_events.py | 25 ++ chats/channel_views.py | 213 +++++++++++++ chats/consumers/chat.py | 42 ++- chats/migrations/0015_program_channels.py | 171 +++++++++++ chats/models.py | 73 ++++- chats/permissions.py | 20 +- chats/program_channels.py | 86 ++++++ chats/search.py | 13 +- chats/serializers.py | 31 +- chats/services/messages.py | 42 ++- chats/signals.py | 11 + chats/tests/test_program_channels.py | 282 ++++++++++++++++++ chats/urls.py | 56 +++- chats/views.py | 25 +- chats/websockets_settings.py | 3 + docs/modules/chats.md | 32 ++ files/service.py | 24 +- files/views.py | 32 +- partner_programs/admin.py | 15 + templates/admin/chats/_message.html | 8 + templates/admin/chats/channel_publish.html | 15 + templates/admin/chats/message_search.html | 3 + .../admin/program_manager_change_form.html | 3 + .../admin/programs_change_form.html | 3 + 27 files changed, 1393 insertions(+), 69 deletions(-) create mode 100644 chats/channel_events.py create mode 100644 chats/channel_views.py create mode 100644 chats/migrations/0015_program_channels.py create mode 100644 chats/program_channels.py create mode 100644 chats/signals.py create mode 100644 chats/tests/test_program_channels.py create mode 100644 templates/admin/chats/channel_publish.html diff --git a/chats/admin.py b/chats/admin.py index bab1f592..077b539d 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -1,5 +1,7 @@ from functools import partial +from uuid import uuid4 +import requests from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django import forms @@ -8,6 +10,7 @@ from django.core.cache import cache from django.core.paginator import Paginator from django.db import transaction +from django.http import Http404 from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from django.urls import path, reverse @@ -23,13 +26,18 @@ ProjectChatMessage, DirectChatMessage, FileToMessage, + ProgramChannel, ) -from chats.permissions import accessible_chats +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.utils import get_user_channel_cache_key from chats.websockets_settings import ChatType, EventGroupType, EventType +from files.models import UserFile +from files.exceptions import SelectelUploadError +from files.service import CDN, SelectelSwiftStorage, create_user_upload @admin.display(description="Пользователи чата") @@ -51,7 +59,7 @@ def message_search_url_name(self): def get_urls(self): object_path = ( - "" if self.model is ProjectChat else "" + "" if self.model is DirectChat else "" ) return [ path( @@ -110,6 +118,7 @@ def message_search(self, request, object_id): "form": form, "page": page, "chat": chat, + **self.extra_channel_context(request, chat), "message_context_route": "admin:" + self.message_search_url_name + "_context", @@ -128,6 +137,9 @@ def get_chat_for_member(self, request, object_id): raise PermissionDenied return chat + def extra_channel_context(self, request, chat): + return {} + def attachment_download(self, request, object_id, message_id, attachment_id): chat = self.get_chat_for_member(request, object_id) return download_chat_attachment(request, chat, message_id, attachment_id) @@ -162,10 +174,11 @@ def change_own_message(self, request, object_id, message_id): **change, text=form.cleaned_data["text"] ) } - transaction.on_commit( - partial(self.publish_change, chat, event_type, content), - robust=True, - ) + if not isinstance(chat, ProgramChannel): + transaction.on_commit( + partial(self.publish_change, chat, event_type, content), + robust=True, + ) except MessageValidationException as error: form.add_error(None, str(error)) else: @@ -183,6 +196,7 @@ def change_own_message(self, request, object_id, message_id): "opts": self.model._meta, "title": "Изменение сообщения", "chat": chat, + **self.extra_channel_context(request, chat), "message": message, "form": form, "message_context_route": search_route + "_context", @@ -237,6 +251,7 @@ def message_context(self, request, object_id, message_id): "opts": self.model._meta, "title": "Сообщение в переписке", "chat": chat, + **self.extra_channel_context(request, chat), "context_form": form, "context": context, "anchor_id": ( @@ -267,6 +282,164 @@ class OwnMessageForm(forms.Form): ) +class ChannelPublicationForm(forms.Form): + text = forms.CharField( + label="Текст публикации", required=False, strip=False, widget=forms.Textarea + ) + files = forms.ModelMultipleChoiceField( + label="Мои загруженные файлы", queryset=UserFile.objects.none(), required=False + ) + client_message_id = forms.UUIDField( + widget=forms.HiddenInput, + error_messages={"invalid": "Некорректный номер публикации."}, + ) + + def __init__(self, user, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["files"].queryset = UserFile.objects.filter(user=user).order_by( + "-datetime_uploaded" + ) + + +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() + + 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( + "/publish/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.publish_message) + ), + name=self.message_search_url_name + "_publish", + ), + 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): + 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) + else None + ), + } + + def change_own_message(self, request, object_id, message_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) + + 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): + raise PermissionDenied("Публикации доступны менеджерам программы.") + posted = request.method == "POST" + uploading = posted and request.POST.get("action") == "upload" + form = ChannelPublicationForm( + request.user, + request.POST if posted and not uploading else None, + initial={"client_message_id": uuid4()}, + ) + upload_form = ChannelUploadForm( + request.POST if uploading else None, request.FILES if uploading else None + ) + if uploading and upload_form.is_valid(): + try: + upload = create_user_upload( + self.cdn, + upload_form.cleaned_data["file"], + request.user, + preserve_original=True, + ) + except (requests.RequestException, SelectelUploadError, KeyError): + upload_form.add_error( + "file", "Не удалось загрузить файл. Повторите попытку." + ) + else: + form.initial["files"] = [upload.pk] + 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"], + ) + except MessageValidationException as error: + form.add_error(None, str(error)) + else: + self.message_user(request, "Публикация сохранена.") + return redirect( + "admin:" + self.message_search_url_name + "_context", + chat.pk, + publication["id"], + ) + return TemplateResponse( + request, + "admin/chats/channel_publish.html", + { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": "Новая публикация в канале", + "form": form, + "upload_form": upload_form, + "back_url": reverse( + "admin:" + self.message_search_url_name, args=[chat.pk] + ), + }, + status=400 if form.errors or upload_form.errors else 200, + ) + + def read_message(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + try: + mark_channel_read( + channel_id=chat.pk, user=request.user, message_id=message_id + ) + except MessageValidationException as error: + raise Http404(str(error)) from error + self.message_user(request, "Публикации отмечены прочитанными.") + return redirect( + "admin:" + self.message_search_url_name + "_context", chat.pk, message_id + ) + + @admin.register(ProjectChat) class ChatAdmin(ChatSearchAdmin): list_display = ( diff --git a/chats/apps.py b/chats/apps.py index 420b4fbf..38052c2c 100644 --- a/chats/apps.py +++ b/chats/apps.py @@ -5,3 +5,6 @@ class ChatsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "chats" verbose_name = "Чаты" + + def ready(self): + from chats import signals # noqa: F401 diff --git a/chats/attachments.py b/chats/attachments.py index a20a1838..e4dde1c5 100644 --- a/chats/attachments.py +++ b/chats/attachments.py @@ -1,7 +1,7 @@ from django.http import Http404 from django.urls import reverse -from chats.models import FileToMessage, ProjectChat +from chats.models import DirectChat, FileToMessage, ProgramChannel, ProjectChat from chats.permissions import accessible_chats from files.downloads import download_stored_file @@ -14,31 +14,43 @@ def get_accessible_chat(user, model, chat_id): def chat_attachments(chat): - if isinstance(chat, ProjectChat): - return FileToMessage.objects.filter( - project_message__chat=chat, - project_message__is_deleted=False, - direct_message__isnull=True, - ) - return FileToMessage.objects.filter( - direct_message__chat=chat, - direct_message__is_deleted=False, - project_message__isnull=True, - ) + message_field = chat_attachment_field(chat) + filters = { + field + "__isnull": True + for field in ATTACHMENT_FIELDS.values() + if field != message_field + } + filters[message_field + "__chat"] = chat + filters[message_field + "__is_deleted"] = False + return FileToMessage.objects.filter(**filters) + + +ATTACHMENT_FIELDS = { + DirectChat: "direct_message", + ProjectChat: "project_message", + ProgramChannel: "channel_message", +} + + +def chat_attachment_field(chat): + return ATTACHMENT_FIELDS[type(chat)] def attachment_download_url(attachment, message): - kind = "project" if attachment.project_message_id is not None else "direct" + if attachment.channel_message_id is not None: + kind = "program-channel" + elif attachment.project_message_id is not None: + kind = "project-chat" + else: + kind = "direct-chat" return reverse( - "chats:" + kind + "-chat-attachment-download", + "chats:" + kind + "-attachment-download", args=[message.chat_id, message.pk, attachment.download_id], ) def download_chat_attachment(request, chat, message_id, attachment_id): - message_field = ( - "project_message_id" if isinstance(chat, ProjectChat) else "direct_message_id" - ) + message_field = chat_attachment_field(chat) + "_id" attachments = chat_attachments(chat).filter(**{message_field: message_id}) for attachment in attachments.select_related("file"): if attachment.download_id == attachment_id: diff --git a/chats/channel_events.py b/chats/channel_events.py new file mode 100644 index 00000000..d584140e --- /dev/null +++ b/chats/channel_events.py @@ -0,0 +1,25 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.db import transaction + +from chats.websockets_settings import EventGroupType, EventType + + +def channel_group(channel_id): + return f"{EventGroupType.PROGRAM_CHANNEL}_{channel_id}" + + +def notify_channel_change(channel_id): + layer = get_channel_layer() + if layer is None: + return + transaction.on_commit( + lambda: async_to_sync(layer.group_send)( + channel_group(channel_id), + { + "type": EventType.PROGRAM_CHANNEL_CHANGED, + "content": {"channel_id": channel_id}, + }, + ), + robust=True, + ) diff --git a/chats/channel_views.py b/chats/channel_views.py new file mode 100644 index 00000000..a75e0b7a --- /dev/null +++ b/chats/channel_views.py @@ -0,0 +1,213 @@ +from django.db.models import Exists, OuterRef, Prefetch +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from drf_yasg.utils import swagger_auto_schema +from rest_framework import serializers, status +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import GenericAPIView, ListAPIView, RetrieveAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from chats.attachments import get_accessible_chat +from chats.exceptions import MessageValidationException +from chats.models import ProgramChannel, ProgramChannelMessage +from chats.pagination import MessageListPagination +from chats.permissions import accessible_chats, can_publish_in_program_channel +from chats.program_channels import ( + MAX_CHANNEL_ATTACHMENTS, + mark_channel_read, + publish_channel_message, + with_channel_unreads, +) +from chats.serializers import ProgramChannelMessageSerializer +from chats.services.messages import delete_message, edit_message +from chats.views import ChatMessageHistory +from partner_programs.models import PartnerProgram + + +class ProgramChannelSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(source="pk") + name = serializers.CharField(source="program.name") + image_address = serializers.CharField(source="program.image_address", allow_null=True) + unread_count = serializers.IntegerField() + first_unread_message_id = serializers.IntegerField(allow_null=True) + can_publish = serializers.BooleanField(read_only=True) + last_message = serializers.SerializerMethodField() + + def get_last_message(self, channel): + if channel.latest_messages: + return ProgramChannelMessageSerializer( + channel.latest_messages[0], context=self.context + ).data + return None + + class Meta: + model = ProgramChannel + fields = ( + "id", + "name", + "image_address", + "can_publish", + "unread_count", + "first_unread_message_id", + "last_message", + ) + + +class ChannelMessageInput(serializers.Serializer): + text = serializers.CharField( + required=False, default="", allow_blank=True, trim_whitespace=False + ) + file_urls = serializers.ListField( + child=serializers.CharField(max_length=200), + required=False, + default=list, + max_length=MAX_CHANNEL_ATTACHMENTS, + ) + client_message_id = serializers.UUIDField( + required=False, allow_null=True, default=None + ) + + +class ChannelEditInput(serializers.Serializer): + text = serializers.CharField(allow_blank=True, trim_whitespace=False) + + +class ChannelReadInput(serializers.Serializer): + message_id = serializers.IntegerField(min_value=1, max_value=2**63 - 1) + + +class ProgramChannelSummary: + permission_classes = [IsAuthenticated] + serializer_class = ProgramChannelSerializer + pagination_class = MessageListPagination + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return ProgramChannel.objects.none() + user = self.request.user + managed = PartnerProgram.objects.filter(pk=OuterRef("pk"), managers=user) + channels = accessible_chats(user, ProgramChannel).select_related("program") + recent = ( + ProgramChannelMessage.objects.filter(is_deleted=False) + .select_related("author") + .prefetch_related("file_to_message__file") + .order_by("-created_at", "-pk")[:1] + ) + return ( + with_channel_unreads(channels, user.pk) + .annotate(can_publish=Exists(managed)) + .prefetch_related( + Prefetch("messages", queryset=recent, to_attr="latest_messages") + ) + .order_by("program__name", "pk") + ) + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelList(ProgramChannelSummary, ListAPIView): + pass + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelDetail(ProgramChannelSummary, RetrieveAPIView): + lookup_url_kwarg = "id" + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelHistory(ChatMessageHistory): + chat_model = ProgramChannel + permission_classes = [IsAuthenticated] + serializer_class = ProgramChannelMessageSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .select_related("author") + .prefetch_related("file_to_message__file") + ) + + @swagger_auto_schema( + request_body=ChannelMessageInput, + responses={ + 200: ProgramChannelMessageSerializer, + 201: ProgramChannelMessageSerializer, + }, + ) + def post(self, request, id): + require_publisher(request.user, id) + form = ChannelMessageInput(data=request.data) + form.is_valid(raise_exception=True) + try: + publication, created = publish_channel_message( + channel_id=id, author=request.user, **form.validated_data + ) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response( + publication, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + +def require_publisher(user, channel_id): + channel = get_accessible_chat(user, ProgramChannel, channel_id) + if not can_publish_in_program_channel(user, channel.program): + raise PermissionDenied("Публикации доступны менеджерам программы.") + return channel + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelMessageChange(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ChannelEditInput + + def get_change(self, request, channel_id, message_id): + channel = require_publisher(request.user, channel_id) + get_object_or_404(channel.visible_messages(), pk=message_id, author=request.user) + return dict( + chat_model=ProgramChannel, + chat_id=channel_id, + message_id=message_id, + author=request.user, + ) + + @swagger_auto_schema( + request_body=ChannelEditInput, responses={200: ProgramChannelMessageSerializer} + ) + def patch(self, request, id, message_id): + change = self.get_change(request, id, message_id) + form = self.get_serializer(data=request.data) + form.is_valid(raise_exception=True) + try: + publication = edit_message(**change, **form.validated_data) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response(publication) + + def delete(self, request, id, message_id): + change = self.get_change(request, id, message_id) + try: + delete_message(**change) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response(status=status.HTTP_204_NO_CONTENT) + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelReadView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ChannelReadInput + + def post(self, request, id): + get_accessible_chat(request.user, ProgramChannel, id) + form = self.get_serializer(data=request.data) + form.is_valid(raise_exception=True) + try: + last_message_id = mark_channel_read( + channel_id=id, user=request.user, **form.validated_data + ) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response({"last_message_id": last_message_id}) diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index bd1b5bb9..1df01e78 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -9,6 +9,7 @@ from django.core.cache import cache from django.utils import timezone +from chats.channel_events import channel_group from chats.exceptions import ChatException from chats.permissions import accessible_chats, direct_chat_is_accessible from chats.project_reads import personalize_project_message_event @@ -25,7 +26,7 @@ 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 ProjectChat +from chats.models import ProgramChannel, ProjectChat @database_sync_to_async @@ -38,6 +39,16 @@ def can_access_project_chat(user, chat_id): return accessible_chats(user, ProjectChat).filter(pk=chat_id).exists() +@database_sync_to_async +def get_user_channel_ids(user): + return list(accessible_chats(user, ProgramChannel).values_list("pk", flat=True)) + + +@database_sync_to_async +def can_access_program_channel(user, channel_id): + return accessible_chats(user, ProgramChannel).filter(pk=channel_id).exists() + + class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -62,6 +73,8 @@ async def connect(self): await self._ensure_room_subscription( f"{EventGroupType.CHATS_RELATED}_{project_id}" ) + for channel_id in await get_user_channel_ids(self.user): + await self._ensure_room_subscription(channel_group(channel_id)) # set user online user_cache_key = get_user_online_cache_key(self.user) @@ -169,6 +182,8 @@ async def receive_json(self, content, **kwargs): except KeyError as e: await self.send_json({"error": f"В запросе отсутствует поле {e}."}) + elif event.type == EventType.SUBSCRIBE_PROGRAM_CHANNEL: + await self.subscribe_program_channel(event.content) elif event.type in [EventType.SET_ONLINE, EventType.SET_OFFLINE]: room_name = EventGroupType.GENERAL_EVENTS await self.__process_general_event(event, room_name) @@ -274,6 +289,31 @@ async def set_offline(self, event: Event): async def edit_message(self, event: Event): await self._send_chat_event(event) + async def subscribe_program_channel(self, content): + channel_id = content.get("channel_id") if isinstance(content, dict) else None + if type(channel_id) is not int or not 0 < channel_id < 2**63: + await self.send_json({"error": "Некорректный идентификатор канала."}) + return + if not await can_access_program_channel(self.user, channel_id): + await self.send_json({"error": "Канал недоступен."}) + return + await self._ensure_room_subscription(channel_group(channel_id)) + await self.send_json( + { + "type": EventType.SUBSCRIBE_PROGRAM_CHANNEL, + "content": {"channel_id": channel_id}, + } + ) + + 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) + else: + room_name = channel_group(channel_id) + await self.channel_layer.group_discard(room_name, self.channel_name) + self.joined_rooms.discard(room_name) + async def __process_general_event(self, event: Event, room_name: str): cache_key = get_user_online_cache_key(self.user) users_online_list_key = get_users_online_cache_key() diff --git a/chats/migrations/0015_program_channels.py b/chats/migrations/0015_program_channels.py new file mode 100644 index 00000000..1b75b939 --- /dev/null +++ b/chats/migrations/0015_program_channels.py @@ -0,0 +1,171 @@ +# Generated by Django 5.2.18 on 2026-11-13 18:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def create_existing_channels(apps, schema_editor): + program = apps.get_model("partner_programs", "PartnerProgram") + channel = apps.get_model("chats", "ProgramChannel") + database = schema_editor.connection.alias + ids = ( + program.objects.using(database) + .values_list("pk", flat=True) + .iterator(chunk_size=1000) + ) + batch = [] + for program_id in ids: + batch.append(channel(program_id=program_id)) + if len(batch) == 1000: + channel.objects.using(database).bulk_create(batch) + batch.clear() + if batch: + channel.objects.using(database).bulk_create(batch) + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0014_chat_message_sync"), + ("partner_programs", "0044_organizer_expert_identities"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ProgramChannel", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "message_revision", + models.PositiveBigIntegerField( + db_default=0, default=0, editable=False + ), + ), + ( + "program", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="partner_programs.partnerprogram", + verbose_name="Программа", + ), + ), + ], + options={ + "verbose_name": "Канал программы", + "verbose_name_plural": "Каналы программ", + }, + ), + migrations.CreateModel( + name="ProgramChannelMessage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField(max_length=8192)), + ("is_read", models.BooleanField(default=False)), + ("is_deleted", models.BooleanField(default=False)), + ("is_edited", models.BooleanField(default=False)), + ( + "client_message_id", + models.UUIDField(blank=True, editable=False, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "sync_revision", + models.PositiveBigIntegerField(editable=False, null=True), + ), + ( + "author", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="program_channel_posts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chats.programchannel", + ), + ), + ], + options={ + "verbose_name": "Публикация канала", + "verbose_name_plural": "Публикации каналов", + }, + ), + migrations.AddField( + model_name="filetomessage", + name="channel_message", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="file_to_message", + to="chats.programchannelmessage", + ), + ), + migrations.CreateModel( + name="ProgramChannelRead", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("last_message_id", models.PositiveBigIntegerField()), + ( + "channel", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="chats.programchannel", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddIndex( + model_name="programchannelmessage", + index=models.Index( + fields=["chat", "sync_revision"], name="program_channel_sync_idx" + ), + ), + migrations.AddConstraint( + model_name="programchannelmessage", + constraint=models.UniqueConstraint( + condition=models.Q(("client_message_id__isnull", False)), + fields=("chat", "author", "client_message_id"), + name="unique_program_channel_client_message", + ), + ), + migrations.AddConstraint( + model_name="programchannelread", + constraint=models.UniqueConstraint( + fields=("channel", "user"), name="unique_program_channel_reader" + ), + ), + migrations.RunPython(create_existing_channels, migrations.RunPython.noop), + ] diff --git a/chats/models.py b/chats/models.py index 22509ffe..0459dd6a 100644 --- a/chats/models.py +++ b/chats/models.py @@ -373,6 +373,60 @@ class Meta: ] +class ProgramChannel(BaseChat): + program = models.OneToOneField( + "partner_programs.PartnerProgram", + on_delete=models.CASCADE, + primary_key=True, + verbose_name="Программа", + ) + + def __str__(self): + return f"Канал программы №{self.pk}" + + class Meta: + verbose_name = "Канал программы" + verbose_name_plural = "Каналы программ" + + +class ProgramChannelMessage(BaseMessage): + chat = models.ForeignKey( + ProgramChannel, on_delete=models.CASCADE, related_name="messages" + ) + author = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="program_channel_posts" + ) + + class Meta: + verbose_name = "Публикация канала" + verbose_name_plural = "Публикации каналов" + indexes = [ + models.Index( + fields=["chat", "sync_revision"], name="program_channel_sync_idx" + ) + ] + constraints = [ + models.UniqueConstraint( + fields=["chat", "author", "client_message_id"], + condition=models.Q(client_message_id__isnull=False), + name="unique_program_channel_client_message", + ) + ] + + +class ProgramChannelRead(models.Model): + channel = models.ForeignKey(ProgramChannel, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + last_message_id = models.PositiveBigIntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["channel", "user"], name="unique_program_channel_reader" + ) + ] + + class FileToMessage(models.Model): file = models.OneToOneField( UserFile, @@ -393,6 +447,13 @@ class FileToMessage(models.Model): related_name="file_to_message", null=True, ) + channel_message = models.ForeignKey( + ProgramChannelMessage, + on_delete=models.CASCADE, + related_name="file_to_message", + null=True, + blank=True, + ) @property def download_id(self): @@ -400,7 +461,17 @@ def download_id(self): @property def is_linked_to_one_message(self): - return (self.direct_message_id is None) != (self.project_message_id is None) + return ( + sum( + message_id is not None + for message_id in ( + self.direct_message_id, + self.project_message_id, + self.channel_message_id, + ) + ) + == 1 + ) def __str__(self): return f"FileToMessage<{self.file}>" diff --git a/chats/permissions.py b/chats/permissions.py index 1f621c28..4c5537bf 100644 --- a/chats/permissions.py +++ b/chats/permissions.py @@ -1,21 +1,39 @@ from django.contrib.auth import get_user_model +from django.db.models import Q from rest_framework.permissions import BasePermission +from partner_programs.models import PartnerProgram +from partner_programs.permissions import active_programs from projects.models import Project User = get_user_model() def accessible_chats(user, chat_model): - from chats.models import DirectChat + from chats.models import DirectChat, ProgramChannel if not user.is_authenticated: return chat_model.objects.none() if chat_model is DirectChat: return user.direct_chats.all() + if chat_model is ProgramChannel: + programs = active_programs(PartnerProgram.objects.filter(draft=False)) + programs = programs.filter( + Q(managers=user) | Q(partner_program_profiles__user=user) + ) + return ProgramChannel.objects.filter(pk__in=programs.values("pk")) return user.get_project_chats() +def can_publish_in_program_channel(user, program): + return ( + user.is_authenticated + and active_programs( + PartnerProgram.objects.filter(pk=program.pk, draft=False, managers=user) + ).exists() + ) + + def direct_chat_is_accessible(user, chat_id): from chats.models import DirectChat diff --git a/chats/program_channels.py b/chats/program_channels.py new file mode 100644 index 00000000..8c2083ec --- /dev/null +++ b/chats/program_channels.py @@ -0,0 +1,86 @@ +from django.db import transaction +from django.db.models import BigIntegerField, Count, OuterRef, Subquery, Value +from django.db.models.functions import Coalesce + +from chats.channel_events import notify_channel_change +from chats.exceptions import MessageValidationException +from chats.models import ProgramChannel, ProgramChannelMessage, ProgramChannelRead +from chats.permissions import accessible_chats, can_publish_in_program_channel +from chats.serializers import ProgramChannelMessageSerializer +from chats.services.messages import _create_message +from files.downloads import is_storage_url + +MAX_CHANNEL_ATTACHMENTS = 20 + + +def channel_unread_messages(user_id): + read = ProgramChannelRead.objects.filter( + channel_id=OuterRef("chat_id"), user_id=user_id + ).values("last_message_id") + return ( + ProgramChannelMessage.objects.filter( + is_deleted=False, + pk__gt=Coalesce(Subquery(read), Value(0), output_field=BigIntegerField()), + ) + .exclude(author_id=user_id) + .order_by() + ) + + +def with_channel_unreads(channels, user_id): + unread = channel_unread_messages(user_id).filter(chat_id=OuterRef("pk")) + totals = unread.values("chat_id").annotate(total=Count("pk")).values("total") + return channels.annotate( + unread_count=Coalesce(Subquery(totals), Value(0)), + first_unread_message_id=Subquery(unread.order_by("pk").values("pk")[:1]), + ) + + +def _locked_channel(user, channel_id): + return ( + ProgramChannel.objects.select_related("program") + .select_for_update(of=("self",)) + .filter(pk=channel_id, pk__in=accessible_chats(user, ProgramChannel).values("pk")) + .first() + ) + + +@transaction.atomic +def publish_channel_message(*, channel_id, author, text, file_urls, client_message_id): + channel = _locked_channel(author, channel_id) + if channel is None or not can_publish_in_program_channel(author, channel.program): + raise MessageValidationException("Публикации доступны менеджерам программы.") + if len(file_urls) > MAX_CHANNEL_ATTACHMENTS: + raise MessageValidationException("К публикации можно прикрепить до 20 файлов.") + if not all(is_storage_url(url) for url in file_urls): + raise MessageValidationException( + "Один или несколько файлов недоступны для отправки." + ) + publication, created = _create_message( + message_model=ProgramChannelMessage, + serializer_class=ProgramChannelMessageSerializer, + message_link_field="channel_message", + chat_id=channel.pk, + author=author, + text=text, + reply_to_id=None, + file_urls=file_urls, + client_message_id=client_message_id, + ) + if created: + notify_channel_change(channel.pk) + return publication, created + + +@transaction.atomic +def mark_channel_read(*, channel_id, user, message_id): + channel = _locked_channel(user, channel_id) + if channel is None or not channel.visible_messages().filter(pk=message_id).exists(): + raise MessageValidationException("Публикация недоступна.") + receipt, created = ProgramChannelRead.objects.get_or_create( + channel=channel, user=user, defaults={"last_message_id": message_id} + ) + if not created and receipt.last_message_id < message_id: + receipt.last_message_id = message_id + receipt.save(update_fields=["last_message_id"]) + return receipt.last_message_id diff --git a/chats/search.py b/chats/search.py index 7b910a52..42d6ff9e 100644 --- a/chats/search.py +++ b/chats/search.py @@ -2,7 +2,7 @@ from django.db.models import Case, Exists, F, OuterRef, Q, TextField, Value, When from django.db.models.functions import Concat -from chats.models import ProjectChat +from chats.attachments import ATTACHMENT_FIELDS, chat_attachment_field from core.search import unicode_literal_pattern from files.models import UserFile @@ -72,6 +72,11 @@ def search_chat_messages(chat, filters): def _message_files(chat): - if isinstance(chat, ProjectChat): - return UserFile.objects.filter(file_to_message__project_message_id=OuterRef("pk")) - return UserFile.objects.filter(file_to_message__direct_message_id=OuterRef("pk")) + message_field = chat_attachment_field(chat) + filters = { + "file_to_message__" + field + "__isnull": True + for field in ATTACHMENT_FIELDS.values() + if field != message_field + } + filters["file_to_message__" + message_field + "_id"] = OuterRef("pk") + return UserFile.objects.filter(**filters) diff --git a/chats/serializers.py b/chats/serializers.py index b91e5d38..eda5aa64 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -7,6 +7,7 @@ ProjectChat, DirectChatMessage, ProjectChatMessage, + ProgramChannelMessage, ) from chats.project_reads import project_message_is_read, with_project_message_reads from files.serializers import UserFileSerializer @@ -27,10 +28,11 @@ class ChatAttachmentSerializer(serializers.ModelSerializer): link = serializers.SerializerMethodField() def get_link(self, attachment) -> str: - message = self.context.get("message") or ( - attachment.project_message - if attachment.project_message_id is not None - else attachment.direct_message + message = ( + self.context.get("message") + or attachment.channel_message + or attachment.project_message + or attachment.direct_message ) return attachment_download_url(attachment, message) @@ -85,6 +87,27 @@ class Meta: fields = ["id", "opponent", "last_message", "name", "image_address"] +class ProgramChannelMessageSerializer(serializers.ModelSerializer): + author = UserChatSerializer(allow_null=True) + files = serializers.SerializerMethodField() + + @staticmethod + def get_files(message): + return serialize_message_attachments(message) + + class Meta: + model = ProgramChannelMessage + fields = [ + "id", + "author", + "text", + "client_message_id", + "files", + "is_edited", + "created_at", + ] + + class DirectChatDetailSerializer(serializers.ModelSerializer): opponent = serializers.SerializerMethodField() diff --git a/chats/services/messages.py b/chats/services/messages.py index 32b75661..4ebcd4a3 100644 --- a/chats/services/messages.py +++ b/chats/services/messages.py @@ -2,17 +2,25 @@ from django.db import transaction +from chats.channel_events import notify_channel_change from chats.exceptions import MessageValidationException from chats.models import ( DirectChat, DirectChatMessage, FileToMessage, + ProgramChannel, + ProgramChannelMessage, ProjectChat, ProjectChatMessage, ) -from chats.permissions import accessible_chats, is_project_member +from chats.permissions import ( + accessible_chats, + can_publish_in_program_channel, + is_project_member, +) from chats.serializers import ( DirectChatMessageListSerializer, + ProgramChannelMessageSerializer, ProjectChatMessageListSerializer, ) from files.models import UserFile @@ -86,11 +94,12 @@ def _get_files(file_urls, author): def _serialize_message(message_model, serializer_class, message_id): - message = ( - message_model.objects.select_related("author", "reply_to__author") - .prefetch_related("file_to_message__file") - .get(pk=message_id) + messages = message_model.objects.select_related("author").prefetch_related( + "file_to_message__file" ) + if message_model is not ProgramChannelMessage: + messages = messages.select_related("reply_to__author") + message = messages.get(pk=message_id) return serializer_class(message).data @@ -126,14 +135,16 @@ def _create_message( ) file_urls = _normalize_message(text, file_urls) - reply_to = _get_reply(message_model, chat_id, reply_to_id) + reply = {} + if message_model is not ProgramChannelMessage: + reply["reply_to"] = _get_reply(message_model, chat_id, reply_to_id) files = _get_files(file_urls, author) message = message_model.objects.create( chat_id=chat_id, author=author, text=text, - reply_to=reply_to, client_message_id=client_message_id, + **reply, ) for user_file in files: FileToMessage.objects.create(file=user_file, **{message_link_field: message}) @@ -236,6 +247,9 @@ def _get_message_for_change(chat_model, chat_id, message_id, author): ) if chat is None: raise MessageValidationException("Чат недоступен.") + if chat_model is ProgramChannel: + if not can_publish_in_program_channel(author, chat.program): + raise MessageValidationException("Публикации доступны менеджерам программы.") try: message = chat.messages.filter(pk=message_id, author=author).first() except (TypeError, ValueError): @@ -262,11 +276,13 @@ def edit_message(*, chat_model, chat_id, message_id, author, text): message.text = text message.is_edited = True message.save(update_fields=["text", "is_edited"]) - serializer_class = ( - DirectChatMessageListSerializer - if chat_model is DirectChat - else ProjectChatMessageListSerializer - ) + if chat_model is ProgramChannel: + notify_channel_change(chat_id) + serializer_class = { + DirectChat: DirectChatMessageListSerializer, + ProjectChat: ProjectChatMessageListSerializer, + ProgramChannel: ProgramChannelMessageSerializer, + }[chat_model] return _serialize_message(type(message), serializer_class, message.pk) @@ -276,4 +292,6 @@ def delete_message(*, chat_model, chat_id, message_id, author): if not message.is_deleted: message.is_deleted = True message.save(update_fields=["is_deleted"]) + if chat_model is ProgramChannel: + notify_channel_change(chat_id) return message.pk diff --git a/chats/signals.py b/chats/signals.py new file mode 100644 index 00000000..2dd1926e --- /dev/null +++ b/chats/signals.py @@ -0,0 +1,11 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from chats.models import ProgramChannel +from partner_programs.models import PartnerProgram + + +@receiver(post_save, sender=PartnerProgram) +def create_program_channel(sender, instance, created, raw, using, **kwargs): + if created and not raw: + ProgramChannel.objects.using(using).get_or_create(program=instance) diff --git a/chats/tests/test_program_channels.py b/chats/tests/test_program_channels.py new file mode 100644 index 00000000..27c822b2 --- /dev/null +++ b/chats/tests/test_program_channels.py @@ -0,0 +1,282 @@ +from unittest.mock import patch +from uuid import uuid4 + +from channels.db import database_sync_to_async +from channels.testing import WebsocketCommunicator +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, TransactionTestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from chats.models import ProgramChannel +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 +from files.typings import FileInfo +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from procollab.asgi import application + + +def create_program(manager, member): + now = timezone.now() + program = PartnerProgram.objects.create( + name="Программа", + draft=False, + datetime_started=now, + datetime_registration_ends=now, + datetime_finished=now, + ) + program.managers.add(manager) + profile = PartnerProgramUserProfile.objects.create( + partner_program=program, user=member, partner_program_data={} + ) + return program, profile + + +@override_settings(ALLOWED_HOSTS=["testserver"], SELECTEL_SWIFT_URL=STORAGE) +class ProgramChannelTests(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_staff=True, is_active=True) + cls.program, cls.profile = create_program(cls.manager, cls.member) + cls.channel = ProgramChannel.objects.get(pk=cls.program.pk) + cls.file = UserFile.objects.create( + link=STORAGE + "program.pdf", + user=cls.manager, + name="План программы", + extension="pdf", + ) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.manager) + self.history = self.url("messages") + + def url(self, suffix, *extra): + return reverse("chats:program-channel-" + suffix, args=[self.channel.pk, *extra]) + + def publish(self, text="Анонс", **payload): + return self.api.post( + self.history, {"text": text, **payload}, format="json", secure=True + ) + + def list_channels(self): + url = reverse("chats:program-channel-list") + return self.api.get(url, secure=True).json()["results"] + + def test_permissions_and_protected_attachment_follow_current_membership(self): + self.api.force_authenticate(self.member) + self.assertEqual(self.publish().status_code, 403) + self.assertFalse(self.list_channels()[0]["can_publish"]) + self.api.force_authenticate(self.manager) + self.assertTrue(self.list_channels()[0]["can_publish"]) + publication = self.publish(file_urls=[self.file.pk]).json() + link = publication["files"][0]["link"] + self.assertNotIn(STORAGE, link) + + self.api.force_authenticate(self.member) + summary = self.api.get(self.url("detail"), secure=True).json() + self.assertEqual(summary["last_message"]["id"], publication["id"]) + result = self.api.get( + self.history, {"q": "план программы", "scope": "files"}, secure=True + ).json() + self.assertEqual([item["id"] for item in result["results"]], [publication["id"]]) + context = self.api.get( + self.url("message-context", publication["id"]), secure=True + ).json() + self.assertEqual(context["results"][0]["id"], publication["id"]) + self.assertEqual( + self.api.get(self.url("files"), secure=True).json()[0]["link"], link + ) + with ( + patch( + "files.downloads.SelectelSwiftStorage._get_auth_token", + return_value="test", + ), + patch( + "files.downloads.requests.get", return_value=upstream_file() + ) as storage, + ): + response = self.api.get(link, secure=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(b"".join(response.streaming_content), b"abcdef") + self.profile.delete() + self.assertEqual(self.api.get(self.history, secure=True).status_code, 404) + self.assertEqual(self.api.get(link, secure=True).status_code, 404) + self.assertEqual(self.list_channels(), []) + storage.assert_called_once() + self.api.force_authenticate(self.outsider) + self.assertEqual(self.publish().status_code, 404) + + def test_read_cursor_is_personal_and_publication_retries_keep_same_id(self): + key = str(uuid4()) + first = self.publish(client_message_id=key, file_urls=[self.file.pk]) + self.assertEqual(first.status_code, 201) + replay = self.publish(client_message_id=key, file_urls=[self.file.pk]) + self.assertEqual(replay.status_code, 200) + self.assertEqual(first.json(), replay.json()) + second = self.publish("Вторая публикация").json()["id"] + self.assertEqual(self.list_channels()[0]["unread_count"], 0) + + self.api.force_authenticate(self.member) + listing = self.list_channels()[0] + self.assertEqual(listing["unread_count"], 2) + self.assertEqual(listing["first_unread_message_id"], first.json()["id"]) + for message_id in (second, first.json()["id"]): + read = self.api.post( + self.url("read"), {"message_id": message_id}, format="json", secure=True + ) + self.assertEqual(read.json(), {"last_message_id": second}) + self.assertEqual(self.list_channels()[0]["unread_count"], 0) + self.assertIsNone(self.list_channels()[0]["first_unread_message_id"]) + + def test_edit_and_delete_are_visible_in_sync_and_withdraw_attachment(self): + published = self.publish(file_urls=[self.file.pk]).json() + message_id = published["id"] + change = self.url("message-change", message_id) + cursor = self.api.get(self.history, secure=True).json()["sync_cursor"] + self.assertEqual( + self.api.patch( + change, {"text": "Правка"}, format="json", secure=True + ).status_code, + 200, + ) + changes = self.url("message-changes") + delta = self.api.get(changes, {"after": cursor}, secure=True).json() + self.assertEqual(delta["results"][0]["message"]["text"], "Правка") + self.assertEqual(self.api.delete(change, secure=True).status_code, 204) + self.api.force_authenticate(self.member) + deleted = self.api.get( + changes, {"after": delta["next_cursor"]}, secure=True + ).json() + self.assertEqual( + deleted["results"], [{"type": "delete", "message_id": message_id}] + ) + self.assertEqual( + self.api.get(published["files"][0]["link"], secure=True).status_code, 404 + ) + + def test_manager_can_publish_and_edit_in_admin_with_shared_validation(self): + self.client.force_login(self.manager) + name = "admin:chats_programchannel_message_search" + search_url = reverse(name, args=[self.channel.pk]) + publish_url = reverse(name + "_publish", args=[self.channel.pk]) + self.assertContains(self.client.get(search_url, secure=True), publish_url) + uploaded_url = STORAGE + "admin.txt" + with patch( + "chats.admin.ProgramChannelAdmin.cdn.upload", + return_value=FileInfo( + url=uploaded_url, + name="Объявление", + extension="txt", + mime_type="text/plain", + detected_mime_type="text/plain", + size=5, + ), + ): + upload_response = self.client.post( + publish_url, + {"action": "upload", "file": SimpleUploadedFile("note.txt", b"hello")}, + secure=True, + ) + self.assertContains(upload_response, "Файл загружен и выбран для публикации.") + self.assertTrue( + UserFile.objects.filter(pk=uploaded_url, user=self.manager).exists() + ) + payload = { + "text": "", + "files": [uploaded_url], + "client_message_id": str(uuid4()), + } + for _ in range(2): + response = self.client.post(publish_url, payload, secure=True) + self.assertEqual(response.status_code, 302) + message = self.channel.messages.get() + change_url = reverse(name + "_change", args=[self.channel.pk, message.pk]) + response = self.client.post( + change_url, {"action": "edit", "text": "Изменено в админке"}, secure=True + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + self.api.get(self.history, secure=True).json()["results"][0]["text"], + "Изменено в админке", + ) + self.program.managers.remove(self.manager) + self.assertEqual(self.client.get(search_url, secure=True).status_code, 404) + + def test_program_return_to_draft_hides_channel_without_losing_history(self): + self.assertEqual(self.publish().status_code, 201) + PartnerProgram.objects.filter(pk=self.program.pk).update(draft=True) + self.assertEqual(self.api.get(self.history, secure=True).status_code, 404) + self.assertEqual(self.list_channels(), []) + PartnerProgram.objects.filter(pk=self.program.pk).update(draft=False) + self.assertEqual(self.api.get(self.history, secure=True).json()["count"], 1) + + @patch("files.service.requests.put") + @patch("files.service.SelectelSwiftStorage._get_auth_token", return_value="test") + def test_failed_storage_upload_does_not_create_an_attachment(self, _token, put): + put.return_value.status_code = 503 + before = UserFile.objects.count() + response = self.api.post( + "/files/?preserve_original=true", + {"file": SimpleUploadedFile("note.txt", b"hello")}, + secure=True, + ) + self.assertEqual(response.status_code, 502) + self.assertEqual(UserFile.objects.count(), before) + + +@override_settings(ALLOWED_HOSTS=["testserver"], RUNNING_TESTS=False) +class ProgramChannelSocketTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.manager = users.create(**TEST_USER1, is_active=True) + self.member = users.create(**TEST_USER2, is_active=True) + self.program, self.profile = create_program(self.manager, self.member) + + async def test_channel_change_is_delivered_only_while_member_has_access(self): + socket = WebsocketCommunicator( + application, + "/ws/chat/", + subprotocols=["Bearer", str(AccessToken.for_user(self.member))], + ) + api = APIClient() + api.force_authenticate(self.manager) + url = reverse("chats:program-channel-messages", args=[self.program.pk]) + self.assertEqual(await socket.connect(), (True, "Bearer")) + try: + await socket.send_json_to( + { + "type": "subscribe_program_channel", + "content": {"channel_id": self.program.pk}, + } + ) + self.assertEqual( + (await socket.receive_json_from(timeout=5))["type"], + "subscribe_program_channel", + ) + response = await database_sync_to_async(api.post)( + url, {"text": "Публикация"}, format="json", secure=True + ) + self.assertEqual(response.status_code, 201) + self.assertEqual( + await socket.receive_json_from(timeout=5), + { + "type": "program_channel_changed", + "content": {"channel_id": self.program.pk}, + }, + ) + await database_sync_to_async(self.profile.delete)() + response = await database_sync_to_async(api.post)( + url, {"text": "Следующая публикация"}, format="json", secure=True + ) + self.assertEqual(response.status_code, 201) + self.assertTrue(await socket.receive_nothing()) + finally: + await socket.disconnect() diff --git a/chats/urls.py b/chats/urls.py index bf736b91..af7b0bf7 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,14 +1,23 @@ from django.urls import path -from chats.models import DirectChat, ProjectChat +from chats.channel_views import ( + ProgramChannelDetail, + ProgramChannelHistory, + ProgramChannelList, + ProgramChannelMessageChange, + ProgramChannelReadView, +) +from chats.models import DirectChat, ProgramChannel, ProjectChat from chats.serializers import ( DirectChatMessageListSerializer, + ProgramChannelMessageSerializer, ProjectChatMessageListSerializer, ) from chats.views import ( ChatAttachmentDownload, ChatMessageChanges, ChatMessageContext, + ChatFileList, DirectChatList, DirectChatMessageList, HasChatUnreadsView, @@ -23,6 +32,51 @@ app_name = "chats" urlpatterns = [ + path("channels/", ProgramChannelList.as_view(), name="program-channel-list"), + path( + "channels//", + ProgramChannelDetail.as_view(), + name="program-channel-detail", + ), + path( + "channels//messages/", + ProgramChannelHistory.as_view(), + name="program-channel-messages", + ), + path( + "channels//read/", + ProgramChannelReadView.as_view(), + name="program-channel-read", + ), + path( + "channels//messages//", + ProgramChannelMessageChange.as_view(), + name="program-channel-message-change", + ), + path( + "channels//messages/changes/", + ChatMessageChanges.as_view( + chat_model=ProgramChannel, serializer_class=ProgramChannelMessageSerializer + ), + name="program-channel-message-changes", + ), + path( + "channels//messages//context/", + ChatMessageContext.as_view( + chat_model=ProgramChannel, serializer_class=ProgramChannelMessageSerializer + ), + name="program-channel-message-context", + ), + path( + "channels//files/", + ChatFileList.as_view(chat_model=ProgramChannel), + name="program-channel-files", + ), + path( + "channels//messages//files//download/", + ChatAttachmentDownload.as_view(chat_model=ProgramChannel), + name="program-channel-attachment-download", + ), path("directs/", DirectChatList.as_view(), name="direct-chat-list"), path("directs//", DirectChatDetail.as_view(), name="direct-chat-detail"), path( diff --git a/chats/views.py b/chats/views.py index 5c27a8c7..c6dbb390 100644 --- a/chats/views.py +++ b/chats/views.py @@ -29,9 +29,10 @@ MessageContextForm, get_message_context, ) -from chats.models import DirectChat, DirectChatMessage, ProjectChat +from chats.models import DirectChat, DirectChatMessage, ProgramChannel, ProjectChat from chats.pagination import MessageListPagination from chats.permissions import IsProjectChatMember, IsChatMember, accessible_chats +from chats.program_channels import channel_unread_messages from chats.project_reads import ( unread_project_messages, with_project_chat_unreads, @@ -292,7 +293,7 @@ def get(self, request, id, message_id): queryset = with_project_message_reads( queryset, request.user.pk, include_reply=True ) - else: + elif isinstance(chat, DirectChat): queryset = queryset.select_related("reply_to__author") context = get_message_context(chat, queryset, message_id, **form.cleaned_data) return Response( @@ -367,7 +368,7 @@ def get(self, request, id): changed = with_project_message_reads( changed, request.user.pk, include_reply=True ) - else: + elif isinstance(chat, DirectChat): changed = changed.select_related("reply_to__author") page = list(changed[: limit + 1]) has_more = len(page) > limit @@ -395,6 +396,7 @@ def get(self, request, id): class ChatFileList(ListAPIView): + chat_model = None serializer_class = ChatAttachmentSerializer permission_classes = [IsAuthenticated] @@ -404,7 +406,9 @@ def get_queryset(self): chat = get_accessible_chat(self.request.user, self.chat_model, self.kwargs["id"]) return ( chat_attachments(chat) - .select_related("file", "direct_message", "project_message") + .select_related( + "file", "direct_message", "project_message", "channel_message" + ) .order_by("file__datetime_uploaded", "pk") ) @@ -472,6 +476,17 @@ def get(self, request, *args, **kwargs): .filter(chat_id__in=user.get_project_chats().values("pk")) .exists() ) + has_channel_messages_unread = ( + channel_unread_messages(user.pk) + .filter(chat_id__in=accessible_chats(user, ProgramChannel).values("pk")) + .exists() + ) return Response( - {"has_unreads": has_direct_messages_unread or has_project_messages_unread} + { + "has_unreads": ( + has_direct_messages_unread + or has_project_messages_unread + or has_channel_messages_unread + ) + } ) diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index d03205e7..84d448d8 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -14,6 +14,8 @@ class EventType(str, Enum): READ_MESSAGE = "message_read" TYPING = "user_typing" EDIT_MESSAGE = "edit_message" + PROGRAM_CHANNEL_CHANGED = "program_channel_changed" + SUBSCRIBE_PROGRAM_CHANNEL = "subscribe_program_channel" # GENERAL EVENTS SET_ONLINE = "set_online" @@ -23,6 +25,7 @@ class EventType(str, Enum): class EventGroupType(str, Enum): CHATS_RELATED = "CHATS_RELATED" GENERAL_EVENTS = "GENERAL_EVENTS" + PROGRAM_CHANNEL = "PROGRAM_CHANNEL" @dataclass(frozen=True) diff --git a/docs/modules/chats.md b/docs/modules/chats.md index 4918c2b6..2dc306c9 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -90,6 +90,38 @@ - `limit`, по умолчанию `20`; - `offset`. +## Каналы программ + +`GET /chats/channels/` и `GET /chats/channels//` возвращают сведения о канале, +последнюю видимую публикацию, `can_publish`, персональные `unread_count` и +`first_unread_message_id`. ID канала совпадает с ID программы. История и +вложения доступны зарегистрированным участникам и менеджерам опубликованной программы. +При возврате программы в черновик доступ закрывается, история сохраняется. + +- `GET /chats/channels//messages/` возвращает историю и поддерживает + `q`, `scope` и `author_id`. Переход к совпадению использует + `GET /chats/channels//messages//context/`. +- `POST /chats/channels//messages/` принимает `text`, `file_urls` из + загрузки `POST /files/?preserve_original=true` и необязательный UUID + `client_message_id`. Повтор с тем же UUID возвращает исходную публикацию. + Менеджер может изменить или удалить свою публикацию через `PATCH` или + `DELETE /chats/channels//messages//`. Эти действия также + доступны из раздела «Каналы программ» в Django-админке. +- `POST /chats/channels//read/` с `message_id` отмечает историю до этой + публикации включительно для текущего пользователя. +- `GET /chats/channels//files/` возвращает вложения. Для скачивания + используйте `link` с авторизацией пользователя. + +WebSocket посылает `program_channel_changed` с `content.channel_id` после +сохранения публикации, правки или удаления. После события или переподключения +клиент вызывает `GET /chats/channels//messages/changes/?after=`. +Первый курсор берется из `sync_cursor` обычной истории, последующие из +`next_cursor` ответа. Клиент применяет изменения по `message_id` и продолжает, +пока `has_more` равно `true`. При `409` клиент заново загружает историю. +Если канал стал доступен уже после подключения, отправьте событие +`subscribe_program_channel` с `content.channel_id` и дождитесь подтверждения +того же типа. + ## WebSocket WebSocket endpoint: diff --git a/files/service.py b/files/service.py index 7a045105..9a379874 100644 --- a/files/service.py +++ b/files/service.py @@ -11,6 +11,7 @@ from files.content import detect_content_type from files.exceptions import SelectelUploadError from files.helpers import convert_image_to_webp +from files.models import UserFile from files.typings import FileInfo from procollab.settings import SELECTEL_SWIFT_URL @@ -86,14 +87,18 @@ def _upload(self, file: File, user: User) -> str: token = self._get_auth_token() url = self._generate_url(file, user) - requests.put( + response = requests.put( url, headers={ "X-Auth-Token": token, "Content-Type": file.content_type, }, data=file.buffer, + timeout=(5, 60), + allow_redirects=False, ) + if not 200 <= response.status_code < 300: + raise SelectelUploadError("Не удалось загрузить файл в хранилище.") return url @@ -161,3 +166,20 @@ def upload( return self.storage.upload( File(file, quality, preserve_original=preserve_original), user ) + + +def create_user_upload(cdn, file, user, *, preserve_original=False): + info = ( + cdn.upload(file, user, preserve_original=True) + if preserve_original + else cdn.upload(file, user) + ) + return UserFile.objects.create( + user=user, + link=info.url, + name=info.name, + size=info.size, + extension=info.extension, + mime_type=info.mime_type, + detected_mime_type=info.detected_mime_type, + ) diff --git a/files/views.py b/files/views.py index 776ab8a5..036609e7 100644 --- a/files/views.py +++ b/files/views.py @@ -1,10 +1,12 @@ +import requests from django.db import transaction from rest_framework import generics from rest_framework import permissions, status from rest_framework.generics import get_object_or_404 from rest_framework.response import Response -from files.service import CDN, SelectelSwiftStorage +from files.exceptions import SelectelUploadError +from files.service import CDN, SelectelSwiftStorage, create_user_upload from files.models import UserFile from files.serializers import UserFileSerializer @@ -43,21 +45,19 @@ def post(self, request): }, status=400, ) - info = ( - self.cdn.upload(request.FILES["file"], request.user, preserve_original=True) - if original == "true" - else self.cdn.upload(request.FILES["file"], request.user) - ) - UserFile.objects.create( - user=request.user, - link=info.url, - name=info.name, - size=info.size, - extension=info.extension, - mime_type=info.mime_type, - detected_mime_type=info.detected_mime_type, - ) - return Response({"url": info.url}, status=status.HTTP_201_CREATED) + try: + upload = create_user_upload( + self.cdn, + request.FILES["file"], + request.user, + preserve_original=original == "true", + ) + except (requests.RequestException, SelectelUploadError, KeyError): + return Response( + {"detail": "Не удалось загрузить файл. Повторите попытку."}, + status=status.HTTP_502_BAD_GATEWAY, + ) + return Response({"url": upload.pk}, status=status.HTTP_201_CREATED) @transaction.atomic def delete(self, request, *args, **kwargs): diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 77633333..1083e631 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -12,6 +12,8 @@ from django.urls import path from django.utils import timezone +from chats.models import ProgramChannel +from chats.permissions import accessible_chats from core.utils import XlsxFileToExport, build_xlsx_download_response from mailing.views import MailingTemplateRender from partner_programs.models import ( @@ -189,6 +191,12 @@ def save_related(self, request, form, formsets, change): form.instance.managers.add(request.user) def change_view(self, request, object_id, form_url="", extra_context=None): + extra_context = { + **(extra_context or {}), + "can_open_program_channel": accessible_chats(request.user, ProgramChannel) + .filter(pk=self._program_id(object_id)) + .exists(), + } if "Руководитель программы" in request.user.groups.all().values_list( "name", flat=True ): @@ -200,6 +208,13 @@ def change_view(self, request, object_id, form_url="", extra_context=None): return super().change_view(request, object_id, form_url, extra_context) + @staticmethod + def _program_id(object_id): + try: + return int(object_id) + except (ValueError, TypeError): + return None + def get_urls(self): default_urls = super(PartnerProgramAdmin, self).get_urls() custom_urls = [ diff --git a/templates/admin/chats/_message.html b/templates/admin/chats/_message.html index 4c294ce1..e86c2ef9 100644 --- a/templates/admin/chats/_message.html +++ b/templates/admin/chats/_message.html @@ -7,7 +7,15 @@

    Сообщение №{{ message.pk }} · {{ message.author.get_full_name }}

    Показать в переписке

    {% endif %} {% if message.author_id == request.user.pk and message_change_route %} + {% if not channel_read_route or channel_publish_url %}

    Изменить или удалить

    + {% endif %} + {% endif %} + {% if channel_read_route %} +
    + {% csrf_token %} + +
    {% endif %} {% if message.file_to_message.all %}

    Вложения:

    diff --git a/templates/admin/chats/channel_publish.html b/templates/admin/chats/channel_publish.html new file mode 100644 index 00000000..73713ef2 --- /dev/null +++ b/templates/admin/chats/channel_publish.html @@ -0,0 +1,15 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

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

    +
    + {% csrf_token %} + {{ upload_form.as_p }} + +
    +
    + {% csrf_token %} + {{ form.as_p }} + +
    +{% endblock %} diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html index 0b86b9db..63d1162c 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -1,6 +1,9 @@ {% extends "admin/base_site.html" %} {% block content %} + {% if channel_publish_url %} +

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

    + {% endif %}
    {{ form.as_p }} diff --git a/templates/partner_programs/admin/program_manager_change_form.html b/templates/partner_programs/admin/program_manager_change_form.html index 97aa84dd..6be0014d 100644 --- a/templates/partner_programs/admin/program_manager_change_form.html +++ b/templates/partner_programs/admin/program_manager_change_form.html @@ -6,6 +6,9 @@
  • Регистрация
  • Шрифты сертификата
  • {% if not original.draft %} + {% if can_open_program_channel %} +
  • Канал программы
  • + {% endif %}
  • Вернуть в черновик
  • {% endif %} {% endif %} diff --git a/templates/partner_programs/admin/programs_change_form.html b/templates/partner_programs/admin/programs_change_form.html index 136a58e7..eca342d5 100644 --- a/templates/partner_programs/admin/programs_change_form.html +++ b/templates/partner_programs/admin/programs_change_form.html @@ -6,6 +6,9 @@
  • Регистрация
  • Шрифты сертификата
  • {% if not original.draft %} + {% if can_open_program_channel %} +
  • Канал программы
  • + {% endif %}
  • Вернуть в черновик
  • {% endif %} {% endif %} From ccc3620ac789afbb2d5b382eb20669a115312a21 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:15:01 +0300 Subject: [PATCH 09/16] Add direct chat privacy and spam reports --- chats/admin.py | 189 +++++++++++++++++++ chats/consumers/chat.py | 32 +++- chats/direct_privacy.py | 131 +++++++++++++ chats/direct_views.py | 95 ++++++++++ chats/migrations/0016_direct_chat_privacy.py | 156 +++++++++++++++ chats/models.py | 56 ++++++ chats/services/messages.py | 22 +++ chats/tests/test_direct_privacy.py | 165 ++++++++++++++++ chats/urls.py | 8 + chats/views.py | 5 + docs/modules/chats.md | 21 +++ templates/admin/chats/_message.html | 7 + templates/admin/chats/direct_chat_list.html | 6 + templates/admin/chats/direct_privacy.html | 27 +++ templates/admin/chats/message_search.html | 3 + 15 files changed, 914 insertions(+), 9 deletions(-) create mode 100644 chats/direct_privacy.py create mode 100644 chats/direct_views.py create mode 100644 chats/migrations/0016_direct_chat_privacy.py create mode 100644 chats/tests/test_direct_privacy.py create mode 100644 templates/admin/chats/direct_chat_list.html create mode 100644 templates/admin/chats/direct_privacy.html diff --git a/chats/admin.py b/chats/admin.py index 077b539d..59c127cd 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -16,8 +16,17 @@ from django.urls import path, reverse from django.utils.html import format_html from django.views.decorators.http import require_http_methods, require_safe +from django.utils import timezone from chats.attachments import download_chat_attachment, get_accessible_chat +from chats.direct_privacy import ( + direct_chat_opponent, + direct_contact, + get_incoming_policy, + report_direct_spam, + set_block, + set_incoming_policy, +) from chats.exceptions import MessageValidationException from chats.message_context import MessageContextForm, get_message_context from chats.models import ( @@ -25,6 +34,8 @@ DirectChat, ProjectChatMessage, DirectChatMessage, + DirectChatPrivacy, + DirectChatSpamReport, FileToMessage, ProgramChannel, ) @@ -466,6 +477,184 @@ class DirectChatAdmin(ChatSearchAdmin): "message_search_link", ) list_display_links = ("id",) + change_list_template = "admin/chats/direct_chat_list.html" + + def has_module_permission(self, request): + return request.user.is_staff + + def get_queryset(self, request): + if request.user.is_superuser: + return super().get_queryset(request) + return accessible_chats(request.user, DirectChat) + + def has_view_permission(self, request, obj=None): + if request.user.is_superuser: + return super().has_view_permission(request, obj) + return ( + obj is None + or accessible_chats(request.user, DirectChat).filter(pk=obj.pk).exists() + ) + + def has_add_permission(self, request): + return request.user.is_superuser and super().has_add_permission(request) + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser and super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser and super().has_delete_permission(request, obj) + + def get_urls(self): + return [ + path( + "incoming-settings/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.incoming_settings) + ), + name="chats_directchat_incoming_settings", + ), + path( + "/privacy/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.chat_privacy) + ), + name=self.message_search_url_name + "_privacy", + ), + ] + super().get_urls() + + def incoming_settings(self, request): + form = DirectPrivacyForm( + request.POST if request.method == "POST" else None, + initial={"incoming": get_incoming_policy(request.user)}, + ) + if request.method == "POST" and form.is_valid(): + set_incoming_policy(request.user, form.cleaned_data["incoming"]) + self.message_user(request, "Настройки сохранены.") + return redirect("admin:chats_directchat_incoming_settings") + return TemplateResponse( + request, + "admin/chats/direct_privacy.html", + { + **self.admin_site.each_context(request), + "title": "Настройки личных сообщений", + "opts": self.model._meta, + "form": form, + "search_url": reverse("admin:chats_directchat_changelist"), + "back_label": "К личным чатам", + }, + status=400 if form.errors else 200, + ) + + def extra_channel_context(self, request, chat): + return { + "direct_privacy_url": reverse( + "admin:" + self.message_search_url_name + "_privacy", args=[chat.pk] + ) + } + + def chat_privacy(self, request, object_id): + chat = self.get_chat_for_member(request, object_id) + posted = request.method == "POST" + action = request.POST.get("action") + try: + other = direct_chat_opponent(chat, request.user) + except MessageValidationException: + other = None + error = None + if posted: + try: + if action in ("block", "unblock") and other is not None: + set_block(request.user, other, action == "block") + elif action == "report" and other is not None: + report = DirectSpamReportForm(request.POST) + if not report.is_valid(): + raise MessageValidationException("Некорректный номер сообщения.") + report_direct_spam( + chat, request.user, report.cleaned_data["message_id"] + ) + else: + raise MessageValidationException("Действие недоступно.") + except MessageValidationException as exc: + error = str(exc) + if not error: + self.message_user( + request, + ( + "Жалоба отправлена." + if action == "report" + else "Настройки сохранены." + ), + ) + return redirect( + "admin:" + self.message_search_url_name + "_privacy", chat.pk + ) + return TemplateResponse( + request, + "admin/chats/direct_privacy.html", + { + **self.admin_site.each_context(request), + "title": "Настройки личных сообщений", + "opts": self.model._meta, + "settings_url": reverse("admin:chats_directchat_incoming_settings"), + "contact": direct_contact(request.user, other, chat) if other else None, + "error": error, + "search_url": reverse( + "admin:" + self.message_search_url_name, args=[chat.pk] + ), + }, + status=400 if error else 200, + ) + + +class DirectPrivacyForm(forms.Form): + incoming = forms.ChoiceField( + label="Кто может начать диалог со мной", + choices=DirectChatPrivacy.Incoming.choices, + error_messages={ + "invalid_choice": "Выберите, кто может начать диалог.", + "required": "Выберите, кто может начать диалог.", + }, + ) + + +class DirectSpamReportForm(forms.Form): + message_id = forms.IntegerField(required=False, min_value=1, max_value=2**63 - 1) + + +@admin.register(DirectChatSpamReport) +class DirectChatSpamReportAdmin(admin.ModelAdmin): + list_display = ( + "message_id", + "reporter", + "reported_user", + "created_at", + "reviewed_at", + ) + list_filter = ("reviewed_at",) + fields = ( + "chat", + "reporter", + "reported_user", + "message_id", + "message_text", + "created_at", + "reviewed_at", + ) + readonly_fields = fields + actions = ("mark_reviewed",) + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + @admin.action(permissions=["change"], description="Отметить рассмотренными") + def mark_reviewed(self, request, queryset): + count = queryset.filter(reviewed_at__isnull=True).update( + reviewed_at=timezone.now() + ) + self.message_user(request, f"Отмечено рассмотренными: {count}") class ChatMessageAdmin(admin.ModelAdmin): diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index 1df01e78..c32b0b5e 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -10,10 +10,11 @@ from django.utils import timezone from chats.channel_events import channel_group +from chats.direct_privacy import direct_chat_opponent, direct_contact from chats.exceptions import ChatException from chats.permissions import accessible_chats, direct_chat_is_accessible from chats.project_reads import personalize_project_message_event -from chats.services.messages import get_direct_chat_for_participants +from chats.services.messages import get_direct_chat_for_typing from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ( Event, @@ -26,7 +27,7 @@ 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 ProgramChannel, ProjectChat +from chats.models import DirectChat, ProgramChannel, ProjectChat @database_sync_to_async @@ -49,6 +50,18 @@ def can_access_program_channel(user, channel_id): return accessible_chats(user, ProgramChannel).filter(pk=channel_id).exists() +@database_sync_to_async +def can_receive_direct_typing(user, chat_id): + chat = accessible_chats(user, DirectChat).filter(pk=chat_id).first() + if chat is None: + return False + try: + other = direct_chat_opponent(chat, user) + except ChatException: + return False + return direct_contact(other, user, chat).can_send + + class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -223,7 +236,7 @@ async def __process_typing_event(self, event: Event, room_name: str): event.content, self.user ) - chat = await database_sync_to_async(get_direct_chat_for_participants)( + chat = await database_sync_to_async(get_direct_chat_for_typing)( self.user, other_user ) event_data["content"]["chat_id"] = chat.pk @@ -237,13 +250,14 @@ async def __process_typing_event(self, event: Event, room_name: str): async def _send_chat_event(self, event): direct_chat_id = event.get("_direct_chat_id") - if direct_chat_id is not None and ( - not self.user - or not await database_sync_to_async(direct_chat_is_accessible)( - self.user, direct_chat_id + if direct_chat_id is not None: + check_access = ( + can_receive_direct_typing + if event["type"] == EventType.TYPING + else database_sync_to_async(direct_chat_is_accessible) ) - ): - return + if not self.user or not await check_access(self.user, direct_chat_id): + return chat_id = event.get("_project_chat_id") if chat_id is not None: if not self.user or not await can_access_project_chat(self.user, chat_id): diff --git a/chats/direct_privacy.py b/chats/direct_privacy.py new file mode 100644 index 00000000..e2b734ec --- /dev/null +++ b/chats/direct_privacy.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import Q +from django.http import Http404 + +from chats.exceptions import MessageValidationException +from chats.models import DirectChatBlock, DirectChatPrivacy, DirectChatSpamReport +from projects.models import Project + + +@dataclass(frozen=True) +class DirectContact: + blocked_by_me: bool + can_send: bool + + +def lock_contact_users(*users): + # A first send and a privacy change must be ordered even before a chat exists. + ids = {user.pk for user in users} + locked = list( + get_user_model() + .objects.filter(pk__in=ids) + .order_by("pk") + .select_for_update(no_key=True) + .values_list("pk", flat=True) + ) + if set(locked) != ids: + raise MessageValidationException("Пользователь недоступен.") + + +def get_incoming_policy(user): + return ( + DirectChatPrivacy.objects.filter(user=user) + .values_list("incoming", flat=True) + .first() + or DirectChatPrivacy.Incoming.EVERYONE + ) + + +@transaction.atomic +def set_incoming_policy(user, incoming): + if incoming not in DirectChatPrivacy.Incoming.values: + raise MessageValidationException("Выберите, кто может начать диалог.") + lock_contact_users(user) + DirectChatPrivacy.objects.update_or_create(user=user, defaults={"incoming": incoming}) + return incoming + + +@transaction.atomic +def set_block(user, other_user, blocked): + if user.pk == other_user.pk: + raise MessageValidationException("Нельзя заблокировать самого себя.") + lock_contact_users(user, other_user) + blocks = DirectChatBlock.objects.filter(user=user, blocked_user=other_user) + if blocked: + blocks.get_or_create(user=user, blocked_user=other_user) + else: + blocks.delete() + + +def direct_contact(user, other_user, chat=None): + if user.pk == other_user.pk: + return DirectContact(blocked_by_me=False, can_send=True) + blockers = set( + DirectChatBlock.objects.filter( + Q(user=user, blocked_user=other_user) | Q(user=other_user, blocked_user=user) + ).values_list("user_id", flat=True) + ) + if blockers: + return DirectContact(blocked_by_me=user.pk in blockers, can_send=False) + if chat is not None and chat.messages.exists(): + return DirectContact(blocked_by_me=False, can_send=True) + if get_incoming_policy(other_user) == DirectChatPrivacy.Incoming.EVERYONE: + return DirectContact(blocked_by_me=False, can_send=True) + + teams = Project.objects.filter(Q(leader=user) | Q(collaborator__user=user)).values( + "pk" + ) + shared_team = ( + Project.objects.filter(pk__in=teams) + .filter(Q(leader=other_user) | Q(collaborator__user=other_user)) + .exists() + ) + return DirectContact(blocked_by_me=False, can_send=shared_team) + + +def require_direct_contact(user, other_user, chat): + if not direct_contact(user, other_user, chat).can_send: + raise MessageValidationException("Отправка сообщений в этот диалог недоступна.") + + +def direct_chat_opponent(chat, user): + other = chat.users.exclude(pk=user.pk).first() + if other is None: + raise MessageValidationException( + "Действие доступно в диалоге с другим пользователем." + ) + return other + + +@transaction.atomic +def report_direct_spam(chat, reporter, message_id=None): + if not chat.users.filter(pk=reporter.pk).exists(): + raise Http404("Чат недоступен.") + other = direct_chat_opponent(chat, reporter) + if ( + message_id is not None + and DirectChatSpamReport.objects.filter( + chat=chat, reporter=reporter, message_id=message_id + ).exists() + ): + return + messages = chat.visible_messages().filter(author=other) + message = ( + messages.filter(pk=message_id).first() + if message_id is not None + else messages.first() + ) + if message is None: + raise MessageValidationException("Нет доступного входящего сообщения для жалобы.") + DirectChatSpamReport.objects.get_or_create( + reporter=reporter, + message_id=message.pk, + defaults={ + "chat": chat, + "reported_user": other, + "message_text": message.text, + }, + ) diff --git a/chats/direct_views.py b/chats/direct_views.py new file mode 100644 index 00000000..615af56e --- /dev/null +++ b/chats/direct_views.py @@ -0,0 +1,95 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from rest_framework import serializers, status +from rest_framework.exceptions import ValidationError +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from chats.attachments import get_accessible_chat +from chats.direct_privacy import ( + direct_chat_opponent, + direct_contact, + get_incoming_policy, + report_direct_spam, + set_block, + set_incoming_policy, +) +from chats.exceptions import MessageValidationException +from chats.models import DirectChat, DirectChatPrivacy + + +class DirectPrivacyInput(serializers.Serializer): + incoming = serializers.ChoiceField( + choices=DirectChatPrivacy.Incoming.choices, + error_messages={ + "invalid_choice": "Выберите, кто может начать диалог.", + "required": "Выберите, кто может начать диалог.", + }, + ) + + +class DirectBlockInput(serializers.Serializer): + blocked = serializers.BooleanField( + error_messages={ + "invalid": "Укажите состояние блокировки.", + "required": "Укажите состояние блокировки.", + } + ) + + +class DirectSpamInput(serializers.Serializer): + message_id = serializers.IntegerField( + required=False, min_value=1, max_value=2**63 - 1 + ) + + +@method_decorator(never_cache, name="dispatch") +class DirectPrivacyView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = DirectPrivacyInput + + def get(self, request): + return Response({"incoming": get_incoming_policy(request.user)}) + + def put(self, request): + data = self.get_serializer(data=request.data) + data.is_valid(raise_exception=True) + incoming = set_incoming_policy(request.user, data.validated_data["incoming"]) + return Response({"incoming": incoming}) + + +@method_decorator(never_cache, name="dispatch") +class DirectBlockView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = DirectBlockInput + + def put(self, request, id): + chat = get_accessible_chat(request.user, DirectChat, id) + data = self.get_serializer(data=request.data) + data.is_valid(raise_exception=True) + try: + other = direct_chat_opponent(chat, request.user) + set_block(request.user, other, data.validated_data["blocked"]) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + contact = direct_contact(request.user, other, chat) + return Response( + {"blocked_by_me": contact.blocked_by_me, "can_send": contact.can_send} + ) + + +@method_decorator(never_cache, name="dispatch") +class DirectSpamReportView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = DirectSpamInput + + def post(self, request, id): + chat = get_accessible_chat(request.user, DirectChat, id) + data = self.get_serializer(data=request.data) + data.is_valid(raise_exception=True) + try: + report_direct_spam(chat, request.user, **data.validated_data) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/chats/migrations/0016_direct_chat_privacy.py b/chats/migrations/0016_direct_chat_privacy.py new file mode 100644 index 00000000..c3cba58b --- /dev/null +++ b/chats/migrations/0016_direct_chat_privacy.py @@ -0,0 +1,156 @@ +# Generated by Django 5.2.18 on 2026-11-13 19:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0015_program_channels"), + ("users", "0066_alter_userachievement_files"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DirectChatPrivacy", + fields=[ + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "incoming", + models.CharField( + choices=[ + ("everyone", "Все пользователи"), + ("team_members", "Участники моих команд"), + ], + default="everyone", + max_length=16, + verbose_name="Кто может начать диалог", + ), + ), + ], + ), + migrations.CreateModel( + name="DirectChatBlock", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "blocked_user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("user", "blocked_user"), name="unique_direct_chat_block" + ), + models.CheckConstraint( + condition=models.Q( + ("user", models.F("blocked_user")), _negated=True + ), + name="direct_chat_block_distinct_users", + ), + ], + }, + ), + migrations.CreateModel( + name="DirectChatSpamReport", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "message_id", + models.PositiveBigIntegerField(verbose_name="Номер сообщения"), + ), + ( + "message_text", + models.TextField(blank=True, verbose_name="Текст сообщения"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Дата жалобы"), + ), + ( + "reviewed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата рассмотрения" + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="chats.directchat", + verbose_name="Диалог", + ), + ), + ( + "reported_user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор сообщения", + ), + ), + ( + "reporter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Кто пожаловался", + ), + ), + ], + options={ + "verbose_name": "Жалоба на спам в личном чате", + "verbose_name_plural": "Жалобы на спам в личных чатах", + "constraints": [ + models.UniqueConstraint( + fields=("reporter", "message_id"), + name="unique_direct_chat_spam_report", + ) + ], + }, + ), + ] diff --git a/chats/models.py b/chats/models.py index 0459dd6a..5d42b18c 100644 --- a/chats/models.py +++ b/chats/models.py @@ -199,6 +199,39 @@ class Meta: verbose_name_plural = "Личные чаты" +class DirectChatPrivacy(models.Model): + class Incoming(models.TextChoices): + EVERYONE = "everyone", "Все пользователи" + TEAM_MEMBERS = "team_members", "Участники моих команд" + + user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) + incoming = models.CharField( + "Кто может начать диалог", + max_length=16, + choices=Incoming.choices, + default=Incoming.EVERYONE, + ) + + +class DirectChatBlock(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+") + blocked_user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="+", verbose_name="Пользователь" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "blocked_user"], name="unique_direct_chat_block" + ), + models.CheckConstraint( + condition=~models.Q(user=models.F("blocked_user")), + name="direct_chat_block_distinct_users", + ), + ] + + class BaseMessage(models.Model): """ Base message model @@ -373,6 +406,29 @@ class Meta: ] +class DirectChatSpamReport(models.Model): + chat = models.ForeignKey(DirectChat, on_delete=models.CASCADE, verbose_name="Диалог") + reporter = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="+", verbose_name="Кто пожаловался" + ) + reported_user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="+", verbose_name="Автор сообщения" + ) + message_id = models.PositiveBigIntegerField("Номер сообщения") + message_text = models.TextField("Текст сообщения", blank=True) + created_at = models.DateTimeField("Дата жалобы", auto_now_add=True) + reviewed_at = models.DateTimeField("Дата рассмотрения", null=True, blank=True) + + class Meta: + verbose_name = "Жалоба на спам в личном чате" + verbose_name_plural = "Жалобы на спам в личных чатах" + constraints = [ + models.UniqueConstraint( + fields=["reporter", "message_id"], name="unique_direct_chat_spam_report" + ) + ] + + class ProgramChannel(BaseChat): program = models.OneToOneField( "partner_programs.PartnerProgram", diff --git a/chats/services/messages.py b/chats/services/messages.py index 4ebcd4a3..ad6569ef 100644 --- a/chats/services/messages.py +++ b/chats/services/messages.py @@ -3,6 +3,7 @@ from django.db import transaction from chats.channel_events import notify_channel_change +from chats.direct_privacy import lock_contact_users, require_direct_contact from chats.exceptions import MessageValidationException from chats.models import ( DirectChat, @@ -114,6 +115,7 @@ def _create_message( reply_to_id, file_urls, client_message_id, + validate_new_message=None, ): client_message_id = _normalize_client_message_id(client_message_id) if client_message_id is not None: @@ -134,6 +136,8 @@ def _create_message( False, ) + if validate_new_message is not None: + validate_new_message() file_urls = _normalize_message(text, file_urls) reply = {} if message_model is not ProgramChannelMessage: @@ -161,6 +165,7 @@ def create_direct_message( file_urls=None, client_message_id=None, ): + lock_contact_users(author, other_user) chat = get_direct_chat_for_participants(author, other_user) # Serialize sends in this chat before checking for a repeat or locking files. DirectChat.objects.select_for_update().get(pk=chat.pk) @@ -174,6 +179,7 @@ def create_direct_message( reply_to_id=reply_to_id, file_urls=file_urls, client_message_id=client_message_id, + validate_new_message=lambda: require_direct_contact(author, other_user, chat), ) @@ -186,6 +192,15 @@ def get_direct_chat_for_participants(user, other_user): return chat +@transaction.atomic +def get_direct_chat_for_typing(user, other_user): + lock_contact_users(user, other_user) + chat_id = DirectChat.get_chat_id_from_users(user, other_user) + chat = DirectChat.objects.filter(pk=chat_id, users=user).first() + require_direct_contact(user, other_user, chat) + return get_direct_chat_for_participants(user, other_user) + + def mark_direct_message_read(*, chat_id, message_id, user, author): if type(message_id) not in (str, int): raise MessageValidationException("Некорректный идентификатор сообщения.") @@ -261,6 +276,13 @@ def _get_message_for_change(chat_model, chat_id, message_id, author): @transaction.atomic def edit_message(*, chat_model, chat_id, message_id, author, text): + if chat_model is DirectChat: + chat = accessible_chats(author, DirectChat).filter(pk=chat_id).first() + if chat is None: + raise MessageValidationException("Чат недоступен.") + other_user = chat.users.exclude(pk=author.pk).first() or author + lock_contact_users(author, other_user) + require_direct_contact(author, other_user, chat) message = _get_message_for_change(chat_model, chat_id, message_id, author) if message.is_deleted: raise MessageValidationException("Сообщение уже удалено.") diff --git a/chats/tests/test_direct_privacy.py b/chats/tests/test_direct_privacy.py new file mode 100644 index 00000000..fa476713 --- /dev/null +++ b/chats/tests/test_direct_privacy.py @@ -0,0 +1,165 @@ +from uuid import uuid4 + +from channels.db import database_sync_to_async +from channels.testing import WebsocketCommunicator +from django.contrib.auth import get_user_model +from django.test import TestCase, TransactionTestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from chats.consumers import ChatConsumer +from chats.models import DirectChat, DirectChatSpamReport +from chats.services.messages import create_direct_message +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from projects.models import Collaborator, Project + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class DirectPrivacyTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.sender = users.create(**TEST_USER1, is_active=True) + cls.recipient = users.create(**TEST_USER2, is_active=True, is_staff=True) + cls.outsider = users.create(**TEST_USER3, is_active=True, is_staff=True) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.recipient) + self.chat_id = DirectChat.get_chat_id_from_users(self.sender, self.recipient) + + def url(self, suffix): + return reverse("chats:direct-chat-" + suffix, args=[self.chat_id]) + + def test_policy_is_private_and_applies_to_new_dialogs(self): + privacy_url = reverse("chats:direct-chat-privacy") + self.assertEqual( + self.api.get(privacy_url, secure=True).json(), {"incoming": "everyone"} + ) + self.client.force_login(self.recipient) + settings_url = reverse("admin:chats_directchat_incoming_settings") + self.assertContains( + self.client.get(reverse("admin:chats_directchat_changelist"), secure=True), + settings_url, + ) + self.assertEqual( + self.client.post( + settings_url, {"incoming": "team_members"}, secure=True + ).status_code, + 302, + ) + self.assertEqual( + self.api.get(privacy_url, secure=True).json(), {"incoming": "team_members"} + ) + self.api.force_authenticate(self.sender) + first = self.api.get(self.url("detail"), secure=True) + self.assertFalse(first.json()["can_send"]) + project = Project.objects.create(leader=self.recipient) + Collaborator.objects.create(project=project, user=self.sender) + self.assertTrue(self.api.get(self.url("detail"), secure=True).json()["can_send"]) + create_direct_message( + author=self.sender, other_user=self.recipient, text="Первое" + ) + Collaborator.objects.filter(project=project, user=self.sender).delete() + self.assertTrue(self.api.get(self.url("detail"), secure=True).json()["can_send"]) + self.assertEqual( + self.api.get(privacy_url, secure=True).json(), {"incoming": "everyone"} + ) + + def test_report_and_block_use_shared_admin_rules_and_keep_history(self): + message, _ = create_direct_message( + author=self.sender, other_user=self.recipient, text="Входящее" + ) + admin_url = reverse( + "admin:chats_directchat_message_search_privacy", args=[self.chat_id] + ) + self.client.force_login(self.recipient) + self.assertContains( + self.client.get(admin_url, secure=True), "Заблокировать собеседника" + ) + response = self.client.post(admin_url, {"action": "block"}, secure=True) + self.assertEqual(response.status_code, 302) + self.assertEqual( + self.api.get(self.url("detail"), secure=True).json()["blocked_by_me"], True + ) + payload = {"message_id": message["id"]} + self.assertEqual( + self.api.post( + self.url("report-spam"), payload, format="json", secure=True + ).status_code, + 204, + ) + self.assertEqual( + self.client.post( + admin_url, {"action": "report", **payload}, secure=True + ).status_code, + 302, + ) + self.assertEqual(DirectChatSpamReport.objects.get().message_text, "Входящее") + self.api.force_authenticate(self.sender) + detail = self.api.get(self.url("detail"), secure=True).json() + self.assertFalse(detail["blocked_by_me"]) + self.assertFalse(detail["can_send"]) + self.assertEqual( + self.api.get(self.url("messages"), secure=True).json()["count"], 1 + ) + self.client.force_login(self.outsider) + self.assertEqual(self.client.get(admin_url, secure=True).status_code, 404) + self.api.force_authenticate(self.recipient) + response = self.api.put( + self.url("block"), {"blocked": False}, format="json", secure=True + ) + self.assertEqual(response.json(), {"blocked_by_me": False, "can_send": True}) + + +class DirectPrivacySocketTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.sender = users.create(**TEST_USER1) + self.recipient = users.create(**TEST_USER2) + self.chat_id = DirectChat.get_chat_id_from_users(self.sender, self.recipient) + + async def test_socket_respects_privacy_and_keeps_acknowledged_retries_idempotent( + self, + ): + from chats.direct_privacy import set_block, set_incoming_policy + + await database_sync_to_async(set_incoming_policy)(self.recipient, "team_members") + socket = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + socket.scope["user"] = self.sender + connected, _ = await socket.connect(timeout=5) + self.assertTrue(connected) + payload = { + "type": "new_message", + "content": { + "chat_type": "direct", + "chat_id": self.chat_id, + "text": "Сообщение", + "client_message_id": str(uuid4()), + }, + } + try: + await socket.send_json_to(payload) + self.assertIn("error", await socket.receive_json_from()) + self.assertFalse(await DirectChat.objects.filter(pk=self.chat_id).aexists()) + await database_sync_to_async(set_incoming_policy)(self.recipient, "everyone") + await socket.send_json_to(payload) + first = (await socket.receive_json_from())["content"]["message"] + await database_sync_to_async(set_block)(self.recipient, self.sender, True) + await socket.send_json_to(payload) + replay = (await socket.receive_json_from())["content"] + self.assertTrue(replay["is_replay"]) + self.assertEqual(replay["message"]["id"], first["id"]) + payload["content"]["client_message_id"] = str(uuid4()) + await socket.send_json_to(payload) + self.assertIn("error", await socket.receive_json_from()) + payload["type"] = "user_typing" + await socket.send_json_to(payload) + self.assertIn("error", await socket.receive_json_from()) + payload["type"] = "edit_message" + payload["content"]["message_id"] = first["id"] + payload["content"]["text"] = "Измененный текст" + await socket.send_json_to(payload) + self.assertIn("error", await socket.receive_json_from()) + finally: + await socket.disconnect() diff --git a/chats/urls.py b/chats/urls.py index af7b0bf7..b1ddcbcb 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -7,6 +7,7 @@ ProgramChannelMessageChange, ProgramChannelReadView, ) +from chats.direct_views import DirectBlockView, DirectPrivacyView, DirectSpamReportView from chats.models import DirectChat, ProgramChannel, ProjectChat from chats.serializers import ( DirectChatMessageListSerializer, @@ -78,7 +79,14 @@ name="program-channel-attachment-download", ), path("directs/", DirectChatList.as_view(), name="direct-chat-list"), + path("direct-privacy/", DirectPrivacyView.as_view(), name="direct-chat-privacy"), path("directs//", DirectChatDetail.as_view(), name="direct-chat-detail"), + path("directs//block/", DirectBlockView.as_view(), name="direct-chat-block"), + path( + "directs//report-spam/", + DirectSpamReportView.as_view(), + name="direct-chat-report-spam", + ), path( "directs//messages/", DirectChatMessageList.as_view(), diff --git a/chats/views.py b/chats/views.py index c6dbb390..c2b4ead4 100644 --- a/chats/views.py +++ b/chats/views.py @@ -24,6 +24,7 @@ get_accessible_chat, ) from chats.exceptions import MessageValidationException +from chats.direct_privacy import direct_contact from chats.message_context import ( MAX_CONTEXT_LIMIT, MessageContextForm, @@ -106,6 +107,7 @@ def get_queryset(self): ) +@method_decorator(never_cache, name="dispatch") class DirectChatDetail(RetrieveAPIView): queryset = DirectChat.objects.all() serializer_class = DirectChatDetailSerializer @@ -134,6 +136,9 @@ def get(self, request, *args, **kwargs) -> Response: data["name"] = f"{opponent.first_name} {opponent.last_name}" data["image_address"] = opponent.avatar + contact = direct_contact(request.user, opponent, chat) + data["blocked_by_me"] = contact.blocked_by_me + data["can_send"] = contact.can_send return Response( status=status.HTTP_200_OK, diff --git a/docs/modules/chats.md b/docs/modules/chats.md index 2dc306c9..ff753b8f 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -90,6 +90,27 @@ - `limit`, по умолчанию `20`; - `offset`. +## Настройки личных сообщений + +- `GET /chats/direct-privacy/` возвращает текущую настройку пользователя. + `PUT` принимает `incoming` со значением `everyone` или `team_members`. + Сервер проверяет настройку адресата при первой отправке. Общая команда + определяется по текущему составу проекта, включая руководителя. +- В карточке диалога `blocked_by_me` показывает собственную блокировку, + а `can_send` — доступность отправки. `PUT /chats/directs//block/` + принимает `blocked` и возвращает оба поля. Блокировка приостанавливает + отправку, правки и набор в обе стороны. История и ранее полученные файлы + остаются доступны участникам. Подтверждение повтора ранее принятой отправки + возвращается с тем же ID. +- `POST /chats/directs//report-spam/` принимает необязательный `message_id`. + Если ID пропущен, выбирается последнее видимое входящее сообщение. Ответ + `204` подтверждает прием. Повтор жалобы на то же сообщение учитывается один + раз. Содержание жалобы доступно в Django-админке с правами на просмотр жалоб. + +В Django-админке настройка входящих доступна из списка личных чатов. +Заблокировать собеседника, разблокировать его и отправить жалобу можно из +переписки. Админка вызывает те же операции, что и API. + ## Каналы программ `GET /chats/channels/` и `GET /chats/channels//` возвращают сведения о канале, diff --git a/templates/admin/chats/_message.html b/templates/admin/chats/_message.html index e86c2ef9..5fe9ec0b 100644 --- a/templates/admin/chats/_message.html +++ b/templates/admin/chats/_message.html @@ -17,6 +17,13 @@

    Сообщение №{{ message.pk }} · {{ message.author.get_full_name }} {% endif %} + {% if direct_privacy_url and message.author_id != request.user.pk %} +
    + {% csrf_token %} + + +
    + {% endif %} {% if message.file_to_message.all %}

    Вложения:

      diff --git a/templates/admin/chats/direct_chat_list.html b/templates/admin/chats/direct_chat_list.html new file mode 100644 index 00000000..2bc62051 --- /dev/null +++ b/templates/admin/chats/direct_chat_list.html @@ -0,0 +1,6 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools-items %} +
    • Настройки личных сообщений
    • + {{ block.super }} +{% endblock %} diff --git a/templates/admin/chats/direct_privacy.html b/templates/admin/chats/direct_privacy.html new file mode 100644 index 00000000..725faba0 --- /dev/null +++ b/templates/admin/chats/direct_privacy.html @@ -0,0 +1,27 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

      {{ back_label|default:"К переписке" }}

      + {% if error %}

      {{ error }}

      {% endif %} + {% if form %} +
      + {% csrf_token %} + {{ form.as_p }} + +
      + {% endif %} + {% if settings_url %} +

      Кто может начать диалог со мной

      + {% endif %} + {% if contact %} +
      + {% csrf_token %} + {% if contact.blocked_by_me %} + + {% else %} + + {% endif %} + +
      + {% endif %} +{% endblock %} diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html index 63d1162c..c6c1886a 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -1,6 +1,9 @@ {% extends "admin/base_site.html" %} {% block content %} + {% if direct_privacy_url %} +

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

      + {% endif %} {% if channel_publish_url %}

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

      {% endif %} From 7f4f37afa2bc36f169dd5a51d5325eaa42119acd Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:24:50 +0300 Subject: [PATCH 10/16] 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 %} From 58f71090b1cee789c998cf0a22714026f42dd8c0 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:32:46 +0300 Subject: [PATCH 11/16] Save personal drafts across chat types --- chats/admin.py | 106 ++++++++++- chats/channel_views.py | 10 +- chats/draft_views.py | 50 ++++++ chats/drafts.py | 83 +++++++++ chats/migrations/0018_personal_chat_drafts.py | 101 +++++++++++ chats/models.py | 46 +++++ chats/serializers.py | 5 +- chats/tests/test_drafts.py | 167 ++++++++++++++++++ chats/urls.py | 16 ++ chats/views.py | 38 ++-- docs/modules/chats.md | 16 ++ templates/admin/chats/draft.html | 14 ++ templates/admin/chats/message_search.html | 3 + 13 files changed, 634 insertions(+), 21 deletions(-) create mode 100644 chats/draft_views.py create mode 100644 chats/drafts.py create mode 100644 chats/migrations/0018_personal_chat_drafts.py create mode 100644 chats/tests/test_drafts.py create mode 100644 templates/admin/chats/draft.html diff --git a/chats/admin.py b/chats/admin.py index eb498cac..d8264ebb 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -29,6 +29,14 @@ set_incoming_policy, ) from chats.exceptions import MessageValidationException +from chats.drafts import ( + DRAFT_TEXT_LIMIT, + MAX_DRAFT_REVISION, + DraftConflict, + draft_chat, + draft_state, + save_draft, +) from chats.message_context import MessageContextForm, get_message_context from chats.models import ( ProjectChat, @@ -80,6 +88,13 @@ def get_urls(self): "" if self.model is DirectChat else "" ) return [ + path( + object_path + "/draft/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.personal_draft) + ), + name=self.message_search_url_name + "_draft", + ), path( object_path + "/message-search/", self.admin_site.admin_view(self.message_search), @@ -136,6 +151,14 @@ def message_search(self, request, object_id): "form": form, "page": page, "chat": chat, + "draft_url": ( + reverse( + "admin:" + self.message_search_url_name + "_draft", args=[chat.pk] + ) + if not isinstance(chat, ProgramChannel) + or can_publish_in_program_channel(request.user, chat.program) + else None + ), **self.extra_channel_context(request, chat), "message_context_route": "admin:" + self.message_search_url_name @@ -158,6 +181,49 @@ def get_chat_for_member(self, request, object_id): def extra_channel_context(self, request, chat): return {} + def personal_draft(self, request, object_id): + chat = self.get_chat_for_member(request, object_id) + draft_chat(request.user, self.model, chat.pk) + current = draft_state(request.user, chat) + form = ChatDraftForm( + request.POST if request.method == "POST" else None, initial=current + ) + response_status = 200 + if request.method == "POST": + response_status = 400 + if form.is_valid(): + try: + save_draft(request.user, self.model, chat.pk, **form.cleaned_data) + except DraftConflict as error: + current = error.current + response_status = 409 + form.add_error( + None, + "Черновик уже изменен. Скопируйте свой текст и обновите страницу.", + ) + except ValueError as error: + form.add_error(None, str(error)) + else: + self.message_user(request, "Черновик сохранен.") + return redirect( + "admin:" + self.message_search_url_name + "_draft", chat.pk + ) + return TemplateResponse( + request, + "admin/chats/draft.html", + { + **self.admin_site.each_context(request), + "title": "Личный черновик", + "opts": self.model._meta, + "form": form, + "current": current if response_status == 409 else None, + "back_url": reverse( + "admin:" + self.message_search_url_name, args=[chat.pk] + ), + }, + status=response_status, + ) + def attachment_download(self, request, object_id, message_id, attachment_id): chat = self.get_chat_for_member(request, object_id) return download_chat_attachment(request, chat, message_id, attachment_id) @@ -286,6 +352,21 @@ def message_context(self, request, object_id, message_id): ) +class ChatDraftForm(forms.Form): + text = forms.CharField( + label="Текст черновика", + required=False, + strip=False, + max_length=DRAFT_TEXT_LIMIT, + widget=forms.Textarea, + ) + revision = forms.IntegerField( + min_value=0, + max_value=MAX_DRAFT_REVISION, + widget=forms.HiddenInput, + ) + + class OwnMessageForm(forms.Form): text = forms.CharField( label="Текст сообщения", required=False, strip=False, widget=forms.Textarea @@ -317,6 +398,12 @@ class GroupMessageForm(forms.Form): required=False, error_messages={"invalid_choice": "Выберите участников этого чата."}, ) + draft_revision = forms.IntegerField( + required=False, + min_value=0, + max_value=MAX_DRAFT_REVISION, + widget=forms.HiddenInput, + ) def __init__(self, user, chat, *args, **kwargs): super().__init__(*args, **kwargs) @@ -423,11 +510,16 @@ def publish_message(self, request, object_id): raise PermissionDenied("Публикации доступны менеджерам программы.") posted = request.method == "POST" uploading = posted and request.POST.get("action") == "upload" + draft = draft_state(request.user, chat) form = GroupMessageForm( request.user, chat, request.POST if posted and not uploading else None, - initial={"client_message_id": uuid4()}, + initial={ + "client_message_id": uuid4(), + "text": draft["text"], + "draft_revision": draft["revision"] if draft["text"] else None, + }, ) upload_form = ChannelUploadForm( request.POST if uploading else None, request.FILES if uploading else None @@ -460,6 +552,18 @@ def publish_message(self, request, object_id): user.pk for user in form.cleaned_data["mentions"] ], ) + revision = form.cleaned_data["draft_revision"] + if created and revision is not None and revision < MAX_DRAFT_REVISION: + try: + save_draft( + request.user, + self.model, + chat.pk, + text="", + revision=revision, + ) + except DraftConflict: + pass if created and isinstance(chat, ProjectChat): transaction.on_commit( partial( diff --git a/chats/channel_views.py b/chats/channel_views.py index 7eb32e84..27bc3357 100644 --- a/chats/channel_views.py +++ b/chats/channel_views.py @@ -10,6 +10,7 @@ from rest_framework.response import Response from chats.attachments import get_accessible_chat +from chats.drafts import with_draft_marker from chats.exceptions import MessageValidationException from chats.models import ProgramChannel, ProgramChannelMessage from chats.notifications import MAX_MENTIONS @@ -34,8 +35,12 @@ class ProgramChannelSerializer(serializers.ModelSerializer): unread_count = serializers.IntegerField() first_unread_message_id = serializers.IntegerField(allow_null=True) can_publish = serializers.BooleanField(read_only=True) + has_draft = serializers.SerializerMethodField() last_message = serializers.SerializerMethodField() + def get_has_draft(self, channel) -> bool: + return channel.can_publish and channel.has_draft + def get_last_message(self, channel): if channel.latest_messages: return ProgramChannelMessageSerializer( @@ -53,6 +58,7 @@ class Meta: "unread_count", "first_unread_message_id", "last_message", + "has_draft", ) @@ -95,7 +101,9 @@ def get_queryset(self): return ProgramChannel.objects.none() user = self.request.user managed = PartnerProgram.objects.filter(pk=OuterRef("pk"), managers=user) - channels = accessible_chats(user, ProgramChannel).select_related("program") + channels = with_draft_marker( + accessible_chats(user, ProgramChannel).select_related("program"), user + ) recent = ( ProgramChannelMessage.objects.filter(is_deleted=False) .select_related("author") diff --git a/chats/draft_views.py b/chats/draft_views.py new file mode 100644 index 00000000..64d00f90 --- /dev/null +++ b/chats/draft_views.py @@ -0,0 +1,50 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from drf_yasg.utils import swagger_auto_schema +from rest_framework import serializers, status +from rest_framework.exceptions import ValidationError +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from chats.drafts import ( + DRAFT_TEXT_LIMIT, + MAX_DRAFT_REVISION, + DraftConflict, + draft_chat, + draft_state, + save_draft, +) + + +class ChatDraftSerializer(serializers.Serializer): + text = serializers.CharField( + allow_blank=True, trim_whitespace=False, max_length=DRAFT_TEXT_LIMIT + ) + revision = serializers.IntegerField(min_value=0, max_value=MAX_DRAFT_REVISION) + updated_at = serializers.DateTimeField(read_only=True, allow_null=True) + + +@method_decorator(never_cache, name="dispatch") +class ChatDraftView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ChatDraftSerializer + chat_model = None + + def get(self, request, id): + chat = draft_chat(request.user, self.chat_model, id) + return Response(self.get_serializer(draft_state(request.user, chat)).data) + + @swagger_auto_schema(responses={200: ChatDraftSerializer, 409: ChatDraftSerializer}) + def put(self, request, id): + data = self.get_serializer(data=request.data) + data.is_valid(raise_exception=True) + try: + draft = save_draft(request.user, self.chat_model, id, **data.validated_data) + except DraftConflict as error: + return Response( + self.get_serializer(error.current).data, status=status.HTTP_409_CONFLICT + ) + except ValueError as error: + raise ValidationError({"detail": str(error)}) from error + return Response(self.get_serializer(draft).data) diff --git a/chats/drafts.py b/chats/drafts.py new file mode 100644 index 00000000..249d6cad --- /dev/null +++ b/chats/drafts.py @@ -0,0 +1,83 @@ +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.db.models import Exists, OuterRef +from django.http import Http404 + +from chats.models import ChatDraft, DirectChat, ProgramChannel, ProjectChat +from chats.permissions import accessible_chats, can_publish_in_program_channel + +CHAT_FIELDS = { + DirectChat: "direct_chat_id", + ProjectChat: "project_chat_id", + ProgramChannel: "program_channel_id", +} +DRAFT_TEXT_LIMIT = ChatDraft._meta.get_field("text").max_length +MAX_DRAFT_REVISION = 2**53 - 1 + + +class DraftConflict(Exception): + def __init__(self, current): + self.current = current + + +def draft_chat(user, chat_model, chat_id, *, lock=False): + chats = chat_model.objects.filter( + pk=chat_id, pk__in=accessible_chats(user, chat_model).values("pk") + ) + if lock: + chats = chats.select_for_update() + chat = chats.first() + if chat is None: + raise Http404("Чат недоступен.") + if chat_model is ProgramChannel and not can_publish_in_program_channel( + user, chat.program + ): + raise PermissionDenied("Черновики канала доступны менеджерам программы.") + return chat + + +def _drafts(user, chat_model, chat_id): + return ChatDraft.objects.filter(user=user, **{CHAT_FIELDS[chat_model]: chat_id}) + + +def with_draft_marker(chats, user): + drafts = _drafts(user, chats.model, OuterRef("pk")).exclude(text="") + return chats.annotate(has_draft=Exists(drafts)) + + +def draft_state(user, chat): + draft = ( + _drafts(user, type(chat), chat.pk) + .values("text", "revision", "updated_at") + .first() + ) + return draft or {"text": "", "revision": 0, "updated_at": None} + + +@transaction.atomic +def save_draft(user, chat_model, chat_id, *, text, revision): + chat = draft_chat(user, chat_model, chat_id, lock=True) + if not isinstance(text, str) or "\x00" in text or len(text) > DRAFT_TEXT_LIMIT: + raise ValueError("Введите текст черновика длиной до 8192 символов.") + if type(revision) is not int or not 0 <= revision <= MAX_DRAFT_REVISION: + raise ValueError("Некорректная версия черновика.") + if not text.strip(): + text = "" + current = draft_state(user, chat) + if text == current["text"]: + return current + if revision != current["revision"]: + raise DraftConflict(current) + if revision == MAX_DRAFT_REVISION: + raise ValueError("Невозможно сохранить следующую версию черновика.") + # Keep the revision when clearing so delayed writes cannot restore old text. + draft, _ = ChatDraft.objects.update_or_create( + user=user, + **{CHAT_FIELDS[chat_model]: chat.pk}, + defaults={"text": text, "revision": revision + 1}, + ) + return { + "text": draft.text, + "revision": draft.revision, + "updated_at": draft.updated_at, + } diff --git a/chats/migrations/0018_personal_chat_drafts.py b/chats/migrations/0018_personal_chat_drafts.py new file mode 100644 index 00000000..e6bc9dfc --- /dev/null +++ b/chats/migrations/0018_personal_chat_drafts.py @@ -0,0 +1,101 @@ +# Generated by Django 5.2.18 on 2026-11-13 19:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0017_chat_notification_preferences"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ChatDraft", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField(blank=True, max_length=8192)), + ("revision", models.PositiveBigIntegerField(default=0)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "direct_chat", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="chats.directchat", + ), + ), + ( + "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( + ("direct_chat__isnull", False), + ("program_channel__isnull", True), + ("project_chat__isnull", True), + ), + models.Q( + ("direct_chat__isnull", True), + ("program_channel__isnull", True), + ("project_chat__isnull", False), + ), + models.Q( + ("direct_chat__isnull", True), + ("program_channel__isnull", False), + ("project_chat__isnull", True), + ), + _connector="OR", + ), + name="chat_draft_has_one_chat", + ), + models.UniqueConstraint( + fields=("user", "direct_chat"), + name="unique_direct_chat_draft_user", + ), + models.UniqueConstraint( + fields=("user", "project_chat"), + name="unique_project_chat_draft_user", + ), + models.UniqueConstraint( + fields=("user", "program_channel"), + name="unique_program_channel_draft_user", + ), + ], + }, + ), + ] diff --git a/chats/models.py b/chats/models.py index 37b0383c..15fc28c1 100644 --- a/chats/models.py +++ b/chats/models.py @@ -485,6 +485,52 @@ class Meta: ] +class ChatDraft(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + direct_chat = models.ForeignKey(DirectChat, on_delete=models.CASCADE, null=True) + project_chat = models.ForeignKey(ProjectChat, on_delete=models.CASCADE, null=True) + program_channel = models.ForeignKey( + ProgramChannel, on_delete=models.CASCADE, null=True + ) + text = models.TextField(max_length=8192, blank=True) + revision = models.PositiveBigIntegerField(default=0) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + models.Q( + direct_chat__isnull=False, + project_chat__isnull=True, + program_channel__isnull=True, + ) + | models.Q( + direct_chat__isnull=True, + project_chat__isnull=False, + program_channel__isnull=True, + ) + | models.Q( + direct_chat__isnull=True, + project_chat__isnull=True, + program_channel__isnull=False, + ) + ), + name="chat_draft_has_one_chat", + ), + models.UniqueConstraint( + fields=["user", "direct_chat"], name="unique_direct_chat_draft_user" + ), + models.UniqueConstraint( + fields=["user", "project_chat"], name="unique_project_chat_draft_user" + ), + models.UniqueConstraint( + fields=["user", "program_channel"], + name="unique_program_channel_draft_user", + ), + ] + + class ChatNotificationPreference(models.Model): class Mode(models.TextChoices): ALL = "all", "Все сообщения" diff --git a/chats/serializers.py b/chats/serializers.py index 4c6d7d77..a35f252f 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -53,6 +53,7 @@ def serialize_message_attachments(message): class DirectChatListSerializer(serializers.ModelSerializer): + has_draft = serializers.BooleanField(read_only=True) last_message = serializers.SerializerMethodField() opponent = serializers.SerializerMethodField() name = serializers.SerializerMethodField(read_only=True) @@ -84,7 +85,7 @@ def get_last_message(cls, chat: DirectChat): class Meta: model = DirectChat - fields = ["id", "opponent", "last_message", "name", "image_address"] + fields = ["id", "opponent", "last_message", "name", "image_address", "has_draft"] class ProgramChannelMessageSerializer(serializers.ModelSerializer): @@ -127,6 +128,7 @@ class Meta: class ProjectChatListSerializer(serializers.ModelSerializer): + has_draft = serializers.BooleanField(read_only=True) last_message = serializers.SerializerMethodField(read_only=True) name = serializers.SerializerMethodField(read_only=True) image_address = serializers.SerializerMethodField(read_only=True) @@ -159,6 +161,7 @@ class Meta: "name", "unread_count", "first_unread_message_id", + "has_draft", ] diff --git a/chats/tests/test_drafts.py b/chats/tests/test_drafts.py new file mode 100644 index 00000000..9cf82838 --- /dev/null +++ b/chats/tests/test_drafts.py @@ -0,0 +1,167 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from uuid import uuid4 + +from django.contrib.auth import get_user_model +from django.db import connections +from django.test import ( + TestCase, + TransactionTestCase, + override_settings, + skipUnlessDBFeature, +) +from django.urls import reverse +from rest_framework.test import APIClient + +from chats.drafts import DraftConflict, save_draft +from chats.models import DirectChat +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 ChatDraftTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.author = 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.direct = DirectChat.get_chat(cls.author, cls.member) + cls.project = create_project_chat(cls.author, cls.member) + cls.program, _ = create_program(cls.author, cls.member) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.author) + + def put(self, url, text, revision): + return self.api.put( + url, {"text": text, "revision": revision}, format="json", secure=True + ) + + def test_drafts_are_private_and_list_exposes_only_the_marker(self): + for kind, chat_id in ( + ("direct-chat", self.direct.pk), + ("project-chat", self.project.pk), + ("program-channel", self.program.pk), + ): + with self.subTest(kind=kind): + route = "chats:" + kind + url = reverse(route + "-draft", args=[chat_id]) + listing_url = reverse(route + "-list") + self.api.force_authenticate(self.author) + initial = self.api.get(url, secure=True) + self.assertEqual( + initial.json(), {"text": "", "revision": 0, "updated_at": None} + ) + self.assertIn("no-store", initial["Cache-Control"]) + written = self.put(url, "Мой черновик", 0) + self.assertEqual(written.status_code, 200) + self.assertEqual(written.json()["revision"], 1) + self.assertEqual(self.api.get(url, secure=True).json(), written.json()) + listing = self.api.get(listing_url, secure=True) + items = ( + listing.json()["results"] + if kind == "program-channel" + else listing.json() + ) + self.assertTrue(items[0]["has_draft"]) + self.assertNotContains(listing, "Мой черновик") + + self.api.force_authenticate(self.member) + response = self.api.get(url, secure=True) + if kind == "program-channel": + self.assertEqual(response.status_code, 403) + else: + self.assertEqual(response.json()["text"], "") + listing = self.api.get(listing_url, secure=True).json() + items = listing["results"] if kind == "program-channel" else listing + self.assertFalse(items[0]["has_draft"]) + self.api.force_authenticate(self.outsider) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + + def test_clearing_draft_does_not_allow_an_older_write_to_restore_it(self): + url = reverse("chats:direct-chat-draft", args=[self.direct.pk]) + first = self.put(url, "Старый текст", 0).json() + self.assertEqual(self.put(url, "Старый текст", 0).json(), first) + cleared = self.put(url, "", first["revision"]).json() + conflict = self.put(url, "Отложенная правка", first["revision"]) + self.assertEqual(conflict.status_code, 409) + self.assertEqual(conflict.json(), cleared) + self.assertEqual(self.api.get(url, secure=True).json()["text"], "") + + def test_admin_and_api_use_the_same_draft_version_and_channel_permission(self): + self.client.force_login(self.author) + admin_url = reverse( + "admin:chats_programchannel_message_search_draft", args=[self.program.pk] + ) + self.assertContains(self.client.get(admin_url, secure=True), "Личный черновик") + response = self.client.post( + admin_url, {"text": "Текст из админки", "revision": 0}, secure=True + ) + self.assertEqual(response.status_code, 302) + url = reverse("chats:program-channel-draft", args=[self.program.pk]) + self.assertEqual( + self.api.get(url, secure=True).json()["text"], "Текст из админки" + ) + publish_url = reverse( + "admin:chats_programchannel_message_search_publish", args=[self.program.pk] + ) + self.assertContains(self.client.get(publish_url, secure=True), "Текст из админки") + second = self.put(url, "Правка с другого устройства", 1).json() + for revision, text, expected_text in ( + (1, "Текст из админки", second["text"]), + (second["revision"], second["text"], ""), + ): + response = self.client.post( + publish_url, + { + "text": text, + "draft_revision": revision, + "client_message_id": str(uuid4()), + }, + secure=True, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(self.api.get(url, secure=True).json()["text"], expected_text) + self.program.managers.remove(self.author) + self.assertEqual(self.client.get(admin_url, secure=True).status_code, 404) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + + def test_removed_project_member_cannot_read_or_replace_saved_draft(self): + self.api.force_authenticate(self.member) + url = reverse("chats:project-chat-draft", args=[self.project.pk]) + self.assertEqual(self.put(url, "Черновик участника", 0).status_code, 200) + Collaborator.objects.filter(project_id=self.project.pk, user=self.member).delete() + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + self.assertEqual(self.put(url, "Правка", 1).status_code, 404) + + +class ChatDraftConcurrencyTests(TransactionTestCase): + @skipUnlessDBFeature("has_select_for_update") + def test_only_one_device_can_commit_a_new_draft_at_the_same_revision(self): + user = get_user_model().objects.create(**TEST_USER1) + chat = DirectChat.get_chat(user, user) + ready = Barrier(2) + + def write(text): + try: + ready.wait(timeout=10) + return save_draft(user, DirectChat, chat.pk, text=text, revision=0) + except DraftConflict as error: + return error + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + requests = [ + pool.submit(write, text) for text in ("Первый текст", "Второй текст") + ] + results = [request.result(timeout=10) for request in requests] + (saved,) = [result for result in results if isinstance(result, dict)] + (conflict,) = [result for result in results if isinstance(result, DraftConflict)] + self.assertEqual(saved["revision"], 1) + self.assertEqual(conflict.current, saved) diff --git a/chats/urls.py b/chats/urls.py index f9f00e32..401fe3ff 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -8,6 +8,7 @@ ProgramChannelReadView, ) from chats.direct_views import DirectBlockView, DirectPrivacyView, DirectSpamReportView +from chats.draft_views import ChatDraftView from chats.models import DirectChat, ProgramChannel, ProjectChat from chats.notification_views import ( ChatMentionRecipientsView, @@ -37,6 +38,21 @@ app_name = "chats" urlpatterns = [ + path( + "directs//draft/", + ChatDraftView.as_view(chat_model=DirectChat), + name="direct-chat-draft", + ), + path( + "projects//draft/", + ChatDraftView.as_view(chat_model=ProjectChat), + name="project-chat-draft", + ), + path( + "channels//draft/", + ChatDraftView.as_view(chat_model=ProgramChannel), + name="program-channel-draft", + ), path( "channels//notification-settings/", ChatNotificationSettingsView.as_view(chat_model=ProgramChannel), diff --git a/chats/views.py b/chats/views.py index c2b4ead4..1a34bc28 100644 --- a/chats/views.py +++ b/chats/views.py @@ -24,6 +24,7 @@ get_accessible_chat, ) from chats.exceptions import MessageValidationException +from chats.drafts import with_draft_marker from chats.direct_privacy import direct_contact from chats.message_context import ( MAX_CONTEXT_LIMIT, @@ -54,35 +55,35 @@ User = get_user_model() +@method_decorator(never_cache, name="dispatch") class DirectChatList(ListAPIView): serializer_class = DirectChatListSerializer permission_classes = [IsAuthenticated] def get_queryset(self): - return DirectChat.objects.prefetch_related("users").filter( - users=self.request.user + return with_draft_marker( + DirectChat.objects.prefetch_related("users").filter(users=self.request.user), + self.request.user, ) def get(self, request, *args, **kwargs): - chats = self.get_queryset() serialized_chats = [] - for chat in chats: - try: - user1_id, _ = map(int, chat.id.split("_")) - # TODO сделать проверку на удаление профиля - if user1_id == request.user.id: - opponent = chat.users.all()[1] - else: - opponent = chat.users.first() - - context = {"opponent": opponent} - serialized_chat = DirectChatListSerializer(chat, context=context).data - serialized_chats.append(serialized_chat) - except IndexError: - pass + for chat in self.get_queryset(): + others = [user for user in chat.users.all() if user.pk != request.user.pk] + if len(others) == 1: + opponent = others[0] + elif not others and chat.pk == DirectChat.get_chat_id_from_users( + request.user, request.user + ): + opponent = request.user + else: + continue + context = {**self.get_serializer_context(), "opponent": opponent} + serialized_chats.append(self.get_serializer(chat, context=context).data) return Response(serialized_chats, status=status.HTTP_200_OK) +@method_decorator(never_cache, name="dispatch") class ProjectChatList(ListAPIView): serializer_class = ProjectChatListSerializer permission_classes = [IsAuthenticated] @@ -92,7 +93,8 @@ def get_queryset(self): if not user.is_authenticated: return ProjectChat.objects.none() return with_project_chat_unreads( - user.get_project_chats().select_related("project"), user.pk + with_draft_marker(user.get_project_chats().select_related("project"), user), + user.pk, ) diff --git a/docs/modules/chats.md b/docs/modules/chats.md index 0d3ad78b..d0704fdf 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -90,6 +90,22 @@ - `limit`, по умолчанию `20`; - `offset`. +## Личные черновики + +Маршрут `/chats/directs//draft/`, `/chats/projects//draft/` или +`/chats/channels//draft/` возвращает через GET поля `text`, `revision` +и `updated_at`. Для нового черновика текст пуст, версия равна нулю. +PUT принимает текст и версию, полученную последним чтением. Ответ `409` +содержит сохраненный черновик, если другой клиент уже его изменил. При совпадении +текста повтор сохранения возвращает текущую версию. + +Пустой текст очищает черновик. После подтверждения отправки клиент может +очистить отправленную версию тем же PUT. Если текст уже обновился на другом +устройстве, сервер сохраняет его. Списки чатов возвращают только `has_draft`. +Для каналов черновики доступны менеджерам программы. Django-админка сохраняет +черновики по тем же правилам и подставляет их в форму сообщения команды или +публикации в канале. + ## Настройки личных сообщений - `GET /chats/direct-privacy/` возвращает текущую настройку пользователя. diff --git a/templates/admin/chats/draft.html b/templates/admin/chats/draft.html new file mode 100644 index 00000000..be69ae62 --- /dev/null +++ b/templates/admin/chats/draft.html @@ -0,0 +1,14 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

      К переписке

      + {% if current %} +

      Сохраненная версия

      +

      {{ current.text|linebreaksbr }}

      + {% endif %} +
      + {% csrf_token %} + {{ form.as_p }} + +
      +{% endblock %} diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html index 78874204..e2e70393 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -1,6 +1,9 @@ {% extends "admin/base_site.html" %} {% block content %} + {% if draft_url %} +

      Личный черновик

      + {% endif %} {% if notification_settings_url %}

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

      {% endif %} From b079e2650f28328f84620647ff85461f5d5ca1aa Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:42:36 +0300 Subject: [PATCH 12/16] Expose first unread message across chat types --- chats/admin.py | 65 ++++++++++++++++- chats/consumers/event_types/DirectEvent.py | 24 +++---- chats/direct_reads.py | 68 ++++++++++++++++++ chats/serializers.py | 17 ++++- chats/services/messages.py | 18 ----- chats/tests/test_permissions.py | 81 ++++++++++++++++++++-- chats/views.py | 37 ++++------ docs/modules/chats.md | 58 ++++++++-------- templates/admin/chats/_message.html | 6 ++ templates/admin/chats/message_search.html | 6 ++ 10 files changed, 282 insertions(+), 98 deletions(-) create mode 100644 chats/direct_reads.py diff --git a/chats/admin.py b/chats/admin.py index d8264ebb..40f4c1a0 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -28,6 +28,11 @@ set_block, set_incoming_policy, ) +from chats.direct_reads import ( + mark_direct_message_read, + publish_direct_message_read, + with_direct_chat_unreads, +) from chats.exceptions import MessageValidationException from chats.drafts import ( DRAFT_TEXT_LIMIT, @@ -55,8 +60,16 @@ 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.program_channels import ( + mark_channel_read, + publish_channel_message, + with_channel_unreads, +) +from chats.project_reads import ( + mark_project_message_read, + publish_project_message_read, + with_project_chat_unreads, +) from chats.search import ChatMessageSearchForm, search_chat_messages from chats.services.messages import create_project_message, delete_message, edit_message from chats.utils import get_user_channel_cache_key @@ -131,6 +144,16 @@ def message_search_link(self, chat): def message_search(self, request, object_id): chat = self.get_chat_for_member(request, object_id) + unreads = { + DirectChat: with_direct_chat_unreads, + ProjectChat: with_project_chat_unreads, + ProgramChannel: with_channel_unreads, + }[self.model] + chat = unreads( + accessible_chats(request.user, self.model).filter(pk=chat.pk), request.user.pk + ).first() + if chat is None: + raise Http404("Чат недоступен.") form = ChatMessageSearchForm(request.GET) page = None if form.is_valid(): @@ -765,6 +788,13 @@ def get_urls(self): ), name=self.message_search_url_name + "_privacy", ), + 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 incoming_settings(self, request): @@ -792,11 +822,40 @@ def incoming_settings(self, request): def extra_channel_context(self, request, chat): return { + "direct_read_route": "admin:" + self.message_search_url_name + "_read", "direct_privacy_url": reverse( "admin:" + self.message_search_url_name + "_privacy", args=[chat.pk] - ) + ), } + def read_message(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + try: + with transaction.atomic(): + author = direct_chat_opponent(chat, request.user) + mark_direct_message_read( + chat_id=chat.pk, + message_id=message_id, + user=request.user, + author=author, + ) + transaction.on_commit( + partial( + async_to_sync(publish_direct_message_read), + chat_id=chat.pk, + message_id=message_id, + user=request.user, + author=author, + ), + robust=True, + ) + except MessageValidationException as error: + raise Http404(str(error)) from error + self.message_user(request, "Сообщение отмечено прочитанным.") + return redirect( + "admin:" + self.message_search_url_name + "_context", chat.pk, message_id + ) + def chat_privacy(self, request, object_id): chat = self.get_chat_for_member(request, object_id) posted = request.method == "POST" diff --git a/chats/consumers/event_types/DirectEvent.py b/chats/consumers/event_types/DirectEvent.py index 9220fd92..b4f06141 100644 --- a/chats/consumers/event_types/DirectEvent.py +++ b/chats/consumers/event_types/DirectEvent.py @@ -1,5 +1,6 @@ from channels.db import database_sync_to_async +from chats.direct_reads import mark_direct_message_read, publish_direct_message_read from chats.models import DirectChat from chats.websockets_settings import ChatType, Event, EventType from django.core.cache import cache @@ -11,7 +12,6 @@ create_direct_message, delete_message, edit_message, - mark_direct_message_read, ) @@ -63,21 +63,13 @@ async def process_read_message_event(self, event: Event, room_name: str): user=self.user, author=other_user, ) - # send 2 events to user's channel - other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - event_data = { - "type": EventType.READ_MESSAGE, - "content": { - "chat_id": chat_id, - "chat_type": ChatType.DIRECT, - "user_id": self.user.id, - "message_id": message_id, - }, - "_direct_chat_id": chat_id, - } - await self.channel_layer.send(self.channel_name, event_data) - if other_user_channel and other_user_channel != self.channel_name: - await self.channel_layer.send(other_user_channel, event_data) + await publish_direct_message_read( + chat_id=chat_id, + message_id=message_id, + user=self.user, + author=other_user, + sender_channel=self.channel_name, + ) async def process_delete_message_event(self, event: Event, room_name: str): _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) diff --git a/chats/direct_reads.py b/chats/direct_reads.py new file mode 100644 index 00000000..80583cb6 --- /dev/null +++ b/chats/direct_reads.py @@ -0,0 +1,68 @@ +from channels.layers import get_channel_layer +from django.core.cache import cache +from django.db.models import Count, OuterRef, Subquery, Value +from django.db.models.functions import Coalesce + +from chats.exceptions import MessageValidationException +from chats.models import DirectChatMessage +from chats.utils import get_user_channel_cache_key +from chats.websockets_settings import ChatType, EventType + + +def unread_direct_messages(user_id): + return DirectChatMessage.objects.filter(is_read=False, is_deleted=False).exclude( + author_id=user_id + ) + + +def with_direct_chat_unreads(chats, user_id): + unread = unread_direct_messages(user_id).filter(chat_id=OuterRef("pk")) + totals = unread.order_by().values("chat_id").annotate(total=Count("pk")) + return chats.annotate( + unread_count=Coalesce(Subquery(totals.values("total")), Value(0)), + first_unread_message_id=Subquery( + unread.order_by("created_at", "pk").values("pk")[:1] + ), + ) + + +def mark_direct_message_read(*, chat_id, message_id, user, author): + if type(message_id) not in (str, int): + raise MessageValidationException("Некорректный идентификатор сообщения.") + try: + messages = DirectChatMessage.objects.filter( + pk=message_id, + chat_id=chat_id, + chat__users=user, + author=author, + is_deleted=False, + ).filter(chat__users=author) + if user.pk != author.pk and messages.update(is_read=True): + return int(message_id) + except (TypeError, ValueError): + pass + raise MessageValidationException("Сообщение недоступно для прочтения.") + + +async def publish_direct_message_read( + *, chat_id, message_id, user, author, sender_channel=None +): + layer = get_channel_layer() + if layer is None: + return + channels = { + sender_channel or cache.get(get_user_channel_cache_key(user)), + cache.get(get_user_channel_cache_key(author)), + } + event = { + "type": EventType.READ_MESSAGE, + "content": { + "chat_id": chat_id, + "chat_type": ChatType.DIRECT, + "user_id": user.pk, + "message_id": message_id, + }, + "_direct_chat_id": chat_id, + } + for channel in channels - {None}: + await layer.send(channel, event) diff --git a/chats/serializers.py b/chats/serializers.py index a35f252f..52b04e26 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -54,6 +54,8 @@ def serialize_message_attachments(message): class DirectChatListSerializer(serializers.ModelSerializer): has_draft = serializers.BooleanField(read_only=True) + unread_count = serializers.IntegerField(read_only=True) + first_unread_message_id = serializers.IntegerField(read_only=True, allow_null=True) last_message = serializers.SerializerMethodField() opponent = serializers.SerializerMethodField() name = serializers.SerializerMethodField(read_only=True) @@ -85,7 +87,16 @@ def get_last_message(cls, chat: DirectChat): class Meta: model = DirectChat - fields = ["id", "opponent", "last_message", "name", "image_address", "has_draft"] + fields = [ + "id", + "opponent", + "last_message", + "name", + "image_address", + "has_draft", + "unread_count", + "first_unread_message_id", + ] class ProgramChannelMessageSerializer(serializers.ModelSerializer): @@ -112,6 +123,8 @@ class Meta: class DirectChatDetailSerializer(serializers.ModelSerializer): opponent = serializers.SerializerMethodField() + unread_count = serializers.IntegerField(read_only=True) + first_unread_message_id = serializers.IntegerField(read_only=True, allow_null=True) def get_opponent(self, chat: DirectChat): user = self.context.get("opponent") @@ -124,6 +137,8 @@ class Meta: fields = [ "id", "opponent", + "unread_count", + "first_unread_message_id", ] diff --git a/chats/services/messages.py b/chats/services/messages.py index a0aded1a..b35ee91e 100644 --- a/chats/services/messages.py +++ b/chats/services/messages.py @@ -208,24 +208,6 @@ def get_direct_chat_for_typing(user, other_user): return get_direct_chat_for_participants(user, other_user) -def mark_direct_message_read(*, chat_id, message_id, user, author): - if type(message_id) not in (str, int): - raise MessageValidationException("Некорректный идентификатор сообщения.") - try: - messages = DirectChatMessage.objects.filter( - pk=message_id, - chat_id=chat_id, - chat__users=user, - author=author, - is_deleted=False, - ).filter(chat__users=author) - if user.pk != author.pk and messages.update(is_read=True): - return int(message_id) - except (TypeError, ValueError): - pass - raise MessageValidationException("Сообщение недоступно для прочтения.") - - @transaction.atomic def create_project_message( *, diff --git a/chats/tests/test_permissions.py b/chats/tests/test_permissions.py index f558b9bb..6b0867d2 100644 --- a/chats/tests/test_permissions.py +++ b/chats/tests/test_permissions.py @@ -81,24 +81,91 @@ def test_project_chat_detail_is_forbidden_for_outsider(self): def test_direct_chat_preview_and_unreads_use_visible_incoming_messages(self): chat = DirectChat.get_chat(self.leader, self.collaborator) chat.messages.create(author=self.leader, text="Свое сообщение") - unread = chat.messages.create(author=self.collaborator, text="Новое сообщение") + first = chat.messages.create( + author=self.collaborator, text="Первое непрочитанное" + ) + newest = chat.messages.create(author=self.collaborator, text="Новое сообщение") chat.messages.create( author=self.collaborator, text="Удаленное сообщение", is_deleted=True ) api = APIClient() api.force_authenticate(self.leader) listing_url = reverse("chats:direct-chat-list") + detail_url = reverse("chats:direct-chat-detail", args=[chat.pk]) unreads_url = reverse("chats:has-chat-unreads") - preview = api.get(listing_url, secure=True).json()[0]["last_message"] - self.assertEqual(preview["id"], unread.pk) + item = api.get(listing_url, secure=True).json()[0] + self.assertEqual(item["last_message"]["id"], newest.pk) + detail = api.get(detail_url, secure=True).json() + for summary in (item, detail): + self.assertEqual(summary["unread_count"], 2) + self.assertEqual(summary["first_unread_message_id"], first.pk) + context_url = reverse( + "chats:direct-chat-message-context", args=[chat.pk, first.pk] + ) + self.assertIn( + first.pk, + [item["id"] for item in api.get(context_url, secure=True).json()["results"]], + ) + first.refresh_from_db() + self.assertFalse(first.is_read) self.assertTrue(api.get(unreads_url, secure=True).json()["has_unreads"]) - unread.is_deleted = True - unread.save(update_fields=["is_deleted"]) - preview = api.get(listing_url, secure=True).json()[0]["last_message"] - self.assertEqual(preview["text"], "Свое сообщение") + first.is_read = True + first.save(update_fields=["is_read"]) + newest.is_deleted = True + newest.save(update_fields=["is_deleted"]) + item = api.get(listing_url, secure=True).json()[0] + self.assertEqual(item["last_message"]["id"], first.pk) + self.assertEqual(item["unread_count"], 0) + self.assertIsNone(item["first_unread_message_id"]) self.assertFalse(api.get(unreads_url, secure=True).json()["has_unreads"]) + def test_admin_opens_first_unread_and_marks_it_for_the_recipient(self): + chat = DirectChat.get_chat(self.staff, self.collaborator) + incoming = chat.messages.create(author=self.collaborator, text="Входящее") + own = chat.messages.create(author=self.staff, text="Исходящее") + self.client.force_login(self.staff) + route = "admin:chats_directchat_message_search" + search_url = reverse(route, args=[chat.pk]) + context_url = reverse(route + "_context", args=[chat.pk, incoming.pk]) + self.assertContains( + self.client.get(search_url, secure=True), "К первому непрочитанному" + ) + self.assertContains( + self.client.get(search_url, secure=True), context_url + "#selected-message" + ) + url = reverse(route + "_read", args=[chat.pk, incoming.pk]) + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.client.post(url, secure=True) + self.assertRedirects(response, context_url, fetch_redirect_response=False) + self.assertEqual(len(callbacks), 1) + incoming.refresh_from_db() + self.assertTrue(incoming.is_read) + own_url = reverse(route + "_read", args=[chat.pk, own.pk]) + self.assertEqual(self.client.post(own_url, secure=True).status_code, 404) + self.assertNotContains( + self.client.get(search_url, secure=True), "К первому непрочитанному" + ) + + def test_direct_and_project_history_are_read_only(self): + direct = DirectChat.get_chat(self.leader, self.collaborator) + api = APIClient() + api.force_authenticate(self.leader) + for route, chat in ( + ("direct-chat-messages", direct), + ("project-chat-messages", self.chat), + ): + with self.subTest(route=route): + url = reverse("chats:" + route, args=[chat.pk]) + history = api.get(url, secure=True) + self.assertEqual(history.status_code, 200) + self.assertIn("no-store", history["Cache-Control"]) + response = api.post( + url, {"text": "Сообщение"}, format="json", secure=True + ) + self.assertEqual(response.status_code, 405) + self.assertFalse(chat.messages.exists()) + @override_settings(RUNNING_TESTS=False) class ChatSocketAccessTests(TransactionTestCase): diff --git a/chats/views.py b/chats/views.py index 1a34bc28..3c7c6ead 100644 --- a/chats/views.py +++ b/chats/views.py @@ -8,7 +8,6 @@ from rest_framework.generics import ( GenericAPIView, ListAPIView, - ListCreateAPIView, RetrieveAPIView, ) from drf_yasg.utils import swagger_auto_schema @@ -26,12 +25,13 @@ from chats.exceptions import MessageValidationException from chats.drafts import with_draft_marker from chats.direct_privacy import direct_contact +from chats.direct_reads import unread_direct_messages, with_direct_chat_unreads from chats.message_context import ( MAX_CONTEXT_LIMIT, MessageContextForm, get_message_context, ) -from chats.models import DirectChat, DirectChatMessage, ProgramChannel, ProjectChat +from chats.models import DirectChat, ProgramChannel, ProjectChat from chats.pagination import MessageListPagination from chats.permissions import IsProjectChatMember, IsChatMember, accessible_chats from chats.program_channels import channel_unread_messages @@ -61,10 +61,9 @@ class DirectChatList(ListAPIView): permission_classes = [IsAuthenticated] def get_queryset(self): - return with_draft_marker( - DirectChat.objects.prefetch_related("users").filter(users=self.request.user), - self.request.user, - ) + user = self.request.user + chats = accessible_chats(user, DirectChat).prefetch_related("users") + return with_direct_chat_unreads(with_draft_marker(chats, user), user.pk) def get(self, request, *args, **kwargs): serialized_chats = [] @@ -134,7 +133,10 @@ def get(self, request, *args, **kwargs) -> Response: opponent = user1 context = {"opponent": opponent} chat = get_direct_chat_for_participants(user1, user2) - data = DirectChatDetailSerializer(chat, context=context).data + chat = with_direct_chat_unreads( + accessible_chats(request.user, DirectChat), request.user.pk + ).get(pk=chat.pk) + data = self.get_serializer(chat, context=context).data data["name"] = f"{opponent.first_name} {opponent.last_name}" data["image_address"] = opponent.avatar @@ -170,7 +172,8 @@ def get(self, request, *args, **kwargs) -> Response: return Response({"detail": str(error)}, status=status.HTTP_404_NOT_FOUND) -class ChatMessageHistory(ListCreateAPIView): +@method_decorator(never_cache, name="dispatch") +class ChatMessageHistory(ListAPIView): pagination_class = MessageListPagination @swagger_auto_schema( @@ -253,14 +256,6 @@ def get_queryset(self): messages, self.request.user.pk, include_reply=True ) - def post(self, request, *args, **kwargs): - # TODO: try to create a message in a chat. If chat doesn't exist, create it and then create a message. - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - @method_decorator(never_cache, name="dispatch") class ChatMessageContext(GenericAPIView): @@ -402,6 +397,7 @@ def get(self, request, id): ) +@method_decorator(never_cache, name="dispatch") class ChatFileList(ListAPIView): chat_model = None serializer_class = ChatAttachmentSerializer @@ -446,6 +442,7 @@ def get(self, request, id, message_id, attachment_id): return download_chat_attachment(request, chat, message_id, attachment_id) +@method_decorator(never_cache, name="dispatch") class HasChatUnreadsView(GenericAPIView): """Returns True if user has unread messages""" @@ -470,12 +467,8 @@ class HasChatUnreadsView(GenericAPIView): def get(self, request, *args, **kwargs): user = request.user has_direct_messages_unread = ( - DirectChatMessage.objects.filter( - chat_id__in=user.direct_chats.values("pk"), - is_read=False, - is_deleted=False, - ) - .exclude(author=user) + unread_direct_messages(user.pk) + .filter(chat_id__in=accessible_chats(user, DirectChat).values("pk")) .exists() ) has_project_messages_unread = ( diff --git a/docs/modules/chats.md b/docs/modules/chats.md index d0704fdf..d2a20844 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -18,10 +18,9 @@ Модуль рабочий и подключен в публичный API через `/chats/`, а WebSocket route подключен через ASGI routing на `/ws/chat/`. -Критичные WebSocket-flow покрыты тестами для личных и проектных чатов. При этом -модуль остается технически сложным: REST API и WebSocket-логика частично -дублируют ответственность, а часть старых views технически допускает `POST`, но -основной рабочий сценарий создания сообщений идет через WebSocket. +Личные и проектные сообщения отправляются через WebSocket. Публикации канала +создаются через REST API. Организаторы также могут отправлять сообщения в +проектные чаты и публиковать записи канала из Django-админки. ## Основные возможности @@ -85,6 +84,13 @@ - `GET /chats/projects//files/` - файлы из сообщений проектного чата. - `GET /chats/has-unreads/` - признак наличия непрочитанных сообщений. +Список и карточка каждого типа чата возвращают персональные `unread_count` +и `first_unread_message_id`. Если непрочитанных нет, ID равен `null`. +Чтобы открыть переписку у первого непрочитанного, используйте этот ID в +`GET /chats///messages//context/`. +Чтение истории и контекста не отмечает сообщения прочитанными. В личных чатах +для этого используется `message_read` или действие в Django-админке. + История сообщений использует `MessageListPagination`: - `limit`, по умолчанию `20`; @@ -189,8 +195,8 @@ WebSocket endpoint: /ws/chat/ ``` -Аутентификация выполняется через `TokenAuthMiddleware`. Клиент должен передать -Bearer token так же, как для REST API. +Браузер открывает соединение с подпротоколами `["Bearer", accessToken]`. +Сервер проверяет access token по тем же правилам, что и REST API. Формат входящего события: @@ -339,10 +345,9 @@ Backend: ### 4. Пользователь читает сообщение -`message_read` переводит `is_read=True`. - -Для личного чата прочитать можно только сообщение второго пользователя в своем -чате. Для проектного чата пользователь должен быть участником проекта. +В личном чате `message_read` отмечает входящее сообщение прочитанным. В +проектном чате сервер сохраняет прочтение для текущего участника. Автор видит +число прочитавших. ### 5. Пользователь редактирует или удаляет сообщение @@ -375,27 +380,18 @@ REST API фильтрует такие сообщения. `ChatConsumer`. - `core` - constants/cache helpers для online users. -## Ограничения и риски - -- `DirectChatMessageList` и `ProjectChatMessageList` технически наследуются от - `ListCreateAPIView`, но основной рабочий сценарий создания сообщений сейчас - WebSocket, а не REST `POST`. -- `DirectChatDetail` создает чат при `GET`, то есть чтение имеет side effect. -- `ProjectChatMessageList.get_queryset()` для несуществующего проектного чата - возвращает пустой список, а не 404. -- `DirectChatList.get()` молча пропускает чат, если у него некорректный состав - пользователей. -- `FileToMessage` допускает одновременную пустоту или неоднозначность - `direct_message` / `project_message` на уровне модели; это держится на - вызывающем коде. -- `match_files_and_messages()` прикрепляет `UserFile` по id без явной проверки, - что файл принадлежит текущему пользователю. -- `get_all_files()` собирает файлы Python-циклом по сообщениям и может быть - дорогим на больших историях. -- `DirectChat.get_avatar()` ожидает второго пользователя и может сломаться на - некорректном личном чате. -- `NotificationConsumer` существует как заготовка и не реализован. -- В `chats/managers.py` остался закомментированный legacy-код. +## Синхронизация и вложения + +- При повторе отправки клиент передает тот же `client_message_id`. Сервер + возвращает сохраненное сообщение с прежним ID. При новой отправке нужен + новый UUID. Событие WebSocket подтверждает сохранение на сервере. +- История личных и проектных чатов поддерживает тот же протокол `sync_cursor` + и `messages/changes/`, что и каналы. Курсор берется из нефильтрованной + истории. Пропущенные отметки прочтения нужно обновить из истории и счетчиков. +- Ответы истории и списка вложений содержат защищенный `link`. Клиент + отправляет авторизацию при скачивании. Доступ проверяется по актуальному + членству и видимости сообщения. Ранее выданные прямые ссылки на Selectel + контролируются настройками самого хранилища. ## Тесты diff --git a/templates/admin/chats/_message.html b/templates/admin/chats/_message.html index 5fe9ec0b..d13c39da 100644 --- a/templates/admin/chats/_message.html +++ b/templates/admin/chats/_message.html @@ -18,6 +18,12 @@

      Сообщение №{{ message.pk }} · {{ message.author.get_full_name }} {% endif %} {% if direct_privacy_url and message.author_id != request.user.pk %} + {% if direct_read_route and not message.is_read %} +
      + {% csrf_token %} + +
      + {% endif %}
      {% csrf_token %} diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html index e2e70393..6521a222 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -1,6 +1,12 @@ {% extends "admin/base_site.html" %} {% block content %} + {% if chat.first_unread_message_id %} +

      + Непрочитанных сообщений {{ chat.unread_count }}. + К первому непрочитанному +

      + {% endif %} {% if draft_url %}

      Личный черновик

      {% endif %} From 32f47dd781b6a837c49a6d6cb4c45abee2cf9d65 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:48:31 +0300 Subject: [PATCH 13/16] Verify chat attachment round trip across reconnects --- chats/tests/test_attachment_download.py | 137 +++++++++++++++++++++++- files/service.py | 3 +- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/chats/tests/test_attachment_download.py b/chats/tests/test_attachment_download.py index 9f672c72..b8e20ff0 100644 --- a/chats/tests/test_attachment_download.py +++ b/chats/tests/test_attachment_download.py @@ -1,8 +1,18 @@ from unittest.mock import Mock, patch +from uuid import uuid4 import requests +from channels.testing import HttpCommunicator, WebsocketCommunicator from django.contrib.auth import get_user_model -from django.test import AsyncRequestFactory, SimpleTestCase, TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import ( + AsyncClient, + AsyncRequestFactory, + SimpleTestCase, + TestCase, + TransactionTestCase, + override_settings, +) from django.urls import reverse from rest_framework.test import APIClient from rest_framework_simplejwt.tokens import AccessToken @@ -12,6 +22,7 @@ from chats.tests.test_project_reads import create_project_chat from files.downloads import download_stored_file from files.models import UserFile +from procollab.asgi import application from projects.models import Collaborator STORAGE = "https://storage.example.test/v1/account/media/" @@ -264,3 +275,127 @@ async def test_asgi_consumes_the_upstream_one_chunk_at_a_time( ) response.close() upstream.close.assert_called_once() + + +@override_settings(ALLOWED_HOSTS=["testserver"], SELECTEL_SWIFT_URL=STORAGE.rstrip("/")) +class ChatAttachmentRoundTripTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.author = users.create(**TEST_USER1, is_active=True) + self.reader = users.create(**TEST_USER2, is_active=True) + self.chat = create_project_chat(self.author, self.reader) + + async def send(self, token, payload): + socket = WebsocketCommunicator( + application, "/ws/chat/", subprotocols=["Bearer", token] + ) + self.assertEqual(await socket.connect(timeout=5), (True, "Bearer")) + try: + await socket.send_json_to(payload) + event = await socket.receive_json_from(timeout=5) + self.assertEqual(event["type"], "new_message") + return event["content"] + finally: + await socket.disconnect() + + async def test_upload_reconnect_and_download_keep_original_file(self): + author_token = str(AccessToken.for_user(self.author)) + reader_token = str(AccessToken.for_user(self.reader)) + reader = AsyncClient(authorization=f"Bearer {reader_token}") + history_url = reverse("chats:project-chat-messages", args=[self.chat.pk]) + history = await reader.get(history_url, secure=True) + self.assertEqual(history.status_code, 200) + cursor = history.json()["sync_cursor"] + original = bytes(range(256)) * 300 + uploaded = {} + + def store(url, *, data, **kwargs): + self.assertTrue(url.startswith(STORAGE)) + self.assertEqual(kwargs["headers"]["X-Auth-Token"], TOKEN) + uploaded[url] = data.read() + return Mock(status_code=201) + + def fetch(url, **kwargs): + self.assertEqual(kwargs["headers"]["X-Auth-Token"], TOKEN) + content = uploaded[url] + upstream = Mock( + status_code=200, headers={"Content-Length": str(len(content))} + ) + view = memoryview(content) + upstream.iter_content.side_effect = lambda chunk_size: ( + bytes(view[offset:][:chunk_size]) + for offset in range(0, len(content), chunk_size) + ) + return upstream + + with ( + patch( + "files.service.requests.post", + return_value=Mock(status_code=201, headers={"x-subject-token": TOKEN}), + ), + patch("files.service.requests.put", side_effect=store) as upload, + patch("files.downloads.requests.get", side_effect=fetch) as download, + ): + sender = AsyncClient(authorization=f"Bearer {author_token}") + response = await sender.post( + "/files/?preserve_original=true", + {"file": SimpleUploadedFile("План.bin", original)}, + secure=True, + ) + self.assertEqual(response.status_code, 201) + storage_url = response.json()["url"] + payload = { + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": self.chat.pk, + "client_message_id": str(uuid4()), + "file_urls": [storage_url], + }, + } + first = await self.send(author_token, payload) + replay = await self.send(author_token, payload) + self.assertFalse(first["is_replay"]) + self.assertTrue(replay["is_replay"]) + self.assertEqual(first["message"], replay["message"]) + + changes_url = reverse( + "chats:project-chat-message-changes", args=[self.chat.pk] + ) + changes = await reader.get(changes_url, {"after": cursor}, secure=True) + self.assertEqual(changes.status_code, 200) + (change,) = changes.json()["results"] + self.assertEqual(change["message_id"], first["message"]["id"]) + link = change["message"]["files"][0]["link"] + self.assertTrue(link.startswith("/chats/")) + download_request = HttpCommunicator( + application, + "GET", + link, + headers=[ + (b"host", b"testserver"), + (b"authorization", f"Bearer {reader_token}".encode()), + ], + ) + download_request.scope["scheme"] = "https" + await download_request.send_input({"type": "http.request", "body": b""}) + result = await download_request.receive_output(timeout=5) + self.assertEqual(result["type"], "http.response.start") + self.assertEqual(result["status"], 200) + headers = {name.lower(): value for name, value in result["headers"]} + chunks = [] + while True: + chunk = await download_request.receive_output(timeout=5) + self.assertEqual(chunk["type"], "http.response.body") + chunks.append(chunk.get("body", b"")) + if not chunk.get("more_body"): + break + await download_request.wait(timeout=5) + self.assertGreater(sum(bool(chunk) for chunk in chunks), 1) + self.assertEqual(b"".join(chunks), original) + self.assertEqual(headers[b"content-length"], str(len(original)).encode()) + self.assertIn(b"filename*=utf-8''", headers[b"content-disposition"]) + self.assertIn(b"no-store", headers[b"cache-control"]) + upload.assert_called_once() + download.assert_called_once() + self.assertEqual(await self.chat.messages.acount(), 1) diff --git a/files/service.py b/files/service.py index 9a379874..763650c4 100644 --- a/files/service.py +++ b/files/service.py @@ -13,7 +13,6 @@ from files.helpers import convert_image_to_webp from files.models import UserFile from files.typings import FileInfo -from procollab.settings import SELECTEL_SWIFT_URL User = get_user_model() @@ -109,7 +108,7 @@ def _generate_url(self, file: File, user: User) -> str: url: str looks like /hashedEmail/hashedFilename_hashedTime.extension """ return ( - f"{SELECTEL_SWIFT_URL}" + f"{settings.SELECTEL_SWIFT_URL.rstrip('/')}/" f"{abs(hash(user.email))}" f"/{abs(hash(file.name))}" f"_{abs(hash(time.time()))}" From 5b2e926840c9dd4475930e4d3c3a81cb5ac4c250 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:54:52 +0300 Subject: [PATCH 14/16] Protect published chat attachments from upload cleanup --- chats/admin.py | 18 +++++++ chats/attachments.py | 15 ++++++ chats/signals.py | 12 ++++- chats/tests/test_attachment_download.py | 62 +++++++++++++++++++++++++ chats/tests/test_program_channels.py | 13 ++++++ docs/modules/chats.md | 4 ++ files/admin.py | 29 +++++++----- files/views.py | 6 +++ 8 files changed, 146 insertions(+), 13 deletions(-) diff --git a/chats/admin.py b/chats/admin.py index 40f4c1a0..10766ff1 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -1032,9 +1032,27 @@ class FileToMessageAdmin(admin.ModelAdmin): "file", "direct_message", "project_message", + "channel_message", ) list_display_links = ( "file", "direct_message", "project_message", + "channel_message", ) + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset if request.user.is_superuser else queryset.none() + + def has_view_permission(self, request, obj=None): + return request.user.is_superuser + + 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 diff --git a/chats/attachments.py b/chats/attachments.py index e4dde1c5..e3302be9 100644 --- a/chats/attachments.py +++ b/chats/attachments.py @@ -1,3 +1,4 @@ +from django.db.models import Q from django.http import Http404 from django.urls import reverse @@ -30,6 +31,20 @@ def chat_attachments(chat): ProjectChat: "project_message", ProgramChannel: "channel_message", } +ATTACHMENT_IN_USE = "Файл прикреплен к опубликованному сообщению." + + +def used_by_visible_chat_message(file_id, *, using=None): + return ( + FileToMessage.objects.using(using) + .filter(file_id=file_id) + .filter( + Q(direct_message__is_deleted=False) + | Q(project_message__is_deleted=False) + | Q(channel_message__is_deleted=False) + ) + .exists() + ) def chat_attachment_field(chat): diff --git a/chats/signals.py b/chats/signals.py index 2dd1926e..f2d5f822 100644 --- a/chats/signals.py +++ b/chats/signals.py @@ -1,7 +1,10 @@ -from django.db.models.signals import post_save +from django.db.models.deletion import ProtectedError +from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver +from chats.attachments import ATTACHMENT_IN_USE, used_by_visible_chat_message from chats.models import ProgramChannel +from files.models import UserFile from partner_programs.models import PartnerProgram @@ -9,3 +12,10 @@ def create_program_channel(sender, instance, created, raw, using, **kwargs): if created and not raw: ProgramChannel.objects.using(using).get_or_create(program=instance) + + +@receiver(pre_delete, sender=UserFile, dispatch_uid="protect_visible_chat_attachment") +def protect_visible_chat_attachment(sender, instance, using, **kwargs): + sender.objects.using(using).select_for_update().filter(pk=instance.pk).first() + if used_by_visible_chat_message(instance.pk, using=using): + raise ProtectedError(ATTACHMENT_IN_USE, [instance]) diff --git a/chats/tests/test_attachment_download.py b/chats/tests/test_attachment_download.py index b8e20ff0..ed39ddbc 100644 --- a/chats/tests/test_attachment_download.py +++ b/chats/tests/test_attachment_download.py @@ -4,7 +4,11 @@ import requests from channels.testing import HttpCommunicator, WebsocketCommunicator from django.contrib.auth import get_user_model +from django.contrib.admin import AdminSite +from django.core.exceptions import PermissionDenied from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import transaction +from django.db.models.deletion import ProtectedError from django.test import ( AsyncClient, AsyncRequestFactory, @@ -14,6 +18,7 @@ override_settings, ) from django.urls import reverse +from django.test import RequestFactory from rest_framework.test import APIClient from rest_framework_simplejwt.tokens import AccessToken @@ -21,7 +26,9 @@ from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 from chats.tests.test_project_reads import create_project_chat from files.downloads import download_stored_file +from files.admin import UserFileAdmin from files.models import UserFile +from files.views import FileView from procollab.asgi import application from projects.models import Collaborator @@ -112,6 +119,61 @@ def test_history_and_file_list_return_the_same_protected_link(self): self.assertEqual(files[0]["name"], "План проекта") self.assertNotIn(STORAGE, str(history) + str(files)) + def test_published_attachment_is_retained_until_message_is_deleted(self): + self.api.force_authenticate(self.author) + for attachment, message in ( + (self.direct_attachment, self.direct_message), + (self.project_attachment, self.project_message), + ): + with self.subTest(message=message): + query = {"QUERY_STRING": "link=" + attachment.file_id} + with patch.object(FileView.cdn, "delete") as storage_delete: + response = self.api.delete("/files/", secure=True, **query) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["code"], "chat_attachment_in_use") + storage_delete.assert_not_called() + with self.assertRaises(ProtectedError), transaction.atomic(): + UserFile.objects.filter(pk=attachment.file_id).delete() + self.assertTrue( + FileToMessage.objects.filter(pk=attachment.pk).exists() + ) + + message.is_deleted = True + message.save(update_fields=["is_deleted"]) + self.assertEqual( + self.api.delete("/files/", secure=True, **query).status_code, 204 + ) + storage_delete.assert_called_once_with(attachment.file_id) + self.assertFalse(UserFile.objects.filter(pk=attachment.file_id).exists()) + + def test_admin_bulk_delete_checks_every_file_before_contacting_storage(self): + unused = UserFile.objects.create(link=STORAGE + "unused.pdf", user=self.author) + files = UserFile.objects.filter( + pk__in=[unused.pk, self.project_attachment.file_id] + ) + request = RequestFactory().post("/admin/files/userfile/") + request.user = self.member + file_admin = UserFileAdmin(UserFile, AdminSite()) + with patch.object(file_admin.cdn, "delete") as storage_delete: + with self.assertRaises(PermissionDenied): + file_admin.delete_queryset(request, files) + storage_delete.assert_not_called() + self.assertEqual(files.count(), 2) + + def test_attachment_link_admin_is_read_only(self): + self.client.force_login(self.member) + route = "admin:chats_filetomessage" + self.assertEqual( + self.client.get(reverse(route + "_changelist"), secure=True).status_code, 200 + ) + url = reverse(route + "_delete", args=[self.project_attachment.pk]) + self.assertEqual( + self.client.post(url, {"post": "yes"}, secure=True).status_code, 403 + ) + self.assertTrue( + FileToMessage.objects.filter(pk=self.project_attachment.pk).exists() + ) + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) @patch("files.downloads.requests.get") def test_members_can_download_through_api_and_admin(self, storage_get, _token): diff --git a/chats/tests/test_program_channels.py b/chats/tests/test_program_channels.py index 300185cb..da888052 100644 --- a/chats/tests/test_program_channels.py +++ b/chats/tests/test_program_channels.py @@ -219,6 +219,19 @@ def test_program_return_to_draft_hides_channel_without_losing_history(self): PartnerProgram.objects.filter(pk=self.program.pk).update(draft=False) self.assertEqual(self.api.get(self.history, secure=True).json()["count"], 1) + def test_uploaded_file_cannot_be_removed_from_a_visible_publication(self): + published = self.publish(file_urls=[self.file.pk]) + self.assertEqual(published.status_code, 201) + with patch("files.views.FileView.cdn.delete") as storage_delete: + response = self.api.delete( + "/files/", QUERY_STRING="link=" + self.file.pk, secure=True + ) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["code"], "chat_attachment_in_use") + storage_delete.assert_not_called() + history = self.api.get(self.history, secure=True).json() + self.assertEqual(history["results"][0]["files"], published.json()["files"]) + @patch("files.service.requests.put") @patch("files.service.SelectelSwiftStorage._get_auth_token", return_value="test") def test_failed_storage_upload_does_not_create_an_attachment(self, _token, put): diff --git a/docs/modules/chats.md b/docs/modules/chats.md index d2a20844..b8fdc2fb 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -392,6 +392,10 @@ REST API фильтрует такие сообщения. отправляет авторизацию при скачивании. Доступ проверяется по актуальному членству и видимости сообщения. Ранее выданные прямые ссылки на Selectel контролируются настройками самого хранилища. +- Если файл уже прикреплен к видимому сообщению или публикации, удаление + загруженного файла через `DELETE /files/` возвращает `409` с кодом + `chat_attachment_in_use`. Очистка черновика или очереди после отправки + сохраняет опубликованное вложение. ## Тесты diff --git a/files/admin.py b/files/admin.py index 39e14de1..65037486 100644 --- a/files/admin.py +++ b/files/admin.py @@ -6,8 +6,18 @@ from django.forms import ModelForm, FileField from django.db.models import QuerySet +from chats.attachments import ATTACHMENT_IN_USE, used_by_visible_chat_message from files.service import CDN, SelectelSwiftStorage from files.models import UserFile +from partner_programs.solution_files import used_by_submitted_solution + + +def file_deletion_error(file): + if used_by_submitted_solution(file.pk): + return "Файл используется в сохраненном решении." + if used_by_visible_chat_message(file.pk): + return ATTACHMENT_IN_USE + return None class UserFileForm(ModelForm): @@ -24,14 +34,12 @@ class UserFileAdmin(admin.ModelAdmin): readonly_fields = ("detected_mime_type",) def get_deleted_objects(self, objs, request): - from partner_programs.solution_files import used_by_submitted_solution - deleted, counts, permissions_needed, protected = super().get_deleted_objects( objs, request ) for obj in objs: - if used_by_submitted_solution(obj.pk): - protected.append(f"Файл сохраненного решения: {obj}") + if error := file_deletion_error(obj): + protected.append(f"{obj} — {error}") return deleted, counts, permissions_needed, protected list_display = ( @@ -92,21 +100,18 @@ def save_model(self, request, obj, form, change): @transaction.atomic def delete_model(self, request, obj): - from partner_programs.solution_files import used_by_submitted_solution - obj = UserFile.objects.select_for_update().get(pk=obj.pk) - if used_by_submitted_solution(obj.pk): - raise PermissionDenied("Файл используется в сохраненном решении.") + if error := file_deletion_error(obj): + raise PermissionDenied(error) self.cdn.delete(obj.link) obj.delete() @transaction.atomic def delete_queryset(self, request, queryset): - from partner_programs.solution_files import used_by_submitted_solution - rows = list(queryset.select_for_update().order_by("pk")) - if any(used_by_submitted_solution(obj.pk) for obj in rows): - raise PermissionDenied("Файл используется в сохраненном решении.") + for obj in rows: + if error := file_deletion_error(obj): + raise PermissionDenied(error) for obj in rows: self.cdn.delete(obj.link) queryset.delete() diff --git a/files/views.py b/files/views.py index 036609e7..08b58e67 100644 --- a/files/views.py +++ b/files/views.py @@ -5,6 +5,7 @@ from rest_framework.generics import get_object_or_404 from rest_framework.response import Response +from chats.attachments import ATTACHMENT_IN_USE, used_by_visible_chat_message from files.exceptions import SelectelUploadError from files.service import CDN, SelectelSwiftStorage, create_user_upload from files.models import UserFile @@ -84,6 +85,11 @@ def delete(self, request, *args, **kwargs): protect_solution_file(instance) except SolutionError as error: return Response(error.payload, status=error.status_code) + if used_by_visible_chat_message(instance.pk): + return Response( + {"code": "chat_attachment_in_use", "detail": ATTACHMENT_IN_USE}, + status=status.HTTP_409_CONFLICT, + ) self.cdn.delete(instance.link) # delete the file via api instance.delete() # delete the UserFile object return Response(status=status.HTTP_204_NO_CONTENT) From 8c4983cc69bd2d8defdcb05c5fea72e6b55df3ab Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 23:05:51 +0300 Subject: [PATCH 15/16] Preserve chat attachments during project cover cleanup --- docs/modules/chats.md | 22 +++++++++++++++++++--- projects/cover_reset.py | 2 ++ projects/tests/test_project_cover_reset.py | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/modules/chats.md b/docs/modules/chats.md index b8fdc2fb..d970995e 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -390,12 +390,28 @@ REST API фильтрует такие сообщения. истории. Пропущенные отметки прочтения нужно обновить из истории и счетчиков. - Ответы истории и списка вложений содержат защищенный `link`. Клиент отправляет авторизацию при скачивании. Доступ проверяется по актуальному - членству и видимости сообщения. Ранее выданные прямые ссылки на Selectel - контролируются настройками самого хранилища. + членству и видимости сообщения. В браузере загрузку надо выполнить через + авторизованный HTTP-клиент, который получает файл как `Blob` и передает его + на сохранение. Переход по обычному `href` не передает JWT. - Если файл уже прикреплен к видимому сообщению или публикации, удаление загруженного файла через `DELETE /files/` возвращает `409` с кодом `chat_attachment_in_use`. Очистка черновика или очереди после отправки - сохраняет опубликованное вложение. + сохраняет опубликованное вложение. Сброс обложки проекта также сохраняет + файл, если он прикреплен к видимому сообщению. + +Для приемки на dev загрузите контрольный файл через +`POST /files/?preserve_original=true`, отправьте его в тестовый проектный чат, +повторите отправку с тем же `client_message_id` после переподключения и +сравните ID сообщения. Участник скачивает вложение по `link` из истории. +Сравните байты скачанного файла с оригиналом, затем удалите тестового +участника из проекта и проверьте ответ `404` по тому же `link`. + +Отдельно проверьте в Selectel тип контейнера с вложениями и настройки его +публичных доменов. На собственном контрольном объекте проверьте, что без +авторизации Selectel не выдает содержимое. Прямые ссылки на старые объекты +регулируются настройками хранилища. Если общий контейнер обслуживает +публичные обложки или другие общедоступные файлы, закрывать его целиком +нельзя без проверки этих сценариев. ## Тесты diff --git a/projects/cover_reset.py b/projects/cover_reset.py index c8d6eaa4..4bf1d6fc 100644 --- a/projects/cover_reset.py +++ b/projects/cover_reset.py @@ -7,6 +7,7 @@ from django.db.models import Q from rest_framework.exceptions import APIException +from chats.attachments import used_by_visible_chat_message from files.models import UserFile from files.service import CDN, SelectelSwiftStorage from projects.models import DefaultProjectAvatar, DefaultProjectCover, Project @@ -69,6 +70,7 @@ def cleanup_previous_cover(old_url: str | None, user_id: int) -> None: if ( DefaultProjectCover.objects.filter(image_id=old_url).exists() or DefaultProjectAvatar.objects.filter(image_id=old_url).exists() + or used_by_visible_chat_message(old_url) or Project.objects.filter( Q(cover_image_address=old_url) | Q(image_address=old_url) diff --git a/projects/tests/test_project_cover_reset.py b/projects/tests/test_project_cover_reset.py index 128df9e8..b3a3be59 100644 --- a/projects/tests/test_project_cover_reset.py +++ b/projects/tests/test_project_cover_reset.py @@ -7,6 +7,7 @@ from django.test.utils import CaptureQueriesContext from rest_framework.test import APIClient +from chats.models import FileToMessage, ProjectChat, ProjectChatMessage from files.models import UserFile from invites.models import Invite from project_rates.tests.helpers import create_rate_expert @@ -121,6 +122,21 @@ def test_file_referenced_by_another_project_is_preserved(self): self.cdn.delete.assert_not_called() self.assertTrue(UserFile.objects.filter(pk=self.custom.pk).exists()) + def test_file_attached_to_a_chat_message_is_preserved(self): + chat, _ = ProjectChat.objects.get_or_create(project=self.project) + message = ProjectChatMessage.objects.create( + chat=chat, author=self.leader, text="Файл проекта" + ) + attachment = FileToMessage.objects.create( + file=self.custom, project_message=message + ) + + self.assertEqual(self.reset().status_code, 200) + self.project.refresh_from_db() + self.assertEqual(self.project.cover_image_address, self.default.link) + self.cdn.delete.assert_not_called() + self.assertTrue(FileToMessage.objects.filter(pk=attachment.pk).exists()) + def test_cleanup_network_failure_keeps_success_and_does_not_log_exception(self): self.cdn.delete.side_effect = RuntimeError("private-url-or-token") with self.assertLogs("projects.cover_reset", level="WARNING") as logs: From 5ac88ed213cc3284c743eb4052b7f248a7cd1cb2 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 23:32:26 +0300 Subject: [PATCH 16/16] Keep file records when storage deletion fails --- chats/program_channels.py | 2 +- chats/tests/test_attachment_download.py | 2 +- docs/modules/chats.md | 3 + files/admin.py | 45 +++++++++- files/downloads.py | 29 +------ files/exceptions.py | 4 + files/service.py | 44 +++++++++- files/tests.py | 104 ++++++++++++++++++++++++ files/views.py | 12 ++- 9 files changed, 206 insertions(+), 39 deletions(-) diff --git a/chats/program_channels.py b/chats/program_channels.py index 2bd7d970..006b939a 100644 --- a/chats/program_channels.py +++ b/chats/program_channels.py @@ -8,7 +8,7 @@ from chats.permissions import accessible_chats, can_publish_in_program_channel from chats.serializers import ProgramChannelMessageSerializer from chats.services.messages import _create_message -from files.downloads import is_storage_url +from files.service import is_storage_url MAX_CHANNEL_ATTACHMENTS = 20 diff --git a/chats/tests/test_attachment_download.py b/chats/tests/test_attachment_download.py index ed39ddbc..eea860fc 100644 --- a/chats/tests/test_attachment_download.py +++ b/chats/tests/test_attachment_download.py @@ -127,7 +127,7 @@ def test_published_attachment_is_retained_until_message_is_deleted(self): ): with self.subTest(message=message): query = {"QUERY_STRING": "link=" + attachment.file_id} - with patch.object(FileView.cdn, "delete") as storage_delete: + with patch.object(FileView.cdn.storage, "delete") as storage_delete: response = self.api.delete("/files/", secure=True, **query) self.assertEqual(response.status_code, 409) self.assertEqual(response.json()["code"], "chat_attachment_in_use") diff --git a/docs/modules/chats.md b/docs/modules/chats.md index d970995e..a5f15219 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -398,6 +398,9 @@ REST API фильтрует такие сообщения. `chat_attachment_in_use`. Очистка черновика или очереди после отправки сохраняет опубликованное вложение. Сброс обложки проекта также сохраняет файл, если он прикреплен к видимому сообщению. +- Если хранилище не подтвердило удаление, `DELETE /files/` возвращает `502` + с кодом `file_delete_failed`. Запись файла сохраняется для повтора. Если + хранилище уже удалило объект и отвечает `404`, API завершает очистку с `204`. Для приемки на dev загрузите контрольный файл через `POST /files/?preserve_original=true`, отправьте его в тестовый проектный чат, diff --git a/files/admin.py b/files/admin.py index 65037486..7956bb5c 100644 --- a/files/admin.py +++ b/files/admin.py @@ -1,12 +1,15 @@ import reprlib -from django.contrib import admin +from django.contrib import admin, messages +from django.contrib.admin.actions import delete_selected from django.db import transaction from django.core.exceptions import PermissionDenied from django.forms import ModelForm, FileField from django.db.models import QuerySet +from django.shortcuts import redirect from chats.attachments import ATTACHMENT_IN_USE, used_by_visible_chat_message +from files.exceptions import SelectelDeleteError from files.service import CDN, SelectelSwiftStorage from files.models import UserFile from partner_programs.solution_files import used_by_submitted_solution @@ -32,6 +35,7 @@ class Meta: class UserFileAdmin(admin.ModelAdmin): form = UserFileForm readonly_fields = ("detected_mime_type",) + actions = ("delete_selected",) def get_deleted_objects(self, objs, request): deleted, counts, permissions_needed, protected = super().get_deleted_objects( @@ -98,6 +102,13 @@ def save_model(self, request, obj, form, change): obj.detected_mime_type = info.detected_mime_type super().save_model(request, obj, form, change) + def delete_view(self, request, object_id, extra_context=None): + try: + return super().delete_view(request, object_id, extra_context) + except SelectelDeleteError as error: + self.message_user(request, str(error), level=messages.ERROR) + return redirect(request.get_full_path()) + @transaction.atomic def delete_model(self, request, obj): obj = UserFile.objects.select_for_update().get(pk=obj.pk) @@ -112,9 +123,37 @@ def delete_queryset(self, request, queryset): for obj in rows: if error := file_deletion_error(obj): raise PermissionDenied(error) + deleted = [] for obj in rows: - self.cdn.delete(obj.link) - queryset.delete() + try: + self.cdn.delete(obj.link) + except SelectelDeleteError: + continue + deleted.append(obj) + if deleted: + self.log_deletions(request, deleted) + queryset.filter(pk__in=[obj.pk for obj in deleted]).delete() + return len(deleted), len(rows) - len(deleted) + + @admin.action(permissions=["delete"], description="Удалить выбранные файлы") + def delete_selected(self, request, queryset): + if not request.POST.get("post"): + return delete_selected(self, request, queryset) + _, _, permissions_needed, protected = self.get_deleted_objects(queryset, request) + if protected: + return delete_selected(self, request, queryset) + if permissions_needed: + raise PermissionDenied + deleted, failed = self.delete_queryset(request, queryset) + if deleted: + self.message_user(request, f"Удалено файлов: {deleted}.", messages.SUCCESS) + if failed: + self.message_user( + request, + f"Не удалось удалить файлов: {failed}. Повторите попытку.", + messages.ERROR, + ) + return None def get_queryset(self, request) -> QuerySet[UserFile]: qs = super().get_queryset(request) diff --git a/files/downloads.py b/files/downloads.py index 8c526990..24fde90b 100644 --- a/files/downloads.py +++ b/files/downloads.py @@ -1,38 +1,11 @@ -from urllib.parse import unquote, urlsplit - import requests from asgiref.sync import sync_to_async -from django.conf import settings from django.core.handlers.asgi import ASGIRequest from django.http import Http404, HttpResponse, StreamingHttpResponse from django.utils.http import content_disposition_header from files.exceptions import SelectelUploadError -from files.service import SelectelSwiftStorage - - -def is_storage_url(link): - try: - base = urlsplit(settings.SELECTEL_SWIFT_URL) - target = urlsplit(link) - path = unquote(target.path) - prefix = unquote(base.path).rstrip("/") + "/" - return ( - target.scheme == base.scheme == "https" - and target.netloc.lower() == base.netloc.lower() - and not target.username - and not target.password - and not target.query - and not target.fragment - and path.startswith(prefix) - and len(path) > len(prefix) - and "\\" not in path - and "%" not in path - and not any(ord(char) < 32 or ord(char) == 127 for char in path) - and all(segment not in ("", ".", "..") for segment in path[1:].split("/")) - ) - except ValueError: - return False +from files.service import SelectelSwiftStorage, is_storage_url def download_filename(file): diff --git a/files/exceptions.py b/files/exceptions.py index 0d8778ba..7ecf991a 100644 --- a/files/exceptions.py +++ b/files/exceptions.py @@ -1,2 +1,6 @@ class SelectelUploadError(Exception): pass + + +class SelectelDeleteError(Exception): + pass diff --git a/files/service.py b/files/service.py index 763650c4..ef36ae96 100644 --- a/files/service.py +++ b/files/service.py @@ -1,5 +1,6 @@ import time from abc import ABC, abstractmethod +from urllib.parse import unquote, urlsplit import requests from django.conf import settings @@ -9,7 +10,7 @@ from files.constants import SUPPORTED_IMAGES_TYPES from files.content import detect_content_type -from files.exceptions import SelectelUploadError +from files.exceptions import SelectelDeleteError, SelectelUploadError from files.helpers import convert_image_to_webp from files.models import UserFile from files.typings import FileInfo @@ -17,6 +18,30 @@ User = get_user_model() +def is_storage_url(link): + try: + base = urlsplit(settings.SELECTEL_SWIFT_URL) + target = urlsplit(link) + path = unquote(target.path) + prefix = unquote(base.path).rstrip("/") + "/" + return ( + target.scheme == base.scheme == "https" + and target.netloc.lower() == base.netloc.lower() + and not target.username + and not target.password + and not target.query + and not target.fragment + and path.startswith(prefix) + and len(path) > len(prefix) + and "\\" not in path + and "%" not in path + and not any(ord(char) < 32 or ord(char) == 127 for char in path) + and all(segment not in ("", ".", "..") for segment in path[1:].split("/")) + ) + except ValueError: + return False + + class File: def __init__( self, @@ -68,8 +93,21 @@ def upload(self, file: File, user: User) -> FileInfo: class SelectelSwiftStorage(Storage): def delete(self, url: str) -> Response: - token = self._get_auth_token() - return requests.delete(url, headers={"X-Auth-Token": token}) + error_message = "Не удалось удалить файл из хранилища." + if not is_storage_url(url): + raise SelectelDeleteError(error_message) + try: + response = requests.delete( + url, + headers={"X-Auth-Token": self._get_auth_token()}, + timeout=(5, 15), + allow_redirects=False, + ) + except (requests.RequestException, SelectelUploadError, KeyError) as error: + raise SelectelDeleteError(error_message) from error + if not (200 <= response.status_code < 300 or response.status_code == 404): + raise SelectelDeleteError(error_message) + return response def upload(self, file: File, user: User) -> FileInfo: url = self._upload(file, user) diff --git a/files/tests.py b/files/tests.py index e69de29b..52deb2ec 100644 --- a/files/tests.py +++ b/files/tests.py @@ -0,0 +1,104 @@ +from unittest.mock import Mock, patch +from urllib.parse import urlencode + +import requests +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME +from django.contrib.admin.models import DELETION, LogEntry +from django.contrib.admin.utils import quote +from django.test import TestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from files.models import UserFile +from projects.tests.helpers import create_staff_user + +STORAGE = "https://storage.example.test/v1/account/media/" + + +@override_settings(ALLOWED_HOSTS=["testserver"], SELECTEL_SWIFT_URL=STORAGE) +class FileDeletionTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = create_staff_user(prefix="file-deletion") + cls.file = UserFile.objects.create(link=STORAGE + "a.pdf", user=cls.user) + + @patch("files.service.SelectelSwiftStorage._get_auth_token", return_value="test") + @patch("files.service.requests.delete") + def test_api_keeps_upload_until_storage_confirms_delete(self, delete, _token): + api = APIClient() + api.force_authenticate(self.user) + url = "/files/?" + urlencode({"link": self.file.pk}) + delete.side_effect = [ + requests.Timeout(), + Mock(status_code=302), + Mock(status_code=503), + Mock(status_code=404), + ] + + for _ in range(3): + response = api.delete(url, secure=True) + self.assertEqual(response.status_code, 502) + self.assertEqual(response.json()["code"], "file_delete_failed") + self.assertTrue(UserFile.objects.filter(pk=self.file.pk).exists()) + self.assertEqual(api.delete(url, secure=True).status_code, 204) + self.assertFalse(UserFile.objects.filter(pk=self.file.pk).exists()) + delete.assert_called_with( + self.file.pk, + headers={"X-Auth-Token": "test"}, + timeout=(5, 15), + allow_redirects=False, + ) + + @patch("files.service.SelectelSwiftStorage._get_auth_token") + @patch("files.service.requests.delete") + def test_api_does_not_send_credentials_for_unmanaged_upload(self, delete, token): + file = UserFile.objects.create( + link="https://external.example.test/a.pdf", user=self.user + ) + api = APIClient() + api.force_authenticate(self.user) + response = api.delete("/files/?" + urlencode({"link": file.pk}), secure=True) + self.assertEqual(response.status_code, 502) + self.assertTrue(UserFile.objects.filter(pk=file.pk).exists()) + token.assert_not_called() + delete.assert_not_called() + + @patch("files.service.SelectelSwiftStorage._get_auth_token", return_value="test") + @patch("files.service.requests.delete", return_value=Mock(status_code=503)) + def test_admin_reports_failure_and_preserves_file(self, delete, _token): + self.client.force_login(self.user) + url = reverse("admin:files_userfile_delete", args=[quote(self.file.pk)]) + response = self.client.post(url, {"post": "yes"}, secure=True, follow=True) + self.assertContains(response, "Не удалось удалить файл из хранилища.") + self.assertTrue(UserFile.objects.filter(pk=self.file.pk).exists()) + self.assertFalse(LogEntry.objects.filter(action_flag=DELETION).exists()) + delete.assert_called_once() + + @patch("files.service.SelectelSwiftStorage._get_auth_token", return_value="test") + @patch("files.service.requests.delete") + def test_admin_bulk_records_only_confirmed_deletions(self, delete, _token): + second = UserFile.objects.create(link=STORAGE + "b.pdf", user=self.user) + delete.side_effect = [Mock(status_code=204), Mock(status_code=503)] + self.client.force_login(self.user) + response = self.client.post( + reverse("admin:files_userfile_changelist"), + { + "action": "delete_selected", + "post": "yes", + ACTION_CHECKBOX_NAME: [self.file.pk, second.pk], + }, + secure=True, + follow=True, + ) + self.assertContains(response, "Удалено файлов: 1.") + self.assertContains(response, "Не удалось удалить файлов: 1.") + self.assertFalse(UserFile.objects.filter(pk=self.file.pk).exists()) + self.assertTrue(UserFile.objects.filter(pk=second.pk).exists()) + self.assertEqual( + list( + LogEntry.objects.filter(action_flag=DELETION).values_list( + "object_id", flat=True + ) + ), + [self.file.pk], + ) diff --git a/files/views.py b/files/views.py index 08b58e67..3b506ccc 100644 --- a/files/views.py +++ b/files/views.py @@ -6,7 +6,7 @@ from rest_framework.response import Response from chats.attachments import ATTACHMENT_IN_USE, used_by_visible_chat_message -from files.exceptions import SelectelUploadError +from files.exceptions import SelectelDeleteError, SelectelUploadError from files.service import CDN, SelectelSwiftStorage, create_user_upload from files.models import UserFile from files.serializers import UserFileSerializer @@ -90,6 +90,12 @@ def delete(self, request, *args, **kwargs): {"code": "chat_attachment_in_use", "detail": ATTACHMENT_IN_USE}, status=status.HTTP_409_CONFLICT, ) - self.cdn.delete(instance.link) # delete the file via api - instance.delete() # delete the UserFile object + try: + self.cdn.delete(instance.link) + except SelectelDeleteError as error: + return Response( + {"code": "file_delete_failed", "detail": str(error)}, + status=status.HTTP_502_BAD_GATEWAY, + ) + instance.delete() return Response(status=status.HTTP_204_NO_CONTENT)