Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions chats/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<int:message_id>/context/",
self.admin_site.admin_view(require_safe(self.message_context)),
name=self.message_search_url_name + "_context",
),
path(
object_path
+ "/messages/<int:message_id>/files/<slug:attachment_id>/download/",
Expand Down Expand Up @@ -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",
Expand All @@ -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):
Expand Down
98 changes: 98 additions & 0 deletions chats/message_context.py
Original file line number Diff line number Diff line change
@@ -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
)
127 changes: 127 additions & 0 deletions chats/tests/test_message_context.py
Original file line number Diff line number Diff line change
@@ -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)
19 changes: 19 additions & 0 deletions chats/urls.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -29,6 +34,13 @@
DirectChatFileList.as_view(),
name="direct-chat-files",
),
path(
"directs/<slug:id>/messages/<int:message_id>/context/",
ChatMessageContext.as_view(
chat_model=DirectChat, serializer_class=DirectChatMessageListSerializer
),
name="direct-chat-message-context",
),
path(
"directs/<slug:id>/messages/<int:message_id>/files/<slug:attachment_id>/download/",
ChatAttachmentDownload.as_view(chat_model=DirectChat),
Expand All @@ -46,6 +58,13 @@
ProjectChatFileList.as_view(),
name="project-chat-files",
),
path(
"projects/<int:id>/messages/<int:message_id>/context/",
ChatMessageContext.as_view(
chat_model=ProjectChat, serializer_class=ProjectChatMessageListSerializer
),
name="project-chat-message-context",
),
path(
"projects/<int:id>/messages/<int:message_id>/files/<slug:attachment_id>/download/",
ChatAttachmentDownload.as_view(chat_model=ProjectChat),
Expand Down
Loading
Loading