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