From 3e6efbc6adcd94f7a786e9189cc0dd8c289b3519 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 21:35:33 +0300 Subject: [PATCH] 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 %}