From 162fb8652e95e3eb96a508ee51b5c5b71cdd3e17 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 20:53:17 +0300 Subject: [PATCH] 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 %} +

Вложения:

+ + {% 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):