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 %}
+
+ {% if page %}
+ Найдено сообщений: {{ page.paginator.count }}
+ {% for message in page %}
+
+ Сообщение №{{ message.pk }} · {{ message.author.get_full_name }}
+ {{ message.created_at|date:"d.m.Y H:i" }}
+ {{ message.text|linebreaksbr }}
+ {% if message.file_to_message.all %}
+ Вложения:
+
+ {% for attachment in message.file_to_message.all %}
+ {{ attachment.file.name }}{% if attachment.file.extension %}.{{ attachment.file.extension }}{% endif %}
+ {% endfor %}
+
+ {% endif %}
+
+ {% empty %}
+ Сообщения не найдены.
+ {% endfor %}
+ {% if page.has_other_pages %}
+
+ {% if page.has_previous %}
+ Назад
+ {% endif %}
+ Страница {{ page.number }} из {{ page.paginator.num_pages }}
+ {% if page.has_next %}
+ Далее
+ {% endif %}
+
+ {% 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):