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}"