From ccc3620ac789afbb2d5b382eb20669a115312a21 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:15:01 +0300 Subject: [PATCH] Add direct chat privacy and spam reports --- chats/admin.py | 189 +++++++++++++++++++ chats/consumers/chat.py | 32 +++- chats/direct_privacy.py | 131 +++++++++++++ chats/direct_views.py | 95 ++++++++++ chats/migrations/0016_direct_chat_privacy.py | 156 +++++++++++++++ chats/models.py | 56 ++++++ chats/services/messages.py | 22 +++ chats/tests/test_direct_privacy.py | 165 ++++++++++++++++ chats/urls.py | 8 + chats/views.py | 5 + docs/modules/chats.md | 21 +++ templates/admin/chats/_message.html | 7 + templates/admin/chats/direct_chat_list.html | 6 + templates/admin/chats/direct_privacy.html | 27 +++ templates/admin/chats/message_search.html | 3 + 15 files changed, 914 insertions(+), 9 deletions(-) create mode 100644 chats/direct_privacy.py create mode 100644 chats/direct_views.py create mode 100644 chats/migrations/0016_direct_chat_privacy.py create mode 100644 chats/tests/test_direct_privacy.py create mode 100644 templates/admin/chats/direct_chat_list.html create mode 100644 templates/admin/chats/direct_privacy.html diff --git a/chats/admin.py b/chats/admin.py index 077b539d..59c127cd 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -16,8 +16,17 @@ from django.urls import path, reverse from django.utils.html import format_html from django.views.decorators.http import require_http_methods, require_safe +from django.utils import timezone from chats.attachments import download_chat_attachment, get_accessible_chat +from chats.direct_privacy import ( + direct_chat_opponent, + direct_contact, + get_incoming_policy, + report_direct_spam, + set_block, + set_incoming_policy, +) from chats.exceptions import MessageValidationException from chats.message_context import MessageContextForm, get_message_context from chats.models import ( @@ -25,6 +34,8 @@ DirectChat, ProjectChatMessage, DirectChatMessage, + DirectChatPrivacy, + DirectChatSpamReport, FileToMessage, ProgramChannel, ) @@ -466,6 +477,184 @@ class DirectChatAdmin(ChatSearchAdmin): "message_search_link", ) list_display_links = ("id",) + change_list_template = "admin/chats/direct_chat_list.html" + + def has_module_permission(self, request): + return request.user.is_staff + + def get_queryset(self, request): + if request.user.is_superuser: + return super().get_queryset(request) + return accessible_chats(request.user, DirectChat) + + def has_view_permission(self, request, obj=None): + if request.user.is_superuser: + return super().has_view_permission(request, obj) + return ( + obj is None + or accessible_chats(request.user, DirectChat).filter(pk=obj.pk).exists() + ) + + 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) + + def get_urls(self): + return [ + path( + "incoming-settings/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.incoming_settings) + ), + name="chats_directchat_incoming_settings", + ), + path( + "/privacy/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.chat_privacy) + ), + name=self.message_search_url_name + "_privacy", + ), + ] + super().get_urls() + + def incoming_settings(self, request): + form = DirectPrivacyForm( + request.POST if request.method == "POST" else None, + initial={"incoming": get_incoming_policy(request.user)}, + ) + if request.method == "POST" and form.is_valid(): + set_incoming_policy(request.user, form.cleaned_data["incoming"]) + self.message_user(request, "Настройки сохранены.") + return redirect("admin:chats_directchat_incoming_settings") + return TemplateResponse( + request, + "admin/chats/direct_privacy.html", + { + **self.admin_site.each_context(request), + "title": "Настройки личных сообщений", + "opts": self.model._meta, + "form": form, + "search_url": reverse("admin:chats_directchat_changelist"), + "back_label": "К личным чатам", + }, + status=400 if form.errors else 200, + ) + + def extra_channel_context(self, request, chat): + return { + "direct_privacy_url": reverse( + "admin:" + self.message_search_url_name + "_privacy", args=[chat.pk] + ) + } + + def chat_privacy(self, request, object_id): + chat = self.get_chat_for_member(request, object_id) + posted = request.method == "POST" + action = request.POST.get("action") + try: + other = direct_chat_opponent(chat, request.user) + except MessageValidationException: + other = None + error = None + if posted: + try: + if action in ("block", "unblock") and other is not None: + set_block(request.user, other, action == "block") + elif action == "report" and other is not None: + report = DirectSpamReportForm(request.POST) + if not report.is_valid(): + raise MessageValidationException("Некорректный номер сообщения.") + report_direct_spam( + chat, request.user, report.cleaned_data["message_id"] + ) + else: + raise MessageValidationException("Действие недоступно.") + except MessageValidationException as exc: + error = str(exc) + if not error: + self.message_user( + request, + ( + "Жалоба отправлена." + if action == "report" + else "Настройки сохранены." + ), + ) + return redirect( + "admin:" + self.message_search_url_name + "_privacy", chat.pk + ) + return TemplateResponse( + request, + "admin/chats/direct_privacy.html", + { + **self.admin_site.each_context(request), + "title": "Настройки личных сообщений", + "opts": self.model._meta, + "settings_url": reverse("admin:chats_directchat_incoming_settings"), + "contact": direct_contact(request.user, other, chat) if other else None, + "error": error, + "search_url": reverse( + "admin:" + self.message_search_url_name, args=[chat.pk] + ), + }, + status=400 if error else 200, + ) + + +class DirectPrivacyForm(forms.Form): + incoming = forms.ChoiceField( + label="Кто может начать диалог со мной", + choices=DirectChatPrivacy.Incoming.choices, + error_messages={ + "invalid_choice": "Выберите, кто может начать диалог.", + "required": "Выберите, кто может начать диалог.", + }, + ) + + +class DirectSpamReportForm(forms.Form): + message_id = forms.IntegerField(required=False, min_value=1, max_value=2**63 - 1) + + +@admin.register(DirectChatSpamReport) +class DirectChatSpamReportAdmin(admin.ModelAdmin): + list_display = ( + "message_id", + "reporter", + "reported_user", + "created_at", + "reviewed_at", + ) + list_filter = ("reviewed_at",) + fields = ( + "chat", + "reporter", + "reported_user", + "message_id", + "message_text", + "created_at", + "reviewed_at", + ) + readonly_fields = fields + actions = ("mark_reviewed",) + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + @admin.action(permissions=["change"], description="Отметить рассмотренными") + def mark_reviewed(self, request, queryset): + count = queryset.filter(reviewed_at__isnull=True).update( + reviewed_at=timezone.now() + ) + self.message_user(request, f"Отмечено рассмотренными: {count}") class ChatMessageAdmin(admin.ModelAdmin): diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index 1df01e78..c32b0b5e 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -10,10 +10,11 @@ from django.utils import timezone from chats.channel_events import channel_group +from chats.direct_privacy import direct_chat_opponent, direct_contact from chats.exceptions import ChatException from chats.permissions import accessible_chats, direct_chat_is_accessible from chats.project_reads import personalize_project_message_event -from chats.services.messages import get_direct_chat_for_participants +from chats.services.messages import get_direct_chat_for_typing from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ( Event, @@ -26,7 +27,7 @@ from users.models import CustomUser from chats.consumers.event_types import DirectEvent, ProjectEvent from chats.utils import get_chat_and_user_ids_from_content -from chats.models import ProgramChannel, ProjectChat +from chats.models import DirectChat, ProgramChannel, ProjectChat @database_sync_to_async @@ -49,6 +50,18 @@ def can_access_program_channel(user, channel_id): return accessible_chats(user, ProgramChannel).filter(pk=channel_id).exists() +@database_sync_to_async +def can_receive_direct_typing(user, chat_id): + chat = accessible_chats(user, DirectChat).filter(pk=chat_id).first() + if chat is None: + return False + try: + other = direct_chat_opponent(chat, user) + except ChatException: + return False + return direct_contact(other, user, chat).can_send + + class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -223,7 +236,7 @@ async def __process_typing_event(self, event: Event, room_name: str): event.content, self.user ) - chat = await database_sync_to_async(get_direct_chat_for_participants)( + chat = await database_sync_to_async(get_direct_chat_for_typing)( self.user, other_user ) event_data["content"]["chat_id"] = chat.pk @@ -237,13 +250,14 @@ async def __process_typing_event(self, event: Event, room_name: str): async def _send_chat_event(self, event): direct_chat_id = event.get("_direct_chat_id") - if direct_chat_id is not None and ( - not self.user - or not await database_sync_to_async(direct_chat_is_accessible)( - self.user, direct_chat_id + if direct_chat_id is not None: + check_access = ( + can_receive_direct_typing + if event["type"] == EventType.TYPING + else database_sync_to_async(direct_chat_is_accessible) ) - ): - return + if not self.user or not await check_access(self.user, direct_chat_id): + return chat_id = event.get("_project_chat_id") if chat_id is not None: if not self.user or not await can_access_project_chat(self.user, chat_id): diff --git a/chats/direct_privacy.py b/chats/direct_privacy.py new file mode 100644 index 00000000..e2b734ec --- /dev/null +++ b/chats/direct_privacy.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import Q +from django.http import Http404 + +from chats.exceptions import MessageValidationException +from chats.models import DirectChatBlock, DirectChatPrivacy, DirectChatSpamReport +from projects.models import Project + + +@dataclass(frozen=True) +class DirectContact: + blocked_by_me: bool + can_send: bool + + +def lock_contact_users(*users): + # A first send and a privacy change must be ordered even before a chat exists. + ids = {user.pk for user in users} + locked = list( + get_user_model() + .objects.filter(pk__in=ids) + .order_by("pk") + .select_for_update(no_key=True) + .values_list("pk", flat=True) + ) + if set(locked) != ids: + raise MessageValidationException("Пользователь недоступен.") + + +def get_incoming_policy(user): + return ( + DirectChatPrivacy.objects.filter(user=user) + .values_list("incoming", flat=True) + .first() + or DirectChatPrivacy.Incoming.EVERYONE + ) + + +@transaction.atomic +def set_incoming_policy(user, incoming): + if incoming not in DirectChatPrivacy.Incoming.values: + raise MessageValidationException("Выберите, кто может начать диалог.") + lock_contact_users(user) + DirectChatPrivacy.objects.update_or_create(user=user, defaults={"incoming": incoming}) + return incoming + + +@transaction.atomic +def set_block(user, other_user, blocked): + if user.pk == other_user.pk: + raise MessageValidationException("Нельзя заблокировать самого себя.") + lock_contact_users(user, other_user) + blocks = DirectChatBlock.objects.filter(user=user, blocked_user=other_user) + if blocked: + blocks.get_or_create(user=user, blocked_user=other_user) + else: + blocks.delete() + + +def direct_contact(user, other_user, chat=None): + if user.pk == other_user.pk: + return DirectContact(blocked_by_me=False, can_send=True) + blockers = set( + DirectChatBlock.objects.filter( + Q(user=user, blocked_user=other_user) | Q(user=other_user, blocked_user=user) + ).values_list("user_id", flat=True) + ) + if blockers: + return DirectContact(blocked_by_me=user.pk in blockers, can_send=False) + if chat is not None and chat.messages.exists(): + return DirectContact(blocked_by_me=False, can_send=True) + if get_incoming_policy(other_user) == DirectChatPrivacy.Incoming.EVERYONE: + return DirectContact(blocked_by_me=False, can_send=True) + + teams = Project.objects.filter(Q(leader=user) | Q(collaborator__user=user)).values( + "pk" + ) + shared_team = ( + Project.objects.filter(pk__in=teams) + .filter(Q(leader=other_user) | Q(collaborator__user=other_user)) + .exists() + ) + return DirectContact(blocked_by_me=False, can_send=shared_team) + + +def require_direct_contact(user, other_user, chat): + if not direct_contact(user, other_user, chat).can_send: + raise MessageValidationException("Отправка сообщений в этот диалог недоступна.") + + +def direct_chat_opponent(chat, user): + other = chat.users.exclude(pk=user.pk).first() + if other is None: + raise MessageValidationException( + "Действие доступно в диалоге с другим пользователем." + ) + return other + + +@transaction.atomic +def report_direct_spam(chat, reporter, message_id=None): + if not chat.users.filter(pk=reporter.pk).exists(): + raise Http404("Чат недоступен.") + other = direct_chat_opponent(chat, reporter) + if ( + message_id is not None + and DirectChatSpamReport.objects.filter( + chat=chat, reporter=reporter, message_id=message_id + ).exists() + ): + return + messages = chat.visible_messages().filter(author=other) + message = ( + messages.filter(pk=message_id).first() + if message_id is not None + else messages.first() + ) + if message is None: + raise MessageValidationException("Нет доступного входящего сообщения для жалобы.") + DirectChatSpamReport.objects.get_or_create( + reporter=reporter, + message_id=message.pk, + defaults={ + "chat": chat, + "reported_user": other, + "message_text": message.text, + }, + ) diff --git a/chats/direct_views.py b/chats/direct_views.py new file mode 100644 index 00000000..615af56e --- /dev/null +++ b/chats/direct_views.py @@ -0,0 +1,95 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from rest_framework import serializers, status +from rest_framework.exceptions import ValidationError +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from chats.attachments import get_accessible_chat +from chats.direct_privacy import ( + direct_chat_opponent, + direct_contact, + get_incoming_policy, + report_direct_spam, + set_block, + set_incoming_policy, +) +from chats.exceptions import MessageValidationException +from chats.models import DirectChat, DirectChatPrivacy + + +class DirectPrivacyInput(serializers.Serializer): + incoming = serializers.ChoiceField( + choices=DirectChatPrivacy.Incoming.choices, + error_messages={ + "invalid_choice": "Выберите, кто может начать диалог.", + "required": "Выберите, кто может начать диалог.", + }, + ) + + +class DirectBlockInput(serializers.Serializer): + blocked = serializers.BooleanField( + error_messages={ + "invalid": "Укажите состояние блокировки.", + "required": "Укажите состояние блокировки.", + } + ) + + +class DirectSpamInput(serializers.Serializer): + message_id = serializers.IntegerField( + required=False, min_value=1, max_value=2**63 - 1 + ) + + +@method_decorator(never_cache, name="dispatch") +class DirectPrivacyView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = DirectPrivacyInput + + def get(self, request): + return Response({"incoming": get_incoming_policy(request.user)}) + + def put(self, request): + data = self.get_serializer(data=request.data) + data.is_valid(raise_exception=True) + incoming = set_incoming_policy(request.user, data.validated_data["incoming"]) + return Response({"incoming": incoming}) + + +@method_decorator(never_cache, name="dispatch") +class DirectBlockView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = DirectBlockInput + + def put(self, request, id): + chat = get_accessible_chat(request.user, DirectChat, id) + data = self.get_serializer(data=request.data) + data.is_valid(raise_exception=True) + try: + other = direct_chat_opponent(chat, request.user) + set_block(request.user, other, data.validated_data["blocked"]) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + contact = direct_contact(request.user, other, chat) + return Response( + {"blocked_by_me": contact.blocked_by_me, "can_send": contact.can_send} + ) + + +@method_decorator(never_cache, name="dispatch") +class DirectSpamReportView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = DirectSpamInput + + def post(self, request, id): + chat = get_accessible_chat(request.user, DirectChat, id) + data = self.get_serializer(data=request.data) + data.is_valid(raise_exception=True) + try: + report_direct_spam(chat, request.user, **data.validated_data) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/chats/migrations/0016_direct_chat_privacy.py b/chats/migrations/0016_direct_chat_privacy.py new file mode 100644 index 00000000..c3cba58b --- /dev/null +++ b/chats/migrations/0016_direct_chat_privacy.py @@ -0,0 +1,156 @@ +# Generated by Django 5.2.18 on 2026-11-13 19:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0015_program_channels"), + ("users", "0066_alter_userachievement_files"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DirectChatPrivacy", + fields=[ + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "incoming", + models.CharField( + choices=[ + ("everyone", "Все пользователи"), + ("team_members", "Участники моих команд"), + ], + default="everyone", + max_length=16, + verbose_name="Кто может начать диалог", + ), + ), + ], + ), + migrations.CreateModel( + name="DirectChatBlock", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "blocked_user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("user", "blocked_user"), name="unique_direct_chat_block" + ), + models.CheckConstraint( + condition=models.Q( + ("user", models.F("blocked_user")), _negated=True + ), + name="direct_chat_block_distinct_users", + ), + ], + }, + ), + migrations.CreateModel( + name="DirectChatSpamReport", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "message_id", + models.PositiveBigIntegerField(verbose_name="Номер сообщения"), + ), + ( + "message_text", + models.TextField(blank=True, verbose_name="Текст сообщения"), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Дата жалобы"), + ), + ( + "reviewed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата рассмотрения" + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="chats.directchat", + verbose_name="Диалог", + ), + ), + ( + "reported_user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Автор сообщения", + ), + ), + ( + "reporter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="Кто пожаловался", + ), + ), + ], + options={ + "verbose_name": "Жалоба на спам в личном чате", + "verbose_name_plural": "Жалобы на спам в личных чатах", + "constraints": [ + models.UniqueConstraint( + fields=("reporter", "message_id"), + name="unique_direct_chat_spam_report", + ) + ], + }, + ), + ] diff --git a/chats/models.py b/chats/models.py index 0459dd6a..5d42b18c 100644 --- a/chats/models.py +++ b/chats/models.py @@ -199,6 +199,39 @@ class Meta: verbose_name_plural = "Личные чаты" +class DirectChatPrivacy(models.Model): + class Incoming(models.TextChoices): + EVERYONE = "everyone", "Все пользователи" + TEAM_MEMBERS = "team_members", "Участники моих команд" + + user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) + incoming = models.CharField( + "Кто может начать диалог", + max_length=16, + choices=Incoming.choices, + default=Incoming.EVERYONE, + ) + + +class DirectChatBlock(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+") + blocked_user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="+", verbose_name="Пользователь" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "blocked_user"], name="unique_direct_chat_block" + ), + models.CheckConstraint( + condition=~models.Q(user=models.F("blocked_user")), + name="direct_chat_block_distinct_users", + ), + ] + + class BaseMessage(models.Model): """ Base message model @@ -373,6 +406,29 @@ class Meta: ] +class DirectChatSpamReport(models.Model): + chat = models.ForeignKey(DirectChat, on_delete=models.CASCADE, verbose_name="Диалог") + reporter = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="+", verbose_name="Кто пожаловался" + ) + reported_user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="+", verbose_name="Автор сообщения" + ) + message_id = models.PositiveBigIntegerField("Номер сообщения") + message_text = models.TextField("Текст сообщения", blank=True) + created_at = models.DateTimeField("Дата жалобы", auto_now_add=True) + reviewed_at = models.DateTimeField("Дата рассмотрения", null=True, blank=True) + + class Meta: + verbose_name = "Жалоба на спам в личном чате" + verbose_name_plural = "Жалобы на спам в личных чатах" + constraints = [ + models.UniqueConstraint( + fields=["reporter", "message_id"], name="unique_direct_chat_spam_report" + ) + ] + + class ProgramChannel(BaseChat): program = models.OneToOneField( "partner_programs.PartnerProgram", diff --git a/chats/services/messages.py b/chats/services/messages.py index 4ebcd4a3..ad6569ef 100644 --- a/chats/services/messages.py +++ b/chats/services/messages.py @@ -3,6 +3,7 @@ from django.db import transaction from chats.channel_events import notify_channel_change +from chats.direct_privacy import lock_contact_users, require_direct_contact from chats.exceptions import MessageValidationException from chats.models import ( DirectChat, @@ -114,6 +115,7 @@ def _create_message( reply_to_id, file_urls, client_message_id, + validate_new_message=None, ): client_message_id = _normalize_client_message_id(client_message_id) if client_message_id is not None: @@ -134,6 +136,8 @@ def _create_message( False, ) + if validate_new_message is not None: + validate_new_message() file_urls = _normalize_message(text, file_urls) reply = {} if message_model is not ProgramChannelMessage: @@ -161,6 +165,7 @@ def create_direct_message( file_urls=None, client_message_id=None, ): + lock_contact_users(author, other_user) chat = get_direct_chat_for_participants(author, other_user) # Serialize sends in this chat before checking for a repeat or locking files. DirectChat.objects.select_for_update().get(pk=chat.pk) @@ -174,6 +179,7 @@ def create_direct_message( reply_to_id=reply_to_id, file_urls=file_urls, client_message_id=client_message_id, + validate_new_message=lambda: require_direct_contact(author, other_user, chat), ) @@ -186,6 +192,15 @@ def get_direct_chat_for_participants(user, other_user): return chat +@transaction.atomic +def get_direct_chat_for_typing(user, other_user): + lock_contact_users(user, other_user) + chat_id = DirectChat.get_chat_id_from_users(user, other_user) + chat = DirectChat.objects.filter(pk=chat_id, users=user).first() + require_direct_contact(user, other_user, chat) + return get_direct_chat_for_participants(user, other_user) + + def mark_direct_message_read(*, chat_id, message_id, user, author): if type(message_id) not in (str, int): raise MessageValidationException("Некорректный идентификатор сообщения.") @@ -261,6 +276,13 @@ def _get_message_for_change(chat_model, chat_id, message_id, author): @transaction.atomic def edit_message(*, chat_model, chat_id, message_id, author, text): + if chat_model is DirectChat: + chat = accessible_chats(author, DirectChat).filter(pk=chat_id).first() + if chat is None: + raise MessageValidationException("Чат недоступен.") + other_user = chat.users.exclude(pk=author.pk).first() or author + lock_contact_users(author, other_user) + require_direct_contact(author, other_user, chat) message = _get_message_for_change(chat_model, chat_id, message_id, author) if message.is_deleted: raise MessageValidationException("Сообщение уже удалено.") diff --git a/chats/tests/test_direct_privacy.py b/chats/tests/test_direct_privacy.py new file mode 100644 index 00000000..fa476713 --- /dev/null +++ b/chats/tests/test_direct_privacy.py @@ -0,0 +1,165 @@ +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.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, DirectChatSpamReport +from chats.services.messages import create_direct_message +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from projects.models import Collaborator, Project + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class DirectPrivacyTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.sender = users.create(**TEST_USER1, is_active=True) + cls.recipient = users.create(**TEST_USER2, is_active=True, is_staff=True) + cls.outsider = users.create(**TEST_USER3, is_active=True, is_staff=True) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.recipient) + self.chat_id = DirectChat.get_chat_id_from_users(self.sender, self.recipient) + + def url(self, suffix): + return reverse("chats:direct-chat-" + suffix, args=[self.chat_id]) + + def test_policy_is_private_and_applies_to_new_dialogs(self): + privacy_url = reverse("chats:direct-chat-privacy") + self.assertEqual( + self.api.get(privacy_url, secure=True).json(), {"incoming": "everyone"} + ) + self.client.force_login(self.recipient) + settings_url = reverse("admin:chats_directchat_incoming_settings") + self.assertContains( + self.client.get(reverse("admin:chats_directchat_changelist"), secure=True), + settings_url, + ) + self.assertEqual( + self.client.post( + settings_url, {"incoming": "team_members"}, secure=True + ).status_code, + 302, + ) + self.assertEqual( + self.api.get(privacy_url, secure=True).json(), {"incoming": "team_members"} + ) + self.api.force_authenticate(self.sender) + first = self.api.get(self.url("detail"), secure=True) + self.assertFalse(first.json()["can_send"]) + project = Project.objects.create(leader=self.recipient) + Collaborator.objects.create(project=project, user=self.sender) + self.assertTrue(self.api.get(self.url("detail"), secure=True).json()["can_send"]) + create_direct_message( + author=self.sender, other_user=self.recipient, text="Первое" + ) + Collaborator.objects.filter(project=project, user=self.sender).delete() + self.assertTrue(self.api.get(self.url("detail"), secure=True).json()["can_send"]) + self.assertEqual( + self.api.get(privacy_url, secure=True).json(), {"incoming": "everyone"} + ) + + def test_report_and_block_use_shared_admin_rules_and_keep_history(self): + message, _ = create_direct_message( + author=self.sender, other_user=self.recipient, text="Входящее" + ) + admin_url = reverse( + "admin:chats_directchat_message_search_privacy", args=[self.chat_id] + ) + self.client.force_login(self.recipient) + self.assertContains( + self.client.get(admin_url, secure=True), "Заблокировать собеседника" + ) + response = self.client.post(admin_url, {"action": "block"}, secure=True) + self.assertEqual(response.status_code, 302) + self.assertEqual( + self.api.get(self.url("detail"), secure=True).json()["blocked_by_me"], True + ) + payload = {"message_id": message["id"]} + self.assertEqual( + self.api.post( + self.url("report-spam"), payload, format="json", secure=True + ).status_code, + 204, + ) + self.assertEqual( + self.client.post( + admin_url, {"action": "report", **payload}, secure=True + ).status_code, + 302, + ) + self.assertEqual(DirectChatSpamReport.objects.get().message_text, "Входящее") + self.api.force_authenticate(self.sender) + detail = self.api.get(self.url("detail"), secure=True).json() + self.assertFalse(detail["blocked_by_me"]) + self.assertFalse(detail["can_send"]) + self.assertEqual( + self.api.get(self.url("messages"), secure=True).json()["count"], 1 + ) + self.client.force_login(self.outsider) + self.assertEqual(self.client.get(admin_url, secure=True).status_code, 404) + self.api.force_authenticate(self.recipient) + response = self.api.put( + self.url("block"), {"blocked": False}, format="json", secure=True + ) + self.assertEqual(response.json(), {"blocked_by_me": False, "can_send": True}) + + +class DirectPrivacySocketTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.sender = users.create(**TEST_USER1) + self.recipient = users.create(**TEST_USER2) + self.chat_id = DirectChat.get_chat_id_from_users(self.sender, self.recipient) + + async def test_socket_respects_privacy_and_keeps_acknowledged_retries_idempotent( + self, + ): + from chats.direct_privacy import set_block, set_incoming_policy + + await database_sync_to_async(set_incoming_policy)(self.recipient, "team_members") + socket = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + socket.scope["user"] = self.sender + connected, _ = await socket.connect(timeout=5) + self.assertTrue(connected) + payload = { + "type": "new_message", + "content": { + "chat_type": "direct", + "chat_id": self.chat_id, + "text": "Сообщение", + "client_message_id": str(uuid4()), + }, + } + try: + await socket.send_json_to(payload) + self.assertIn("error", await socket.receive_json_from()) + self.assertFalse(await DirectChat.objects.filter(pk=self.chat_id).aexists()) + await database_sync_to_async(set_incoming_policy)(self.recipient, "everyone") + await socket.send_json_to(payload) + first = (await socket.receive_json_from())["content"]["message"] + await database_sync_to_async(set_block)(self.recipient, self.sender, True) + await socket.send_json_to(payload) + replay = (await socket.receive_json_from())["content"] + self.assertTrue(replay["is_replay"]) + self.assertEqual(replay["message"]["id"], first["id"]) + payload["content"]["client_message_id"] = str(uuid4()) + await socket.send_json_to(payload) + self.assertIn("error", await socket.receive_json_from()) + payload["type"] = "user_typing" + await socket.send_json_to(payload) + self.assertIn("error", await socket.receive_json_from()) + payload["type"] = "edit_message" + payload["content"]["message_id"] = first["id"] + payload["content"]["text"] = "Измененный текст" + await socket.send_json_to(payload) + self.assertIn("error", await socket.receive_json_from()) + finally: + await socket.disconnect() diff --git a/chats/urls.py b/chats/urls.py index af7b0bf7..b1ddcbcb 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -7,6 +7,7 @@ ProgramChannelMessageChange, ProgramChannelReadView, ) +from chats.direct_views import DirectBlockView, DirectPrivacyView, DirectSpamReportView from chats.models import DirectChat, ProgramChannel, ProjectChat from chats.serializers import ( DirectChatMessageListSerializer, @@ -78,7 +79,14 @@ name="program-channel-attachment-download", ), path("directs/", DirectChatList.as_view(), name="direct-chat-list"), + path("direct-privacy/", DirectPrivacyView.as_view(), name="direct-chat-privacy"), path("directs//", DirectChatDetail.as_view(), name="direct-chat-detail"), + path("directs//block/", DirectBlockView.as_view(), name="direct-chat-block"), + path( + "directs//report-spam/", + DirectSpamReportView.as_view(), + name="direct-chat-report-spam", + ), path( "directs//messages/", DirectChatMessageList.as_view(), diff --git a/chats/views.py b/chats/views.py index c6dbb390..c2b4ead4 100644 --- a/chats/views.py +++ b/chats/views.py @@ -24,6 +24,7 @@ get_accessible_chat, ) from chats.exceptions import MessageValidationException +from chats.direct_privacy import direct_contact from chats.message_context import ( MAX_CONTEXT_LIMIT, MessageContextForm, @@ -106,6 +107,7 @@ def get_queryset(self): ) +@method_decorator(never_cache, name="dispatch") class DirectChatDetail(RetrieveAPIView): queryset = DirectChat.objects.all() serializer_class = DirectChatDetailSerializer @@ -134,6 +136,9 @@ def get(self, request, *args, **kwargs) -> Response: data["name"] = f"{opponent.first_name} {opponent.last_name}" data["image_address"] = opponent.avatar + contact = direct_contact(request.user, opponent, chat) + data["blocked_by_me"] = contact.blocked_by_me + data["can_send"] = contact.can_send return Response( status=status.HTTP_200_OK, diff --git a/docs/modules/chats.md b/docs/modules/chats.md index 2dc306c9..ff753b8f 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -90,6 +90,27 @@ - `limit`, по умолчанию `20`; - `offset`. +## Настройки личных сообщений + +- `GET /chats/direct-privacy/` возвращает текущую настройку пользователя. + `PUT` принимает `incoming` со значением `everyone` или `team_members`. + Сервер проверяет настройку адресата при первой отправке. Общая команда + определяется по текущему составу проекта, включая руководителя. +- В карточке диалога `blocked_by_me` показывает собственную блокировку, + а `can_send` — доступность отправки. `PUT /chats/directs//block/` + принимает `blocked` и возвращает оба поля. Блокировка приостанавливает + отправку, правки и набор в обе стороны. История и ранее полученные файлы + остаются доступны участникам. Подтверждение повтора ранее принятой отправки + возвращается с тем же ID. +- `POST /chats/directs//report-spam/` принимает необязательный `message_id`. + Если ID пропущен, выбирается последнее видимое входящее сообщение. Ответ + `204` подтверждает прием. Повтор жалобы на то же сообщение учитывается один + раз. Содержание жалобы доступно в Django-админке с правами на просмотр жалоб. + +В Django-админке настройка входящих доступна из списка личных чатов. +Заблокировать собеседника, разблокировать его и отправить жалобу можно из +переписки. Админка вызывает те же операции, что и API. + ## Каналы программ `GET /chats/channels/` и `GET /chats/channels//` возвращают сведения о канале, diff --git a/templates/admin/chats/_message.html b/templates/admin/chats/_message.html index e86c2ef9..5fe9ec0b 100644 --- a/templates/admin/chats/_message.html +++ b/templates/admin/chats/_message.html @@ -17,6 +17,13 @@

Сообщение №{{ message.pk }} · {{ message.author.get_full_name }} {% endif %} + {% if direct_privacy_url and message.author_id != request.user.pk %} +
+ {% csrf_token %} + + +
+ {% endif %} {% if message.file_to_message.all %}

Вложения:

    diff --git a/templates/admin/chats/direct_chat_list.html b/templates/admin/chats/direct_chat_list.html new file mode 100644 index 00000000..2bc62051 --- /dev/null +++ b/templates/admin/chats/direct_chat_list.html @@ -0,0 +1,6 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools-items %} +
  • Настройки личных сообщений
  • + {{ block.super }} +{% endblock %} diff --git a/templates/admin/chats/direct_privacy.html b/templates/admin/chats/direct_privacy.html new file mode 100644 index 00000000..725faba0 --- /dev/null +++ b/templates/admin/chats/direct_privacy.html @@ -0,0 +1,27 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

    {{ back_label|default:"К переписке" }}

    + {% if error %}

    {{ error }}

    {% endif %} + {% if form %} +
    + {% csrf_token %} + {{ form.as_p }} + +
    + {% endif %} + {% if settings_url %} +

    Кто может начать диалог со мной

    + {% endif %} + {% if contact %} +
    + {% csrf_token %} + {% if contact.blocked_by_me %} + + {% else %} + + {% endif %} + +
    + {% endif %} +{% endblock %} diff --git a/templates/admin/chats/message_search.html b/templates/admin/chats/message_search.html index 63d1162c..c6c1886a 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -1,6 +1,9 @@ {% extends "admin/base_site.html" %} {% block content %} + {% if direct_privacy_url %} +

    Настройки личных сообщений

    + {% endif %} {% if channel_publish_url %}

    Опубликовать в канале

    {% endif %}