diff --git a/chats/admin.py b/chats/admin.py index 3609acda..10766ff1 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -1,12 +1,82 @@ -from django.contrib import admin +from functools import partial +from uuid import uuid4 +import requests +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django import forms +from django.contrib import admin, messages +from django.contrib.auth import get_user_model +from django.core.exceptions import PermissionDenied +from django.core.cache import cache +from django.core.paginator import Paginator +from django.db import transaction +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +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.direct_reads import ( + mark_direct_message_read, + publish_direct_message_read, + with_direct_chat_unreads, +) +from chats.exceptions import MessageValidationException +from chats.drafts import ( + DRAFT_TEXT_LIMIT, + MAX_DRAFT_REVISION, + DraftConflict, + draft_chat, + draft_state, + save_draft, +) +from chats.message_context import MessageContextForm, get_message_context from chats.models import ( ProjectChat, DirectChat, ProjectChatMessage, DirectChatMessage, + DirectChatPrivacy, + DirectChatSpamReport, FileToMessage, + ProgramChannel, +) +from chats.notifications import ( + Mode, + chat_mention_recipients, + notification_settings, + set_notification_mode, +) +from chats.permissions import accessible_chats, can_publish_in_program_channel +from chats.program_channels import ( + mark_channel_read, + publish_channel_message, + with_channel_unreads, +) +from chats.project_reads import ( + mark_project_message_read, + publish_project_message_read, + with_project_chat_unreads, ) +from chats.search import ChatMessageSearchForm, search_chat_messages +from chats.services.messages import create_project_message, delete_message, edit_message +from chats.utils import get_user_channel_cache_key +from chats.websockets_settings import ChatType, EventGroupType, EventType +from files.models import UserFile +from files.exceptions import SelectelUploadError +from files.service import CDN, SelectelSwiftStorage, create_user_upload @admin.display(description="Пользователи чата") @@ -19,29 +89,939 @@ 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): + object_path = ( + "" if self.model is DirectChat else "" + ) + return [ + path( + object_path + "/draft/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.personal_draft) + ), + name=self.message_search_url_name + "_draft", + ), + path( + object_path + "/message-search/", + 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//change/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.change_own_message) + ), + name=self.message_search_url_name + "_change", + ), + path( + object_path + + "/messages//files//download/", + self.admin_site.admin_view(require_safe(self.attachment_download)), + name=self.message_search_url_name + "_download", + ), + ] + 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): + chat = self.get_chat_for_member(request, object_id) + unreads = { + DirectChat: with_direct_chat_unreads, + ProjectChat: with_project_chat_unreads, + ProgramChannel: with_channel_unreads, + }[self.model] + chat = unreads( + accessible_chats(request.user, self.model).filter(pk=chat.pk), request.user.pk + ).first() + if chat is None: + raise Http404("Чат недоступен.") + 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, + "chat": chat, + "draft_url": ( + reverse( + "admin:" + self.message_search_url_name + "_draft", args=[chat.pk] + ) + if not isinstance(chat, ProgramChannel) + or can_publish_in_program_channel(request.user, chat.program) + else None + ), + **self.extra_channel_context(request, chat), + "message_context_route": "admin:" + + self.message_search_url_name + + "_context", + "message_change_route": "admin:" + + self.message_search_url_name + + "_change", + "attachment_download_route": "admin:" + + self.message_search_url_name + + "_download", + }, + ) + + def get_chat_for_member(self, request, object_id): + chat = get_accessible_chat(request.user, self.model, object_id) + if not self.has_view_or_change_permission(request, chat): + raise PermissionDenied + return chat + + def extra_channel_context(self, request, chat): + return {} + + def personal_draft(self, request, object_id): + chat = self.get_chat_for_member(request, object_id) + draft_chat(request.user, self.model, chat.pk) + current = draft_state(request.user, chat) + form = ChatDraftForm( + request.POST if request.method == "POST" else None, initial=current + ) + response_status = 200 + if request.method == "POST": + response_status = 400 + if form.is_valid(): + try: + save_draft(request.user, self.model, chat.pk, **form.cleaned_data) + except DraftConflict as error: + current = error.current + response_status = 409 + form.add_error( + None, + "Черновик уже изменен. Скопируйте свой текст и обновите страницу.", + ) + except ValueError as error: + form.add_error(None, str(error)) + else: + self.message_user(request, "Черновик сохранен.") + return redirect( + "admin:" + self.message_search_url_name + "_draft", chat.pk + ) + return TemplateResponse( + request, + "admin/chats/draft.html", + { + **self.admin_site.each_context(request), + "title": "Личный черновик", + "opts": self.model._meta, + "form": form, + "current": current if response_status == 409 else None, + "back_url": reverse( + "admin:" + self.message_search_url_name, args=[chat.pk] + ), + }, + status=response_status, + ) + + 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 change_own_message(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + message = get_object_or_404( + chat.messages, pk=message_id, author=request.user, is_deleted=False + ) + form = OwnMessageForm( + request.POST if request.method == "POST" else None, + initial={"text": message.text}, + ) + search_route = "admin:" + self.message_search_url_name + if request.method == "POST" and form.is_valid(): + change = dict( + chat_model=self.model, + chat_id=chat.pk, + message_id=message.pk, + author=request.user, + ) + try: + with transaction.atomic(): + if form.cleaned_data["action"] == "delete": + delete_message(**change) + event_type = EventType.DELETE_MESSAGE + content = {"message_id": message.pk} + else: + event_type = EventType.EDIT_MESSAGE + content = { + "message": edit_message( + **change, text=form.cleaned_data["text"] + ) + } + if not isinstance(chat, ProgramChannel): + transaction.on_commit( + partial(self.publish_change, chat, event_type, content), + robust=True, + ) + except MessageValidationException as error: + form.add_error(None, str(error)) + else: + if event_type == EventType.DELETE_MESSAGE: + self.message_user(request, "Сообщение удалено.") + return redirect(search_route, chat.pk) + self.message_user(request, "Сообщение сохранено.") + return redirect(search_route + "_context", chat.pk, message.pk) + request.current_app = self.admin_site.name + return TemplateResponse( + request, + "admin/chats/message_change.html", + { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": "Изменение сообщения", + "chat": chat, + **self.extra_channel_context(request, chat), + "message": message, + "form": form, + "message_context_route": search_route + "_context", + }, + status=400 if form.errors else 200, + ) + + def publish_change(self, chat, event_type, content): + layer = get_channel_layer() + if layer is None: + return + if isinstance(chat, ProjectChat): + async_to_sync(layer.group_send)( + f"{EventGroupType.CHATS_RELATED}_{chat.pk}", + { + "type": event_type, + "content": { + "chat_id": chat.pk, + "chat_type": ChatType.PROJECT, + **content, + }, + "_project_chat_id": chat.pk, + }, + ) + else: + event = { + "type": event_type, + "content": {"chat_id": chat.pk, "chat_type": ChatType.DIRECT, **content}, + "_direct_chat_id": chat.pk, + } + for user in chat.users.all(): + channel = cache.get(get_user_channel_cache_key(user)) + if channel: + async_to_sync(layer.send)(channel, event) + + 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, + **self.extra_channel_context(request, 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", + "message_change_route": search_route + "_change", + "attachment_download_route": search_route + "_download", + }, + status=200 if context is not None else 400, + ) + + +class ChatDraftForm(forms.Form): + text = forms.CharField( + label="Текст черновика", + required=False, + strip=False, + max_length=DRAFT_TEXT_LIMIT, + widget=forms.Textarea, + ) + revision = forms.IntegerField( + min_value=0, + max_value=MAX_DRAFT_REVISION, + widget=forms.HiddenInput, + ) + + +class OwnMessageForm(forms.Form): + text = forms.CharField( + label="Текст сообщения", required=False, strip=False, widget=forms.Textarea + ) + action = forms.ChoiceField( + choices=[("edit", "Сохранить"), ("delete", "Удалить")], + widget=forms.HiddenInput, + error_messages={ + "required": "Выберите действие.", + "invalid_choice": "Выберите доступное действие.", + }, + ) + + +class GroupMessageForm(forms.Form): + text = forms.CharField( + label="Текст публикации", required=False, strip=False, widget=forms.Textarea + ) + files = forms.ModelMultipleChoiceField( + label="Мои загруженные файлы", queryset=UserFile.objects.none(), required=False + ) + client_message_id = forms.UUIDField( + widget=forms.HiddenInput, + error_messages={"invalid": "Некорректный номер публикации."}, + ) + mentions = forms.ModelMultipleChoiceField( + label="Упомянуть участников", + queryset=get_user_model().objects.none(), + required=False, + error_messages={"invalid_choice": "Выберите участников этого чата."}, + ) + draft_revision = forms.IntegerField( + required=False, + min_value=0, + max_value=MAX_DRAFT_REVISION, + widget=forms.HiddenInput, + ) + + def __init__(self, user, chat, *args, **kwargs): + super().__init__(*args, **kwargs) + if isinstance(chat, ProjectChat): + self.fields["text"].label = "Текст сообщения" + self.fields["files"].queryset = UserFile.objects.filter(user=user).order_by( + "-datetime_uploaded" + ) + self.fields["mentions"].queryset = ( + chat_mention_recipients(chat) + .exclude(pk=user.pk) + .order_by("first_name", "last_name", "pk") + ) + self.fields["mentions"].label_from_instance = ( + lambda member: member.get_full_name() + ) + + +class ChannelUploadForm(forms.Form): + file = forms.FileField(label="Файл") + + +class NotificationSettingsForm(forms.Form): + mode = forms.ChoiceField( + label="Режим уведомлений", + choices=Mode.choices, + error_messages={ + "invalid_choice": "Выберите режим уведомлений.", + "required": "Выберите режим уведомлений.", + }, + ) + + +class GroupChatAdmin(ChatSearchAdmin): + cdn = CDN(storage=SelectelSwiftStorage()) + + def get_urls(self): + return [ + path( + "/publish/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.publish_message) + ), + name=self.message_search_url_name + "_publish", + ), + path( + "/notification-settings/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.notification_settings_view) + ), + name=self.message_search_url_name + "_notifications", + ), + ] + super().get_urls() + + def can_publish(self, user, chat): + return True + + def extra_channel_context(self, request, chat): + prefix = "admin:" + self.message_search_url_name + return { + "channel_publish_url": ( + reverse(prefix + "_publish", args=[chat.pk]) + if self.can_publish(request.user, chat) + else None + ), + "notification_settings_url": reverse( + prefix + "_notifications", args=[chat.pk] + ), + } + + def notification_settings_view(self, request, object_id): + chat = self.get_chat_for_member(request, object_id) + settings = notification_settings(request.user, self.model, chat.pk) + form = NotificationSettingsForm( + request.POST if request.method == "POST" else None, initial=settings + ) + if request.method == "POST" and form.is_valid(): + set_notification_mode( + request.user, self.model, chat.pk, form.cleaned_data["mode"] + ) + self.message_user(request, "Настройки уведомлений сохранены.") + return redirect( + "admin:" + self.message_search_url_name + "_notifications", chat.pk + ) + return TemplateResponse( + request, + "admin/chats/notification_settings.html", + { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": "Настройки уведомлений", + "form": form, + "muted_until": settings["muted_until"], + "back_url": reverse( + "admin:" + self.message_search_url_name, args=[chat.pk] + ), + }, + status=400 if form.errors else 200, + ) + + def publish_message(self, request, object_id): + chat = self.get_chat_for_member(request, object_id) + if not self.can_publish(request.user, chat): + raise PermissionDenied("Публикации доступны менеджерам программы.") + posted = request.method == "POST" + uploading = posted and request.POST.get("action") == "upload" + draft = draft_state(request.user, chat) + form = GroupMessageForm( + request.user, + chat, + request.POST if posted and not uploading else None, + initial={ + "client_message_id": uuid4(), + "text": draft["text"], + "draft_revision": draft["revision"] if draft["text"] else None, + }, + ) + upload_form = ChannelUploadForm( + request.POST if uploading else None, request.FILES if uploading else None + ) + if uploading and upload_form.is_valid(): + try: + upload = create_user_upload( + self.cdn, + upload_form.cleaned_data["file"], + request.user, + preserve_original=True, + ) + except (requests.RequestException, SelectelUploadError, KeyError): + upload_form.add_error( + "file", "Не удалось загрузить файл. Повторите попытку." + ) + else: + form.initial["files"] = [upload.pk] + self.message_user(request, "Файл загружен и выбран для публикации.") + if posted and not uploading and form.is_valid(): + try: + with transaction.atomic(): + publication, created = self.save_publication( + chat.pk, + author=request.user, + text=form.cleaned_data["text"], + file_urls=[file.pk for file in form.cleaned_data["files"]], + client_message_id=form.cleaned_data["client_message_id"], + mentioned_user_ids=[ + user.pk for user in form.cleaned_data["mentions"] + ], + ) + revision = form.cleaned_data["draft_revision"] + if created and revision is not None and revision < MAX_DRAFT_REVISION: + try: + save_draft( + request.user, + self.model, + chat.pk, + text="", + revision=revision, + ) + except DraftConflict: + pass + if created and isinstance(chat, ProjectChat): + transaction.on_commit( + partial( + self.publish_change, + chat, + EventType.NEW_MESSAGE, + {"message": publication, "is_replay": False}, + ), + robust=True, + ) + except MessageValidationException as error: + form.add_error(None, str(error)) + else: + self.message_user( + request, + ( + "Публикация сохранена." + if isinstance(chat, ProgramChannel) + else "Сообщение отправлено." + ), + ) + return redirect( + "admin:" + self.message_search_url_name + "_context", + chat.pk, + publication["id"], + ) + return TemplateResponse( + request, + "admin/chats/channel_publish.html", + { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": ( + "Новая публикация в канале" + if isinstance(chat, ProgramChannel) + else "Новое сообщение" + ), + "send_label": ( + "Опубликовать" if isinstance(chat, ProgramChannel) else "Отправить" + ), + "form": form, + "upload_form": upload_form, + "back_url": reverse( + "admin:" + self.message_search_url_name, args=[chat.pk] + ), + }, + status=400 if form.errors or upload_form.errors else 200, + ) + + +@admin.register(ProgramChannel) +class ProgramChannelAdmin(GroupChatAdmin): + list_display = ("program", "message_search_link") + list_display_links = None + + def get_queryset(self, request): + return accessible_chats(request.user, ProgramChannel).select_related("program") + + def has_view_permission(self, request, obj=None): + if obj is None: + return accessible_chats(request.user, ProgramChannel).exists() + return accessible_chats(request.user, ProgramChannel).filter(pk=obj.pk).exists() + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def get_urls(self): + return [ + path( + "/messages//read/", + self.admin_site.admin_view( + require_http_methods(["POST"])(self.read_message) + ), + name=self.message_search_url_name + "_read", + ), + ] + super().get_urls() + + def extra_channel_context(self, request, chat): + return { + **super().extra_channel_context(request, chat), + "channel_read_route": "admin:" + self.message_search_url_name + "_read", + } + + def can_publish(self, user, chat): + return can_publish_in_program_channel(user, chat.program) + + def save_publication(self, chat_id, **data): + return publish_channel_message(channel_id=chat_id, **data) + + def change_own_message(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + if not self.can_publish(request.user, chat): + raise PermissionDenied("Публикации доступны менеджерам программы.") + return super().change_own_message(request, object_id, message_id) + + def read_message(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + try: + mark_channel_read( + channel_id=chat.pk, user=request.user, message_id=message_id + ) + except MessageValidationException as error: + raise Http404(str(error)) from error + self.message_user(request, "Публикации отмечены прочитанными.") + return redirect( + "admin:" + self.message_search_url_name + "_context", chat.pk, message_id + ) + + @admin.register(ProjectChat) -class ChatAdmin(admin.ModelAdmin): - list_display = ("id", "project", chat_users, chat_message_count, "created_at") +class ChatAdmin(GroupChatAdmin): + list_display = ( + "id", + "project", + chat_users, + chat_message_count, + "created_at", + "message_search_link", + ) list_display_links = ( "id", "project", ) + def get_queryset(self, request): + if request.user.is_superuser: + return super().get_queryset(request) + return accessible_chats(request.user, ProjectChat).select_related("project") + + def has_view_permission(self, request, obj=None): + if request.user.is_superuser: + return super().has_view_permission(request, obj) + chats = accessible_chats(request.user, ProjectChat) + return (chats if obj is None else chats.filter(pk=obj.pk)).exists() + + def has_module_permission(self, request): + return super().has_module_permission(request) or self.has_view_permission(request) + + def save_publication(self, chat_id, **data): + return create_project_message(chat_id=chat_id, **data) + @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",) + 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", + ), + path( + "/messages//read/", + self.admin_site.admin_view( + require_http_methods(["POST"])(self.read_message) + ), + name=self.message_search_url_name + "_read", + ), + ] + 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_read_route": "admin:" + self.message_search_url_name + "_read", + "direct_privacy_url": reverse( + "admin:" + self.message_search_url_name + "_privacy", args=[chat.pk] + ), + } + + def read_message(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + try: + with transaction.atomic(): + author = direct_chat_opponent(chat, request.user) + mark_direct_message_read( + chat_id=chat.pk, + message_id=message_id, + user=request.user, + author=author, + ) + transaction.on_commit( + partial( + async_to_sync(publish_direct_message_read), + chat_id=chat.pk, + message_id=message_id, + user=request.user, + author=author, + ), + robust=True, + ) + except MessageValidationException as error: + raise Http404(str(error)) from error + self.message_user(request, "Сообщение отмечено прочитанным.") + return redirect( + "admin:" + self.message_search_url_name + "_context", chat.pk, message_id + ) + + 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): + def get_queryset(self, request): + queryset = super().get_queryset(request) + if request.user.is_superuser: + return queryset + chat_model = self.model._meta.get_field("chat").related_model + return queryset.filter( + chat_id__in=accessible_chats(request.user, chat_model).values("pk") + ) + + 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) @admin.register(ProjectChatMessage) -class ProjectChatMessageAdmin(admin.ModelAdmin): +class ProjectChatMessageAdmin(ChatMessageAdmin): list_display = ("id", "author", "chat", "created_at") list_display_links = ("id", "author", "chat") + exclude = ("is_read",) + actions = ("mark_read_for_me",) + + @admin.action(permissions=["view"], description="Отметить как прочитанные для себя") + def mark_read_for_me(self, request, queryset): + try: + with transaction.atomic(): + marked = 0 + for chat_id, message_id in queryset.order_by("pk").values_list( + "chat_id", "pk" + ): + author_id, read_count, created = mark_project_message_read( + chat_id=chat_id, message_id=message_id, user=request.user + ) + if created: + marked += 1 + transaction.on_commit( + partial( + async_to_sync(publish_project_message_read), + chat_id=chat_id, + message_id=message_id, + user_id=request.user.pk, + author_id=author_id, + read_count=read_count, + ), + robust=True, + ) + except MessageValidationException as error: + self.message_user(request, str(error), level=messages.ERROR) + else: + self.message_user( + request, f"Отмечено прочитанными: {marked}", level=messages.SUCCESS + ) @admin.register(DirectChatMessage) -class DirectChatMessageAdmin(admin.ModelAdmin): +class DirectChatMessageAdmin(ChatMessageAdmin): list_display = ("id", "author", "chat", "created_at") list_display_links = ("id", "author", "chat") @@ -52,9 +1032,27 @@ class FileToMessageAdmin(admin.ModelAdmin): "file", "direct_message", "project_message", + "channel_message", ) list_display_links = ( "file", "direct_message", "project_message", + "channel_message", ) + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset if request.user.is_superuser else queryset.none() + + def has_view_permission(self, request, obj=None): + return request.user.is_superuser + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/chats/apps.py b/chats/apps.py index 420b4fbf..38052c2c 100644 --- a/chats/apps.py +++ b/chats/apps.py @@ -5,3 +5,6 @@ class ChatsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "chats" verbose_name = "Чаты" + + def ready(self): + from chats import signals # noqa: F401 diff --git a/chats/attachments.py b/chats/attachments.py new file mode 100644 index 00000000..e3302be9 --- /dev/null +++ b/chats/attachments.py @@ -0,0 +1,73 @@ +from django.db.models import Q +from django.http import Http404 +from django.urls import reverse + +from chats.models import DirectChat, FileToMessage, ProgramChannel, ProjectChat +from chats.permissions import accessible_chats +from files.downloads import download_stored_file + + +def get_accessible_chat(user, model, chat_id): + chat = accessible_chats(user, model).filter(pk=chat_id).first() + if chat is None: + raise Http404("Чат недоступен.") + return chat + + +def chat_attachments(chat): + message_field = chat_attachment_field(chat) + filters = { + field + "__isnull": True + for field in ATTACHMENT_FIELDS.values() + if field != message_field + } + filters[message_field + "__chat"] = chat + filters[message_field + "__is_deleted"] = False + return FileToMessage.objects.filter(**filters) + + +ATTACHMENT_FIELDS = { + DirectChat: "direct_message", + ProjectChat: "project_message", + ProgramChannel: "channel_message", +} +ATTACHMENT_IN_USE = "Файл прикреплен к опубликованному сообщению." + + +def used_by_visible_chat_message(file_id, *, using=None): + return ( + FileToMessage.objects.using(using) + .filter(file_id=file_id) + .filter( + Q(direct_message__is_deleted=False) + | Q(project_message__is_deleted=False) + | Q(channel_message__is_deleted=False) + ) + .exists() + ) + + +def chat_attachment_field(chat): + return ATTACHMENT_FIELDS[type(chat)] + + +def attachment_download_url(attachment, message): + if attachment.channel_message_id is not None: + kind = "program-channel" + elif attachment.project_message_id is not None: + kind = "project-chat" + else: + kind = "direct-chat" + return reverse( + "chats:" + kind + "-attachment-download", + args=[message.chat_id, message.pk, attachment.download_id], + ) + + +def download_chat_attachment(request, chat, message_id, attachment_id): + message_field = chat_attachment_field(chat) + "_id" + attachments = chat_attachments(chat).filter(**{message_field: message_id}) + for attachment in attachments.select_related("file"): + if attachment.download_id == attachment_id: + return download_stored_file(request, attachment.file) + raise Http404("Вложение недоступно.") diff --git a/chats/channel_events.py b/chats/channel_events.py new file mode 100644 index 00000000..4aba1fc7 --- /dev/null +++ b/chats/channel_events.py @@ -0,0 +1,26 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.db import transaction + +from chats.websockets_settings import EventGroupType, EventType + + +def channel_group(channel_id): + return f"{EventGroupType.PROGRAM_CHANNEL}_{channel_id}" + + +def notify_channel_change(channel_id, *, new_message_id=None): + layer = get_channel_layer() + if layer is None: + return + transaction.on_commit( + lambda: async_to_sync(layer.group_send)( + channel_group(channel_id), + { + "type": EventType.PROGRAM_CHANNEL_CHANGED, + "content": {"channel_id": channel_id}, + "_new_message_id": new_message_id, + }, + ), + robust=True, + ) diff --git a/chats/channel_views.py b/chats/channel_views.py new file mode 100644 index 00000000..27bc3357 --- /dev/null +++ b/chats/channel_views.py @@ -0,0 +1,228 @@ +from django.db.models import Exists, OuterRef, Prefetch +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from drf_yasg.utils import swagger_auto_schema +from rest_framework import serializers, status +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.generics import GenericAPIView, ListAPIView, RetrieveAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from chats.attachments import get_accessible_chat +from chats.drafts import with_draft_marker +from chats.exceptions import MessageValidationException +from chats.models import ProgramChannel, ProgramChannelMessage +from chats.notifications import MAX_MENTIONS +from chats.pagination import MessageListPagination +from chats.permissions import accessible_chats, can_publish_in_program_channel +from chats.program_channels import ( + MAX_CHANNEL_ATTACHMENTS, + mark_channel_read, + publish_channel_message, + with_channel_unreads, +) +from chats.serializers import ProgramChannelMessageSerializer +from chats.services.messages import delete_message, edit_message +from chats.views import ChatMessageHistory +from partner_programs.models import PartnerProgram + + +class ProgramChannelSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(source="pk") + name = serializers.CharField(source="program.name") + image_address = serializers.CharField(source="program.image_address", allow_null=True) + unread_count = serializers.IntegerField() + first_unread_message_id = serializers.IntegerField(allow_null=True) + can_publish = serializers.BooleanField(read_only=True) + has_draft = serializers.SerializerMethodField() + last_message = serializers.SerializerMethodField() + + def get_has_draft(self, channel) -> bool: + return channel.can_publish and channel.has_draft + + def get_last_message(self, channel): + if channel.latest_messages: + return ProgramChannelMessageSerializer( + channel.latest_messages[0], context=self.context + ).data + return None + + class Meta: + model = ProgramChannel + fields = ( + "id", + "name", + "image_address", + "can_publish", + "unread_count", + "first_unread_message_id", + "last_message", + "has_draft", + ) + + +class ChannelMessageInput(serializers.Serializer): + text = serializers.CharField( + required=False, default="", allow_blank=True, trim_whitespace=False + ) + file_urls = serializers.ListField( + child=serializers.CharField(max_length=200), + required=False, + default=list, + max_length=MAX_CHANNEL_ATTACHMENTS, + ) + client_message_id = serializers.UUIDField( + required=False, allow_null=True, default=None + ) + mentioned_user_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1, max_value=2**63 - 1), + required=False, + default=list, + max_length=MAX_MENTIONS, + ) + + +class ChannelEditInput(serializers.Serializer): + text = serializers.CharField(allow_blank=True, trim_whitespace=False) + + +class ChannelReadInput(serializers.Serializer): + message_id = serializers.IntegerField(min_value=1, max_value=2**63 - 1) + + +class ProgramChannelSummary: + permission_classes = [IsAuthenticated] + serializer_class = ProgramChannelSerializer + pagination_class = MessageListPagination + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return ProgramChannel.objects.none() + user = self.request.user + managed = PartnerProgram.objects.filter(pk=OuterRef("pk"), managers=user) + channels = with_draft_marker( + accessible_chats(user, ProgramChannel).select_related("program"), user + ) + recent = ( + ProgramChannelMessage.objects.filter(is_deleted=False) + .select_related("author") + .prefetch_related("file_to_message__file") + .order_by("-created_at", "-pk")[:1] + ) + return ( + with_channel_unreads(channels, user.pk) + .annotate(can_publish=Exists(managed)) + .prefetch_related( + Prefetch("messages", queryset=recent, to_attr="latest_messages") + ) + .order_by("program__name", "pk") + ) + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelList(ProgramChannelSummary, ListAPIView): + pass + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelDetail(ProgramChannelSummary, RetrieveAPIView): + lookup_url_kwarg = "id" + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelHistory(ChatMessageHistory): + chat_model = ProgramChannel + permission_classes = [IsAuthenticated] + serializer_class = ProgramChannelMessageSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .select_related("author") + .prefetch_related("file_to_message__file") + ) + + @swagger_auto_schema( + request_body=ChannelMessageInput, + responses={ + 200: ProgramChannelMessageSerializer, + 201: ProgramChannelMessageSerializer, + }, + ) + def post(self, request, id): + require_publisher(request.user, id) + form = ChannelMessageInput(data=request.data) + form.is_valid(raise_exception=True) + try: + publication, created = publish_channel_message( + channel_id=id, author=request.user, **form.validated_data + ) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response( + publication, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + +def require_publisher(user, channel_id): + channel = get_accessible_chat(user, ProgramChannel, channel_id) + if not can_publish_in_program_channel(user, channel.program): + raise PermissionDenied("Публикации доступны менеджерам программы.") + return channel + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelMessageChange(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ChannelEditInput + + def get_change(self, request, channel_id, message_id): + channel = require_publisher(request.user, channel_id) + get_object_or_404(channel.visible_messages(), pk=message_id, author=request.user) + return dict( + chat_model=ProgramChannel, + chat_id=channel_id, + message_id=message_id, + author=request.user, + ) + + @swagger_auto_schema( + request_body=ChannelEditInput, responses={200: ProgramChannelMessageSerializer} + ) + def patch(self, request, id, message_id): + change = self.get_change(request, id, message_id) + form = self.get_serializer(data=request.data) + form.is_valid(raise_exception=True) + try: + publication = edit_message(**change, **form.validated_data) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response(publication) + + def delete(self, request, id, message_id): + change = self.get_change(request, id, message_id) + try: + delete_message(**change) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response(status=status.HTTP_204_NO_CONTENT) + + +@method_decorator(never_cache, name="dispatch") +class ProgramChannelReadView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ChannelReadInput + + def post(self, request, id): + get_accessible_chat(request.user, ProgramChannel, id) + form = self.get_serializer(data=request.data) + form.is_valid(raise_exception=True) + try: + last_message_id = mark_channel_read( + channel_id=id, user=request.user, **form.validated_data + ) + except MessageValidationException as error: + raise ValidationError({"detail": str(error)}) from error + return Response({"last_message_id": last_message_id}) diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index a93d615f..76312e5f 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -3,16 +3,18 @@ from json import JSONDecodeError from typing import Optional -from asgiref.sync import sync_to_async from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.conf import settings from django.core.cache import cache -from django.db.models import Q 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.models import BaseChat +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_typing from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ( Event, @@ -22,41 +24,61 @@ ) from core.constants import ONE_DAY_IN_SECONDS, ONE_WEEK_IN_SECONDS from core.utils import get_user_online_cache_key, get_users_online_cache_key -from projects.models import Collaborator 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 DirectChat, ProjectChat +from chats.models import DirectChat, ProgramChannel, ProgramChannelMessage, ProjectChat +from chats.notifications import should_notify @database_sync_to_async -def get_user_project_ids(user_id: int) -> list[int]: - return list( - Collaborator.objects.filter(user_id=user_id).values_list("project", flat=True) +def get_user_project_ids(user) -> list[int]: + return list(accessible_chats(user, ProjectChat).values_list("pk", flat=True)) + + +@database_sync_to_async +def can_access_project_chat(user, chat_id): + return accessible_chats(user, ProjectChat).filter(pk=chat_id).exists() + + +@database_sync_to_async +def get_user_channel_ids(user): + return list(accessible_chats(user, ProgramChannel).values_list("pk", flat=True)) + + +@database_sync_to_async +def can_access_program_channel(user, channel_id): + return accessible_chats(user, ProgramChannel).filter(pk=channel_id).exists() + + +@database_sync_to_async +def channel_notification(user, channel_id, message_id): + message = ( + ProgramChannelMessage.objects.filter( + chat_id=channel_id, pk=message_id, is_deleted=False + ) + .values("author_id", "mentioned_user_ids") + .first() ) + return bool(message and should_notify(user, ProgramChannel, channel_id, **message)) @database_sync_to_async -def can_access_project_chat(user_id, chat_id): +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: - chat_id = int(chat_id) - except (TypeError, ValueError): + other = direct_chat_opponent(chat, user) + except ChatException: return False - return ( - ProjectChat.objects.filter(pk=chat_id) - .filter(Q(project__leader_id=user_id) | Q(project__collaborator__user_id=user_id)) - .exists() - ) + return direct_contact(other, user, chat).can_send class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.room_name: str = "" self.user: Optional[CustomUser] = None - self.chat_type = None - self.chat: Optional[BaseChat] = None - self.event = None self.joined_rooms: set[str] = set() async def connect(self): @@ -72,17 +94,13 @@ async def connect(self): ) if not settings.RUNNING_TESTS: - # get all projects that user is a member of - project_ids_list = await get_user_project_ids(self.user.id) + project_ids_list = await get_user_project_ids(self.user) for project_id in project_ids_list: - # FIXME: if a user is a leader but not a collaborator, this doesn't work - # upd: it seems not possible to be a leader without being a collaborator - # join room for each project - - # It's currently not possible to do this in a single call, - - # so we have to do it in a loop (e.g. that's O(N) calls to layer backend, redis cache that would be) - - room_name = f"{EventGroupType.CHATS_RELATED}_{project_id}" - await self.channel_layer.group_add(room_name, self.channel_name) - self.joined_rooms.add(room_name) + await self._ensure_room_subscription( + f"{EventGroupType.CHATS_RELATED}_{project_id}" + ) + for channel_id in await get_user_channel_ids(self.user): + await self._ensure_room_subscription(channel_group(channel_id)) # set user online user_cache_key = get_user_online_cache_key(self.user) @@ -120,6 +138,16 @@ async def disconnect(self, close_code): # don't need to proccess logic for disconnect # if we are not logged in! return + for room_name in self.joined_rooms: + await self.channel_layer.group_discard(room_name, self.channel_name) + self.joined_rooms.clear() + await self.channel_layer.group_discard( + EventGroupType.GENERAL_EVENTS, self.channel_name + ) + user_channel_key = get_user_channel_cache_key(self.user) + if cache.get(user_channel_key) != self.channel_name: + return + cache.delete(user_channel_key) online_users = cache.get(get_users_online_cache_key(), set()) online_users.discard(self.user.id) cache.set(get_users_online_cache_key(), online_users) @@ -135,7 +163,9 @@ async def disconnect(self, close_code): async def receive_json(self, content, **kwargs): """Receive message from WebSocket in JSON format""" - # todo reply_to key is not required + if not isinstance(content, dict) or not isinstance(content.get("type"), str): + await self.send_json({"error": "Некорректный формат события."}) + return event = Event(type=content["type"], content=content.get("content")) # two event types - related to group chat and related to leave/connect @@ -146,51 +176,57 @@ async def receive_json(self, content, **kwargs): EventType.DELETE_MESSAGE, EventType.EDIT_MESSAGE, ]: - if event.content["chat_type"] == ChatType.DIRECT: - self.event = DirectEvent(self.user, self.channel_layer, self.channel_name) - elif event.content["chat_type"] == ChatType.PROJECT: - if not await can_access_project_chat( - self.user.pk, event.content.get("chat_id") - ): + if not isinstance(event.content, dict): + await self.send_json({"error": "Некорректные данные события."}) + return + chat_type = event.content.get("chat_type") + chat_id = event.content.get("chat_id") + room_name = "" + if chat_type == ChatType.DIRECT: + handler = DirectEvent(self.user, self.channel_layer, self.channel_name) + elif chat_type == ChatType.PROJECT: + try: + if type(chat_id) not in (int, str): + raise ValueError + project_id = int(chat_id) + except ValueError: + await self.send_json({"error": "Некорректный идентификатор чата."}) + return + if not await can_access_project_chat(self.user, project_id): await self.send_json({"error": "У вас нет доступа к чату проекта."}) return - self.event = ProjectEvent( - self.user, self.channel_layer, self.channel_name - ) - - room_name = f"{EventGroupType.CHATS_RELATED}_{event.content.get('chat_id')}" - if event.content["chat_type"] == ChatType.PROJECT: + handler = ProjectEvent(self.user, self.channel_layer, self.channel_name) + room_name = f"{EventGroupType.CHATS_RELATED}_{project_id}" await self._ensure_room_subscription(room_name) + else: + await self.send_json({"error": "Неизвестный тип чата."}) + return try: - await self.__process_chat_related_event(event, room_name) + await self.__process_chat_related_event(handler, event, room_name) except ChatException as e: await self.send_json({"error": str(e.get_error())}) except KeyError as e: - await self.send_json( - { - "error": f"Missing key (might be backend's fault," - f" but most likely you are missing this field): {e}" - } - ) + await self.send_json({"error": f"В запросе отсутствует поле {e}."}) + elif event.type == EventType.SUBSCRIBE_PROGRAM_CHANNEL: + await self.subscribe_program_channel(event.content) elif event.type in [EventType.SET_ONLINE, EventType.SET_OFFLINE]: room_name = EventGroupType.GENERAL_EVENTS await self.__process_general_event(event, room_name) else: - return self.disconnect(400) + await self.send_json({"error": "Неизвестный тип события."}) - async def __process_chat_related_event(self, event, room_name): - self.event: DirectEvent | ProjectEvent + async def __process_chat_related_event(self, handler, event, room_name): if event.type == EventType.NEW_MESSAGE: - await self.event.process_new_message_event(event, room_name) + await handler.process_new_message_event(event, room_name) elif event.type == EventType.TYPING: await self.__process_typing_event(event, room_name) elif event.type == EventType.READ_MESSAGE: - await self.event.process_read_message_event(event, room_name) + await handler.process_read_message_event(event, room_name) elif event.type == EventType.DELETE_MESSAGE: - await self.event.process_delete_message_event(event, room_name) + await handler.process_delete_message_event(event, room_name) elif event.type == EventType.EDIT_MESSAGE: - await self.event.process_edit_message_event(event, room_name) + await handler.process_edit_message_event(event, room_name) async def __process_typing_event(self, event: Event, room_name: str): """Send typing event to room group.""" @@ -206,49 +242,67 @@ async def __process_typing_event(self, event: Event, room_name: str): if event.content["chat_type"] == ChatType.PROJECT: event_data["_project_chat_id"] = event.content["chat_id"] + await self.channel_layer.group_send(room_name, event_data) - if event.content["chat_type"] == ChatType.DIRECT: - # fixme: need to move this to func - chat_id, other_user = await get_chat_and_user_ids_from_content( + else: + _, other_user = await get_chat_and_user_ids_from_content( event.content, self.user ) - # if chat_id == 17_7, then chat_id will be == 7_17 - chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) - - # check if chat exists - try: - await sync_to_async(DirectChat.objects.get)(pk=chat_id) - except DirectChat.DoesNotExist: - # if not, create such chat - await sync_to_async(DirectChat.create_from_two_users)( - self.user, other_user - ) + chat = await database_sync_to_async(get_direct_chat_for_typing)( + self.user, other_user + ) + event_data["content"]["chat_id"] = chat.pk + event_data["_direct_chat_id"] = chat.pk # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - if other_user_channel: + if other_user_channel and other_user_channel != self.channel_name: await self.channel_layer.send(other_user_channel, event_data) - await self.channel_layer.group_send( - room_name, - event_data, - ) - async def _send_chat_event(self, event): + direct_chat_id = event.get("_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) + ) + 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.pk, chat_id): - room_name = f"{EventGroupType.CHATS_RELATED}_{chat_id}" + if not self.user or not await can_access_project_chat(self.user, chat_id): + room_name = f"{EventGroupType.CHATS_RELATED}_{int(chat_id)}" await self.channel_layer.group_discard(room_name, self.channel_name) self.joined_rooms.discard(room_name) return + audience = event.get("_project_read_audience") + if audience is not None and self.user.pk not in audience: + return + if event["type"] in (EventType.NEW_MESSAGE, EventType.EDIT_MESSAGE): + content = event["content"] + message = await database_sync_to_async(personalize_project_message_event)( + content["message"], self.user.pk + ) + event = {**event, "content": {**content, "message": message}} + if event["type"] == EventType.NEW_MESSAGE: + notify = not content.get( + "is_replay" + ) and await database_sync_to_async(should_notify)( + self.user, + ProjectChat, + chat_id, + author_id=message["author"]["id"], + mentioned_user_ids=message.get("mentioned_user_ids", []), + ) + event["content"]["notify"] = notify # Служебный контекст нужен для проверки уже открытых сокетов, # клиентский формат сообщений остается прежним. await self.send( json.dumps( - {key: value for key, value in event.items() if key != "_project_chat_id"} + {key: value for key, value in event.items() if not key.startswith("_")} ) ) @@ -273,6 +327,38 @@ async def set_offline(self, event: Event): async def edit_message(self, event: Event): await self._send_chat_event(event) + async def subscribe_program_channel(self, content): + channel_id = content.get("channel_id") if isinstance(content, dict) else None + if type(channel_id) is not int or not 0 < channel_id < 2**63: + await self.send_json({"error": "Некорректный идентификатор канала."}) + return + if not await can_access_program_channel(self.user, channel_id): + await self.send_json({"error": "Канал недоступен."}) + return + await self._ensure_room_subscription(channel_group(channel_id)) + await self.send_json( + { + "type": EventType.SUBSCRIBE_PROGRAM_CHANNEL, + "content": {"channel_id": channel_id}, + } + ) + + async def program_channel_changed(self, event): + channel_id = event["content"]["channel_id"] + if self.user and await can_access_program_channel(self.user, channel_id): + message_id = event.get("_new_message_id") + notify = message_id is not None and await channel_notification( + self.user, channel_id, message_id + ) + content = {"channel_id": channel_id, "notify": notify} + if notify: + content["message_id"] = message_id + await self.send_json({"type": event["type"], "content": content}) + else: + room_name = channel_group(channel_id) + await self.channel_layer.group_discard(room_name, self.channel_name) + self.joined_rooms.discard(room_name) + async def __process_general_event(self, event: Event, room_name: str): cache_key = get_user_online_cache_key(self.user) users_online_list_key = get_users_online_cache_key() @@ -289,7 +375,7 @@ async def __process_general_event(self, event: Event, room_name: str): elif event.type == EventType.SET_OFFLINE: cache.delete(cache_key) users_online_list = cache.get_or_set(users_online_list_key, set()) - users_online_list.remove(self.user.pk) + users_online_list.discard(self.user.pk) cache.set(users_online_list_key, users_online_list, ONE_DAY_IN_SECONDS) # sent everyone online event that user X is offline @@ -305,6 +391,5 @@ async def __process_general_event(self, event: Event, room_name: str): async def decode_json(self, text_data) -> dict: try: return json.loads(text_data) - except JSONDecodeError as error: - await self.disconnect(400) - raise error + except JSONDecodeError: + return {} diff --git a/chats/consumers/event_types/DirectEvent.py b/chats/consumers/event_types/DirectEvent.py index 37d95053..b4f06141 100644 --- a/chats/consumers/event_types/DirectEvent.py +++ b/chats/consumers/event_types/DirectEvent.py @@ -1,23 +1,18 @@ -from chats.models import DirectChatMessage, DirectChat -from chats.websockets_settings import Event, EventType -from chats.exceptions import ( - WrongChatIdException, - UserNotMessageAuthorException, - UserIsNotAuthor, -) +from channels.db import database_sync_to_async + +from chats.direct_reads import mark_direct_message_read, publish_direct_message_read +from chats.models import DirectChat +from chats.websockets_settings import ChatType, Event, EventType from django.core.cache import cache from chats.utils import ( get_user_channel_cache_key, - create_message, get_chat_and_user_ids_from_content, - match_files_and_messages, - orm_create, - orm_exists, - orm_get, - orm_save, - orm_set, ) -from chats.serializers import DirectChatMessageListSerializer +from chats.services.messages import ( + create_direct_message, + delete_message, + edit_message, +) class DirectEvent: @@ -27,149 +22,91 @@ def __init__(self, user, channel_layer, channel_name): self.channel_name = channel_name async def process_new_message_event(self, event: Event, room_name: str): - chat_id, other_user = await get_chat_and_user_ids_from_content( - event.content, self.user - ) - - # if chat_id == 17_7, then chat_id will be == 7_17 + _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) - # check if chat exists - if not await orm_exists(DirectChat.objects.filter(pk=chat_id)): - # if not, create such chat - chat = await orm_create(DirectChat.objects, pk=chat_id) - await orm_set(chat.users, [self.user, other_user]) - - reply_to_message = None - if event.content["reply_to"] is not None: - try: - reply_to_message = await orm_get( - DirectChatMessage.objects, pk=event.content["reply_to"] - ) - except DirectChatMessage.DoesNotExist: - reply_to_message = None - - msg = await create_message( - chat_id=chat_id, - chat_model=DirectChatMessage, + message_data, created = await database_sync_to_async(create_direct_message)( author=self.user, - text=event.content["text"], - reply_to=reply_to_message, - ) - - messages = { - "direct_message": msg, - "project_message": None, - } - await match_files_and_messages(event.content["file_urls"], messages) - - serialized_message = await orm_get( - DirectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( - "file_to_message__file" - ), - pk=msg.pk, + other_user=other_user, + text=event.content.get("text", ""), + reply_to_id=event.content.get("reply_to"), + file_urls=event.content.get("file_urls", []), + client_message_id=event.content.get("client_message_id"), ) - message_data = DirectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, + "is_replay": not created, } # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - event_data = {"type": EventType.NEW_MESSAGE, "content": content} + event_data = { + "type": EventType.NEW_MESSAGE, + "content": content, + "_direct_chat_id": chat_id, + } await self.channel_layer.send(self.channel_name, event_data) - if other_user_channel: + if created and other_user_channel and other_user_channel != self.channel_name: await self.channel_layer.send(other_user_channel, event_data) async def process_read_message_event(self, event: Event, room_name: str): - chat_id, other_user = await get_chat_and_user_ids_from_content( - event.content, self.user + _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) + chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) + message_id = await database_sync_to_async(mark_direct_message_read)( + chat_id=chat_id, + message_id=event.content["message_id"], + user=self.user, + author=other_user, + ) + await publish_direct_message_read( + chat_id=chat_id, + message_id=message_id, + user=self.user, + author=other_user, + sender_channel=self.channel_name, ) - msg = await orm_get(DirectChatMessage.objects, pk=event.content["message_id"]) - if msg.chat_id != chat_id or msg.author_id != other_user.id: - raise WrongChatIdException( - "Some of chat/message ids are wrong, you can't access this message" - ) - msg.is_read = True - await orm_save(msg, update_fields=["is_read"]) - # send 2 events to user's channel - other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - json_thingy = { - "type": EventType.READ_MESSAGE, - "content": { - "chat_id": event.content["chat_id"], - "chat_type": event.content["chat_type"], - "user_id": self.user.id, - "message_id": event.content["message_id"], - }, - } - await self.channel_layer.send(self.channel_name, json_thingy) - if other_user_channel is None: - return - await self.channel_layer.send(other_user_channel, json_thingy) async def process_delete_message_event(self, event: Event, room_name: str): - message_id = event.content["message_id"] - - message = await orm_get(DirectChatMessage.objects, pk=message_id) - - if self.user.id != message.author_id: - raise UserIsNotAuthor(f"User {self.user.id} is not author {message.text}") - - message.is_deleted = True - await orm_save(message, update_fields=["is_deleted"]) - - chat_id, other_user = await get_chat_and_user_ids_from_content( - event.content, self.user + _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) + chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) + message_id = await database_sync_to_async(delete_message)( + chat_model=DirectChat, + chat_id=chat_id, + message_id=event.content["message_id"], + author=self.user, ) # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - content = { + event_data = { "type": EventType.DELETE_MESSAGE, "content": { "message_id": message_id, + "chat_id": chat_id, + "chat_type": ChatType.DIRECT, }, + "_direct_chat_id": chat_id, } - await self.channel_layer.send(self.channel_name, content) - - if other_user_channel is None: - return + await self.channel_layer.send(self.channel_name, event_data) - await self.channel_layer.send(other_user_channel, content) + if other_user_channel and other_user_channel != self.channel_name: + await self.channel_layer.send(other_user_channel, event_data) async def process_edit_message_event(self, event, room_name): - chat_id, other_user = await get_chat_and_user_ids_from_content( - event.content, self.user - ) - # if chat_id == 17_7, then chat_id will be == 7_17 + _, other_user = await get_chat_and_user_ids_from_content(event.content, self.user) chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) - - # check if chat exists ( this raises exception if not ) - await orm_get(DirectChat.objects, pk=chat_id) - - msg = await orm_get(DirectChatMessage.objects, pk=event.content["message_id"]) - - if msg.author_id != self.user.id: - raise UserNotMessageAuthorException( - f"User {self.user.id} is not author of message {msg.id}" - ) - msg.text = event.content["text"] - msg.is_edited = True - await orm_save(msg, update_fields=["text", "is_edited"]) - - serialized_message = await orm_get( - DirectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( - "file_to_message__file" - ), - pk=msg.pk, + message_data = await database_sync_to_async(edit_message)( + chat_model=DirectChat, + chat_id=chat_id, + message_id=event.content["message_id"], + author=self.user, + text=event.content["text"], ) - message_data = DirectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, @@ -178,9 +115,13 @@ async def process_edit_message_event(self, event, room_name): # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - event_data = {"type": EventType.EDIT_MESSAGE, "content": content} + event_data = { + "type": EventType.EDIT_MESSAGE, + "content": content, + "_direct_chat_id": chat_id, + } - if other_user_channel: + if other_user_channel and other_user_channel != self.channel_name: await self.channel_layer.send(other_user_channel, event_data) await self.channel_layer.send(self.channel_name, event_data) diff --git a/chats/consumers/event_types/ProjectEvent.py b/chats/consumers/event_types/ProjectEvent.py index 96461184..220b332c 100644 --- a/chats/consumers/event_types/ProjectEvent.py +++ b/chats/consumers/event_types/ProjectEvent.py @@ -1,23 +1,9 @@ -from chats.models import ProjectChat, ProjectChatMessage -from chats.utils import ( - create_message, - match_files_and_messages, - orm_exists, - orm_get, - orm_save, -) -from chats.websockets_settings import Event, EventType -from chats.exceptions import ( - WrongChatIdException, - UserNotInChatException, - UserNotMessageAuthorException, - UserIsNotAuthor, -) +from channels.db import database_sync_to_async -from chats.serializers import ( - ProjectChatMessageListSerializer, -) -from projects.models import Collaborator +from chats.models import ProjectChat +from chats.project_reads import mark_project_message_read, publish_project_message_read +from chats.services.messages import create_project_message, delete_message, edit_message +from chats.websockets_settings import ChatType, Event, EventType class ProjectEvent: @@ -28,53 +14,19 @@ def __init__(self, user, channel_layer, channel_name): async def process_new_message_event(self, event: Event, room_name: str): chat_id = event.content["chat_id"] - chat = await orm_get( - ProjectChat.objects.select_related("project__leader"), - pk=chat_id, - ) - - # check that user is in this chat - is_member = chat.project.leader_id == self.user.id or await orm_exists( - Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) - ) - if not is_member: - raise UserNotInChatException( - f"User {self.user.id} is not in project chat {chat_id}" - ) - - reply_to_message = None - if event.content["reply_to"] is not None: - try: - reply_to_message = await orm_get( - ProjectChatMessage.objects, pk=event.content["reply_to"] - ) - except ProjectChatMessage.DoesNotExist: - reply_to_message = None - - msg = await create_message( + message_data, created = await database_sync_to_async(create_project_message)( chat_id=chat_id, - chat_model=ProjectChatMessage, author=self.user, - text=event.content["text"], - reply_to=reply_to_message, - ) - - messages = { - "direct_message": None, - "project_message": msg, - } - await match_files_and_messages(event.content["file_urls"], messages) - - serialized_message = await orm_get( - ProjectChatMessage.objects.select_related( - "author", "reply_to__author" - ).prefetch_related("file_to_message__file"), - pk=msg.pk, + text=event.content.get("text", ""), + reply_to_id=event.content.get("reply_to"), + file_urls=event.content.get("file_urls", []), + client_message_id=event.content.get("client_message_id"), + mentioned_user_ids=event.content.get("mentioned_user_ids", []), ) - message_data = ProjectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, + "is_replay": not created, } event_data = { @@ -83,111 +35,59 @@ async def process_new_message_event(self, event: Event, room_name: str): "_project_chat_id": chat_id, } - await self.channel_layer.group_send(room_name, event_data) + if created: + await self.channel_layer.group_send(room_name, event_data) + else: + await self.channel_layer.send(self.channel_name, event_data) async def process_read_message_event(self, event: Event, room_name: str): - msg = await orm_get(ProjectChatMessage.objects, pk=event.content["message_id"]) - chat = await orm_get( - ProjectChat.objects.select_related("project__leader"), - pk=msg.chat_id, - ) - # check that user is in this chat - is_member = chat.project.leader_id == self.user.id or await orm_exists( - Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) - ) - if not is_member: - raise UserNotInChatException( - f"User {self.user.id} is not in project chat {msg.chat_id}" - ) - if msg.chat_id != int(event.content["chat_id"]): - raise WrongChatIdException( - "Some of chat/message ids are wrong, you can't access this message" - ) - msg.is_read = True - await orm_save(msg, update_fields=["is_read"]) - await self.channel_layer.group_send( - room_name, - { - "type": EventType.READ_MESSAGE, - "content": { - "chat_id": event.content["chat_id"], - "chat_type": event.content["chat_type"], - "user_id": self.user.id, - "message_id": event.content["message_id"], - }, - "_project_chat_id": event.content["chat_id"], - }, + chat_id = event.content["chat_id"] + message_id = event.content["message_id"] + author_id, read_count, created = await database_sync_to_async( + mark_project_message_read + )(chat_id=chat_id, message_id=message_id, user=self.user) + if author_id == self.user.pk: + return + await publish_project_message_read( + chat_id=chat_id, + message_id=int(message_id), + user_id=self.user.id, + author_id=author_id, + read_count=read_count, + sender_channel=None if created else self.channel_name, ) async def process_delete_message_event(self, event: Event, room_name: str): chat_id = event.content["chat_id"] - chat = await orm_get( - ProjectChat.objects.select_related("project__leader"), - pk=chat_id, - ) - - # check that user is in this chat - is_member = chat.project.leader_id == self.user.id or await orm_exists( - Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) - ) - if not is_member: - raise UserNotInChatException( - f"User {self.user.id} is not in project chat {chat_id}" - ) - - message = await orm_get( - ProjectChatMessage.objects, pk=event.content["message_id"] + message_id = await database_sync_to_async(delete_message)( + chat_model=ProjectChat, + chat_id=chat_id, + message_id=event.content["message_id"], + author=self.user, ) - if self.user.id != message.author_id: - raise UserIsNotAuthor(f"User {self.user.id} is not author {chat_id}") - message.is_deleted = True - await orm_save(message, update_fields=["is_deleted"]) - await self.channel_layer.group_send( room_name, { "type": EventType.DELETE_MESSAGE, - "content": {"message_id": event.content["message_id"]}, - "_project_chat_id": event.content["chat_id"], + "content": { + "message_id": message_id, + "chat_id": chat_id, + "chat_type": ChatType.PROJECT, + }, + "_project_chat_id": chat_id, }, ) async def process_edit_message_event(self, event, room_name): chat_id = event.content["chat_id"] - chat = await orm_get( - ProjectChat.objects.select_related("project__leader"), - pk=chat_id, - ) - - # check that user is in this chat - is_member = chat.project.leader_id == self.user.id or await orm_exists( - Collaborator.objects.filter(project_id=chat.project_id, user_id=self.user.id) - ) - if not is_member: - raise UserNotInChatException( - f"User {self.user.id} is not in project chat {chat_id}" - ) - - message = await orm_get( - ProjectChatMessage.objects, pk=event.content["message_id"] - ) - - if message.author_id != self.user.id: - raise UserNotMessageAuthorException( - f"User {self.user.id} is not author of message {message.id}" - ) - message.text = event.content["text"] - message.is_edited = True - await orm_save(message, update_fields=["text", "is_edited"]) - - serialized_message = await orm_get( - ProjectChatMessage.objects.select_related( - "author", "reply_to__author" - ).prefetch_related("file_to_message__file"), - pk=message.pk, + message_data = await database_sync_to_async(edit_message)( + chat_model=ProjectChat, + chat_id=chat_id, + message_id=event.content["message_id"], + author=self.user, + text=event.content["text"], ) - message_data = ProjectChatMessageListSerializer(serialized_message).data content = { "chat_id": chat_id, "message": message_data, 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_reads.py b/chats/direct_reads.py new file mode 100644 index 00000000..80583cb6 --- /dev/null +++ b/chats/direct_reads.py @@ -0,0 +1,68 @@ +from channels.layers import get_channel_layer +from django.core.cache import cache +from django.db.models import Count, OuterRef, Subquery, Value +from django.db.models.functions import Coalesce + +from chats.exceptions import MessageValidationException +from chats.models import DirectChatMessage +from chats.utils import get_user_channel_cache_key +from chats.websockets_settings import ChatType, EventType + + +def unread_direct_messages(user_id): + return DirectChatMessage.objects.filter(is_read=False, is_deleted=False).exclude( + author_id=user_id + ) + + +def with_direct_chat_unreads(chats, user_id): + unread = unread_direct_messages(user_id).filter(chat_id=OuterRef("pk")) + totals = unread.order_by().values("chat_id").annotate(total=Count("pk")) + return chats.annotate( + unread_count=Coalesce(Subquery(totals.values("total")), Value(0)), + first_unread_message_id=Subquery( + unread.order_by("created_at", "pk").values("pk")[:1] + ), + ) + + +def mark_direct_message_read(*, chat_id, message_id, user, author): + if type(message_id) not in (str, int): + raise MessageValidationException("Некорректный идентификатор сообщения.") + try: + messages = DirectChatMessage.objects.filter( + pk=message_id, + chat_id=chat_id, + chat__users=user, + author=author, + is_deleted=False, + ).filter(chat__users=author) + if user.pk != author.pk and messages.update(is_read=True): + return int(message_id) + except (TypeError, ValueError): + pass + raise MessageValidationException("Сообщение недоступно для прочтения.") + + +async def publish_direct_message_read( + *, chat_id, message_id, user, author, sender_channel=None +): + layer = get_channel_layer() + if layer is None: + return + channels = { + sender_channel or cache.get(get_user_channel_cache_key(user)), + cache.get(get_user_channel_cache_key(author)), + } + event = { + "type": EventType.READ_MESSAGE, + "content": { + "chat_id": chat_id, + "chat_type": ChatType.DIRECT, + "user_id": user.pk, + "message_id": message_id, + }, + "_direct_chat_id": chat_id, + } + for channel in channels - {None}: + await layer.send(channel, event) 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/draft_views.py b/chats/draft_views.py new file mode 100644 index 00000000..64d00f90 --- /dev/null +++ b/chats/draft_views.py @@ -0,0 +1,50 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from drf_yasg.utils import swagger_auto_schema +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.drafts import ( + DRAFT_TEXT_LIMIT, + MAX_DRAFT_REVISION, + DraftConflict, + draft_chat, + draft_state, + save_draft, +) + + +class ChatDraftSerializer(serializers.Serializer): + text = serializers.CharField( + allow_blank=True, trim_whitespace=False, max_length=DRAFT_TEXT_LIMIT + ) + revision = serializers.IntegerField(min_value=0, max_value=MAX_DRAFT_REVISION) + updated_at = serializers.DateTimeField(read_only=True, allow_null=True) + + +@method_decorator(never_cache, name="dispatch") +class ChatDraftView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ChatDraftSerializer + chat_model = None + + def get(self, request, id): + chat = draft_chat(request.user, self.chat_model, id) + return Response(self.get_serializer(draft_state(request.user, chat)).data) + + @swagger_auto_schema(responses={200: ChatDraftSerializer, 409: ChatDraftSerializer}) + def put(self, request, id): + data = self.get_serializer(data=request.data) + data.is_valid(raise_exception=True) + try: + draft = save_draft(request.user, self.chat_model, id, **data.validated_data) + except DraftConflict as error: + return Response( + self.get_serializer(error.current).data, status=status.HTTP_409_CONFLICT + ) + except ValueError as error: + raise ValidationError({"detail": str(error)}) from error + return Response(self.get_serializer(draft).data) diff --git a/chats/drafts.py b/chats/drafts.py new file mode 100644 index 00000000..249d6cad --- /dev/null +++ b/chats/drafts.py @@ -0,0 +1,83 @@ +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.db.models import Exists, OuterRef +from django.http import Http404 + +from chats.models import ChatDraft, DirectChat, ProgramChannel, ProjectChat +from chats.permissions import accessible_chats, can_publish_in_program_channel + +CHAT_FIELDS = { + DirectChat: "direct_chat_id", + ProjectChat: "project_chat_id", + ProgramChannel: "program_channel_id", +} +DRAFT_TEXT_LIMIT = ChatDraft._meta.get_field("text").max_length +MAX_DRAFT_REVISION = 2**53 - 1 + + +class DraftConflict(Exception): + def __init__(self, current): + self.current = current + + +def draft_chat(user, chat_model, chat_id, *, lock=False): + chats = chat_model.objects.filter( + pk=chat_id, pk__in=accessible_chats(user, chat_model).values("pk") + ) + if lock: + chats = chats.select_for_update() + chat = chats.first() + if chat is None: + raise Http404("Чат недоступен.") + if chat_model is ProgramChannel and not can_publish_in_program_channel( + user, chat.program + ): + raise PermissionDenied("Черновики канала доступны менеджерам программы.") + return chat + + +def _drafts(user, chat_model, chat_id): + return ChatDraft.objects.filter(user=user, **{CHAT_FIELDS[chat_model]: chat_id}) + + +def with_draft_marker(chats, user): + drafts = _drafts(user, chats.model, OuterRef("pk")).exclude(text="") + return chats.annotate(has_draft=Exists(drafts)) + + +def draft_state(user, chat): + draft = ( + _drafts(user, type(chat), chat.pk) + .values("text", "revision", "updated_at") + .first() + ) + return draft or {"text": "", "revision": 0, "updated_at": None} + + +@transaction.atomic +def save_draft(user, chat_model, chat_id, *, text, revision): + chat = draft_chat(user, chat_model, chat_id, lock=True) + if not isinstance(text, str) or "\x00" in text or len(text) > DRAFT_TEXT_LIMIT: + raise ValueError("Введите текст черновика длиной до 8192 символов.") + if type(revision) is not int or not 0 <= revision <= MAX_DRAFT_REVISION: + raise ValueError("Некорректная версия черновика.") + if not text.strip(): + text = "" + current = draft_state(user, chat) + if text == current["text"]: + return current + if revision != current["revision"]: + raise DraftConflict(current) + if revision == MAX_DRAFT_REVISION: + raise ValueError("Невозможно сохранить следующую версию черновика.") + # Keep the revision when clearing so delayed writes cannot restore old text. + draft, _ = ChatDraft.objects.update_or_create( + user=user, + **{CHAT_FIELDS[chat_model]: chat.pk}, + defaults={"text": text, "revision": revision + 1}, + ) + return { + "text": draft.text, + "revision": draft.revision, + "updated_at": draft.updated_at, + } diff --git a/chats/exceptions.py b/chats/exceptions.py index fd9a2a80..ee09075d 100644 --- a/chats/exceptions.py +++ b/chats/exceptions.py @@ -23,5 +23,5 @@ class UserNotMessageAuthorException(ChatException): pass -class NonMatchingReplyChatIdException(ChatException): +class MessageValidationException(ChatException): pass 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/migrations/0012_directchatmessage_client_message_id_and_more.py b/chats/migrations/0012_directchatmessage_client_message_id_and_more.py new file mode 100644 index 00000000..9303db59 --- /dev/null +++ b/chats/migrations/0012_directchatmessage_client_message_id_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.18 on 2026-11-13 16:33 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0011_alter_filetomessage_direct_message_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="directchatmessage", + name="client_message_id", + field=models.UUIDField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name="projectchatmessage", + name="client_message_id", + field=models.UUIDField(blank=True, editable=False, null=True), + ), + migrations.AddConstraint( + model_name="directchatmessage", + constraint=models.UniqueConstraint( + condition=models.Q(("client_message_id__isnull", False)), + fields=("chat", "author", "client_message_id"), + name="unique_direct_chat_client_message", + ), + ), + migrations.AddConstraint( + model_name="projectchatmessage", + constraint=models.UniqueConstraint( + condition=models.Q(("client_message_id__isnull", False)), + fields=("chat", "author", "client_message_id"), + name="unique_project_chat_client_message", + ), + ), + ] diff --git a/chats/migrations/0013_project_chat_message_reads.py b/chats/migrations/0013_project_chat_message_reads.py new file mode 100644 index 00000000..21d6a7cd --- /dev/null +++ b/chats/migrations/0013_project_chat_message_reads.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.18 on 2026-11-13 16:56 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0012_directchatmessage_client_message_id_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ProjectChatMessageRead", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "message", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="read_receipts", + to="chats.projectchatmessage", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Прочтение сообщения в чате проекта", + "verbose_name_plural": "Прочтения сообщений в чатах проектов", + "constraints": [ + models.UniqueConstraint( + fields=("message", "user"), + name="unique_project_chat_message_reader", + ) + ], + }, + ), + ] diff --git a/chats/migrations/0014_chat_message_sync.py b/chats/migrations/0014_chat_message_sync.py new file mode 100644 index 00000000..181723fb --- /dev/null +++ b/chats/migrations/0014_chat_message_sync.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.18 on 2026-11-13 18:24 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0013_project_chat_message_reads"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="directchat", + name="message_revision", + field=models.PositiveBigIntegerField(db_default=0, default=0, editable=False), + ), + migrations.AddField( + model_name="directchatmessage", + name="sync_revision", + field=models.PositiveBigIntegerField(editable=False, null=True), + ), + migrations.AddField( + model_name="projectchat", + name="message_revision", + field=models.PositiveBigIntegerField(db_default=0, default=0, editable=False), + ), + migrations.AddField( + model_name="projectchatmessage", + name="sync_revision", + field=models.PositiveBigIntegerField(editable=False, null=True), + ), + migrations.AddIndex( + model_name="directchatmessage", + index=models.Index( + fields=["chat", "sync_revision"], name="direct_chat_sync_idx" + ), + ), + migrations.AddIndex( + model_name="projectchatmessage", + index=models.Index( + fields=["chat", "sync_revision"], name="project_chat_sync_idx" + ), + ), + ] diff --git a/chats/migrations/0015_program_channels.py b/chats/migrations/0015_program_channels.py new file mode 100644 index 00000000..1b75b939 --- /dev/null +++ b/chats/migrations/0015_program_channels.py @@ -0,0 +1,171 @@ +# Generated by Django 5.2.18 on 2026-11-13 18:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def create_existing_channels(apps, schema_editor): + program = apps.get_model("partner_programs", "PartnerProgram") + channel = apps.get_model("chats", "ProgramChannel") + database = schema_editor.connection.alias + ids = ( + program.objects.using(database) + .values_list("pk", flat=True) + .iterator(chunk_size=1000) + ) + batch = [] + for program_id in ids: + batch.append(channel(program_id=program_id)) + if len(batch) == 1000: + channel.objects.using(database).bulk_create(batch) + batch.clear() + if batch: + channel.objects.using(database).bulk_create(batch) + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0014_chat_message_sync"), + ("partner_programs", "0044_organizer_expert_identities"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ProgramChannel", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "message_revision", + models.PositiveBigIntegerField( + db_default=0, default=0, editable=False + ), + ), + ( + "program", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="partner_programs.partnerprogram", + verbose_name="Программа", + ), + ), + ], + options={ + "verbose_name": "Канал программы", + "verbose_name_plural": "Каналы программ", + }, + ), + migrations.CreateModel( + name="ProgramChannelMessage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField(max_length=8192)), + ("is_read", models.BooleanField(default=False)), + ("is_deleted", models.BooleanField(default=False)), + ("is_edited", models.BooleanField(default=False)), + ( + "client_message_id", + models.UUIDField(blank=True, editable=False, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "sync_revision", + models.PositiveBigIntegerField(editable=False, null=True), + ), + ( + "author", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="program_channel_posts", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chats.programchannel", + ), + ), + ], + options={ + "verbose_name": "Публикация канала", + "verbose_name_plural": "Публикации каналов", + }, + ), + migrations.AddField( + model_name="filetomessage", + name="channel_message", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="file_to_message", + to="chats.programchannelmessage", + ), + ), + migrations.CreateModel( + name="ProgramChannelRead", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("last_message_id", models.PositiveBigIntegerField()), + ( + "channel", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="chats.programchannel", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddIndex( + model_name="programchannelmessage", + index=models.Index( + fields=["chat", "sync_revision"], name="program_channel_sync_idx" + ), + ), + migrations.AddConstraint( + model_name="programchannelmessage", + constraint=models.UniqueConstraint( + condition=models.Q(("client_message_id__isnull", False)), + fields=("chat", "author", "client_message_id"), + name="unique_program_channel_client_message", + ), + ), + migrations.AddConstraint( + model_name="programchannelread", + constraint=models.UniqueConstraint( + fields=("channel", "user"), name="unique_program_channel_reader" + ), + ), + migrations.RunPython(create_existing_channels, migrations.RunPython.noop), + ] 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/migrations/0017_chat_notification_preferences.py b/chats/migrations/0017_chat_notification_preferences.py new file mode 100644 index 00000000..b0f74d3b --- /dev/null +++ b/chats/migrations/0017_chat_notification_preferences.py @@ -0,0 +1,114 @@ +# Generated by Django 5.2.18 on 2026-11-13 19:20 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0016_direct_chat_privacy"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="programchannelmessage", + name="mentioned_user_ids", + field=models.JSONField(blank=True, default=list, editable=False), + ), + migrations.AddField( + model_name="projectchatmessage", + name="mentioned_user_ids", + field=models.JSONField(blank=True, default=list, editable=False), + ), + migrations.CreateModel( + name="ChatNotificationPreference", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "mode", + models.CharField( + choices=[ + ("all", "Все сообщения"), + ("mentions", "Только упоминания"), + ("mute_8h", "Без звука на 8 часов"), + ("muted", "Отключены до включения"), + ], + max_length=12, + verbose_name="Режим уведомлений", + ), + ), + ("muted_until", models.DateTimeField(null=True)), + ( + "program_channel", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="chats.programchannel", + ), + ), + ( + "project_chat", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="chats.projectchat", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.CheckConstraint( + condition=models.Q( + models.Q( + ("program_channel__isnull", True), + ("project_chat__isnull", False), + ), + models.Q( + ("program_channel__isnull", False), + ("project_chat__isnull", True), + ), + _connector="OR", + ), + name="chat_notification_has_one_chat", + ), + models.CheckConstraint( + condition=models.Q( + models.Q(("mode", "mute_8h"), ("muted_until__isnull", False)), + models.Q( + models.Q(("mode", "mute_8h"), _negated=True), + ("muted_until__isnull", True), + ), + _connector="OR", + ), + name="chat_notification_mute_has_expiry", + ), + models.UniqueConstraint( + fields=("user", "project_chat"), + name="unique_project_chat_notification_user", + ), + models.UniqueConstraint( + fields=("user", "program_channel"), + name="unique_channel_notification_user", + ), + ], + }, + ), + ] diff --git a/chats/migrations/0018_personal_chat_drafts.py b/chats/migrations/0018_personal_chat_drafts.py new file mode 100644 index 00000000..e6bc9dfc --- /dev/null +++ b/chats/migrations/0018_personal_chat_drafts.py @@ -0,0 +1,101 @@ +# Generated by Django 5.2.18 on 2026-11-13 19:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0017_chat_notification_preferences"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ChatDraft", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField(blank=True, max_length=8192)), + ("revision", models.PositiveBigIntegerField(default=0)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "direct_chat", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="chats.directchat", + ), + ), + ( + "program_channel", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="chats.programchannel", + ), + ), + ( + "project_chat", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="chats.projectchat", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.CheckConstraint( + condition=models.Q( + models.Q( + ("direct_chat__isnull", False), + ("program_channel__isnull", True), + ("project_chat__isnull", True), + ), + models.Q( + ("direct_chat__isnull", True), + ("program_channel__isnull", True), + ("project_chat__isnull", False), + ), + models.Q( + ("direct_chat__isnull", True), + ("program_channel__isnull", False), + ("project_chat__isnull", True), + ), + _connector="OR", + ), + name="chat_draft_has_one_chat", + ), + models.UniqueConstraint( + fields=("user", "direct_chat"), + name="unique_direct_chat_draft_user", + ), + models.UniqueConstraint( + fields=("user", "project_chat"), + name="unique_project_chat_draft_user", + ), + models.UniqueConstraint( + fields=("user", "program_channel"), + name="unique_program_channel_draft_user", + ), + ], + }, + ), + ] diff --git a/chats/models.py b/chats/models.py index 3a84ddbd..15fc28c1 100644 --- a/chats/models.py +++ b/chats/models.py @@ -1,9 +1,10 @@ from abc import abstractmethod +from hashlib import sha256 from typing import List from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError -from django.db import models +from django.db import models, router, transaction from files.models import UserFile from projects.models import Project @@ -20,9 +21,31 @@ class BaseChat(models.Model): """ created_at = models.DateTimeField(auto_now_add=True) + message_revision = models.PositiveBigIntegerField( + default=0, db_default=0, editable=False + ) - def get_last_message(self): - return self.messages.last() + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + if not self._state.adding and update_fields is None: + # A chat settings save must not overwrite a newer message cursor. + update_fields = { + field.attname + for field in self._meta.concrete_fields + if not field.primary_key + and field.name != "message_revision" + and field.attname not in self.get_deferred_fields() + } + return super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) + + def visible_messages(self): + return self.messages.filter(is_deleted=False).order_by("-created_at", "-pk") def get_users_str(self): """Returns string of users separated by a comma, who are in chat @@ -99,7 +122,7 @@ def get_avatar(self, user): return self.project.image_address def get_last_messages(self, message_count) -> List["BaseMessage"]: - return self.messages.order_by("-created_at")[:message_count] + return self.visible_messages()[:message_count] def __str__(self): return f"ProjectChat<{self.project.id}> - {self.project.name}" @@ -137,6 +160,7 @@ def get_avatar(self, user): return other_user.avatar @classmethod + @transaction.atomic def get_chat(cls, user1, user2) -> "DirectChat": """ Returns chat between two users. @@ -150,25 +174,17 @@ def get_chat(cls, user1, user2) -> "DirectChat": """ pk = cls.get_chat_id_from_users(user1, user2) - try: - return cls.objects.get(pk=pk) - except cls.DoesNotExist: - chat = cls.objects.create(pk=pk) + chat, created = cls.objects.get_or_create(pk=pk) + if created: chat.users.set([user1, user2]) - return chat + return chat def get_last_messages(self, message_count): - return self.messages.order_by("-created_at")[:message_count] + return self.visible_messages()[:message_count] def get_other_user(self, user) -> User: return self.users.exclude(pk=user.pk).first() - @classmethod - def create_from_two_users(cls, user1, user2): - chat = cls.objects.create(pk=cls.get_chat_id_from_users(user1, user2)) - chat.users.set([user1, user2]) - return chat - @classmethod def get_chat_id_from_users(cls, user1, user2): first_user = user1 if user1.pk < user2.pk else user2 @@ -183,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 @@ -198,7 +247,42 @@ class BaseMessage(models.Model): is_read = models.BooleanField(default=False) is_deleted = models.BooleanField(default=False) is_edited = models.BooleanField(default=False) + client_message_id = models.UUIDField(null=True, blank=True, editable=False) created_at = models.DateTimeField(auto_now_add=True) + sync_revision = models.PositiveBigIntegerField(null=True, editable=False) + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + if update_fields is not None and not { + "text", + "is_deleted", + "is_edited", + "reply_to", + "reply_to_id", + }.intersection(update_fields): + return super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) + chat_model = self._meta.get_field("chat").related_model + using = using or router.db_for_write(type(self), instance=self) + with transaction.atomic(using=using): + chats = chat_model.objects.using(using) + # Serialize the revision with the message write in the same transaction. + chat = chats.select_for_update().get(pk=self.chat_id) + self.sync_revision = chat.message_revision + 1 + chats.filter(pk=chat.pk).update(message_revision=self.sync_revision) + if update_fields is not None: + update_fields = {*update_fields, "sync_revision"} + return super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) def __str__(self): return f"Message<{self.pk}>" @@ -234,6 +318,7 @@ class ProjectChatMessage(BaseMessage): blank=True, related_name="project_replies", ) + mentioned_user_ids = models.JSONField(default=list, blank=True, editable=False) def clean(self): # check that replied message is in the same chat @@ -246,6 +331,32 @@ def __str__(self): class Meta: verbose_name = "Сообщение в чате проекта" verbose_name_plural = "Сообщения в чатах проектов" + indexes = [ + models.Index(fields=["chat", "sync_revision"], name="project_chat_sync_idx") + ] + constraints = [ + models.UniqueConstraint( + fields=["chat", "author", "client_message_id"], + condition=models.Q(client_message_id__isnull=False), + name="unique_project_chat_client_message", + ) + ] + + +class ProjectChatMessageRead(models.Model): + message = models.ForeignKey( + ProjectChatMessage, on_delete=models.CASCADE, related_name="read_receipts" + ) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + class Meta: + verbose_name = "Прочтение сообщения в чате проекта" + verbose_name_plural = "Прочтения сообщений в чатах проектов" + constraints = [ + models.UniqueConstraint( + fields=["message", "user"], name="unique_project_chat_message_reader" + ) + ] class DirectChatMessage(BaseMessage): @@ -284,6 +395,182 @@ def __str__(self): class Meta: verbose_name = "Сообщение в личном чате" verbose_name_plural = "Сообщения в личных чатах" + indexes = [ + models.Index(fields=["chat", "sync_revision"], name="direct_chat_sync_idx") + ] + constraints = [ + models.UniqueConstraint( + fields=["chat", "author", "client_message_id"], + condition=models.Q(client_message_id__isnull=False), + name="unique_direct_chat_client_message", + ) + ] + + +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", + on_delete=models.CASCADE, + primary_key=True, + verbose_name="Программа", + ) + + def __str__(self): + return f"Канал программы №{self.pk}" + + class Meta: + verbose_name = "Канал программы" + verbose_name_plural = "Каналы программ" + + +class ProgramChannelMessage(BaseMessage): + chat = models.ForeignKey( + ProgramChannel, on_delete=models.CASCADE, related_name="messages" + ) + author = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="program_channel_posts" + ) + mentioned_user_ids = models.JSONField(default=list, blank=True, editable=False) + + class Meta: + verbose_name = "Публикация канала" + verbose_name_plural = "Публикации каналов" + indexes = [ + models.Index( + fields=["chat", "sync_revision"], name="program_channel_sync_idx" + ) + ] + constraints = [ + models.UniqueConstraint( + fields=["chat", "author", "client_message_id"], + condition=models.Q(client_message_id__isnull=False), + name="unique_program_channel_client_message", + ) + ] + + +class ProgramChannelRead(models.Model): + channel = models.ForeignKey(ProgramChannel, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + last_message_id = models.PositiveBigIntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["channel", "user"], name="unique_program_channel_reader" + ) + ] + + +class ChatDraft(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + direct_chat = models.ForeignKey(DirectChat, on_delete=models.CASCADE, null=True) + project_chat = models.ForeignKey(ProjectChat, on_delete=models.CASCADE, null=True) + program_channel = models.ForeignKey( + ProgramChannel, on_delete=models.CASCADE, null=True + ) + text = models.TextField(max_length=8192, blank=True) + revision = models.PositiveBigIntegerField(default=0) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + models.Q( + direct_chat__isnull=False, + project_chat__isnull=True, + program_channel__isnull=True, + ) + | models.Q( + direct_chat__isnull=True, + project_chat__isnull=False, + program_channel__isnull=True, + ) + | models.Q( + direct_chat__isnull=True, + project_chat__isnull=True, + program_channel__isnull=False, + ) + ), + name="chat_draft_has_one_chat", + ), + models.UniqueConstraint( + fields=["user", "direct_chat"], name="unique_direct_chat_draft_user" + ), + models.UniqueConstraint( + fields=["user", "project_chat"], name="unique_project_chat_draft_user" + ), + models.UniqueConstraint( + fields=["user", "program_channel"], + name="unique_program_channel_draft_user", + ), + ] + + +class ChatNotificationPreference(models.Model): + class Mode(models.TextChoices): + ALL = "all", "Все сообщения" + MENTIONS = "mentions", "Только упоминания" + MUTE_8H = "mute_8h", "Без звука на 8 часов" + MUTED = "muted", "Отключены до включения" + + user = models.ForeignKey(User, on_delete=models.CASCADE) + project_chat = models.ForeignKey(ProjectChat, on_delete=models.CASCADE, null=True) + program_channel = models.ForeignKey( + ProgramChannel, on_delete=models.CASCADE, null=True + ) + mode = models.CharField("Режим уведомлений", max_length=12, choices=Mode.choices) + muted_until = models.DateTimeField(null=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + models.Q(project_chat__isnull=False, program_channel__isnull=True) + | models.Q(project_chat__isnull=True, program_channel__isnull=False) + ), + name="chat_notification_has_one_chat", + ), + models.CheckConstraint( + condition=( + models.Q(mode="mute_8h", muted_until__isnull=False) + | (~models.Q(mode="mute_8h") & models.Q(muted_until__isnull=True)) + ), + name="chat_notification_mute_has_expiry", + ), + models.UniqueConstraint( + fields=["user", "project_chat"], + name="unique_project_chat_notification_user", + ), + models.UniqueConstraint( + fields=["user", "program_channel"], + name="unique_channel_notification_user", + ), + ] class FileToMessage(models.Model): @@ -306,6 +593,31 @@ class FileToMessage(models.Model): related_name="file_to_message", null=True, ) + channel_message = models.ForeignKey( + ProgramChannelMessage, + on_delete=models.CASCADE, + related_name="file_to_message", + null=True, + blank=True, + ) + + @property + def download_id(self): + return sha256(self.file_id.encode()).hexdigest() + + @property + def is_linked_to_one_message(self): + return ( + sum( + message_id is not None + for message_id in ( + self.direct_message_id, + self.project_message_id, + self.channel_message_id, + ) + ) + == 1 + ) def __str__(self): return f"FileToMessage<{self.file}>" diff --git a/chats/notification_views.py b/chats/notification_views.py new file mode 100644 index 00000000..9ce2074d --- /dev/null +++ b/chats/notification_views.py @@ -0,0 +1,71 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import GenericAPIView, ListAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from chats.attachments import get_accessible_chat +from chats.models import ProgramChannel +from chats.notifications import ( + Mode, + chat_mention_recipients, + notification_settings, + set_notification_mode, +) +from chats.pagination import MessageListPagination +from chats.permissions import can_publish_in_program_channel +from users.serializers import UserChatSerializer + + +class NotificationModeInput(serializers.Serializer): + mode = serializers.ChoiceField( + choices=Mode.choices, + error_messages={ + "invalid_choice": "Выберите режим уведомлений.", + "required": "Выберите режим уведомлений.", + }, + ) + + +@method_decorator(never_cache, name="dispatch") +class ChatNotificationSettingsView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = NotificationModeInput + chat_model = None + + def get(self, request, id): + chat = get_accessible_chat(request.user, self.chat_model, id) + return Response(notification_settings(request.user, self.chat_model, chat.pk)) + + def put(self, request, id): + form = self.get_serializer(data=request.data) + form.is_valid(raise_exception=True) + return Response( + set_notification_mode( + request.user, self.chat_model, id, form.validated_data["mode"] + ) + ) + + +@method_decorator(never_cache, name="dispatch") +class ChatMentionRecipientsView(ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = UserChatSerializer + pagination_class = MessageListPagination + chat_model = None + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return self.serializer_class.Meta.model.objects.none() + chat = get_accessible_chat(self.request.user, self.chat_model, self.kwargs["id"]) + if isinstance(chat, ProgramChannel) and not can_publish_in_program_channel( + self.request.user, chat.program + ): + raise PermissionDenied("Упоминания в канале доступны менеджерам программы.") + return ( + chat_mention_recipients(chat) + .exclude(pk=self.request.user.pk) + .order_by("first_name", "last_name", "pk") + ) diff --git a/chats/notifications.py b/chats/notifications.py new file mode 100644 index 00000000..1aae9a67 --- /dev/null +++ b/chats/notifications.py @@ -0,0 +1,71 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.db.models import Q +from django.utils import timezone + +from chats.attachments import get_accessible_chat +from chats.exceptions import MessageValidationException +from chats.models import ChatNotificationPreference, ProjectChat + +MAX_MENTIONS = 20 +Mode = ChatNotificationPreference.Mode + + +def _chat_field(chat_model): + return "project_chat_id" if chat_model is ProjectChat else "program_channel_id" + + +def notification_settings(user, chat_model, chat_id): + preference = ChatNotificationPreference.objects.filter( + user=user, **{_chat_field(chat_model): chat_id} + ).first() + if preference is None or ( + preference.muted_until is not None and preference.muted_until <= timezone.now() + ): + return {"mode": Mode.ALL, "muted_until": None} + return {"mode": preference.mode, "muted_until": preference.muted_until} + + +def set_notification_mode(user, chat_model, chat_id, mode): + chat = get_accessible_chat(user, chat_model, chat_id) + if mode not in Mode.values: + raise MessageValidationException("Выберите режим уведомлений.") + muted_until = timezone.now() + timedelta(hours=8) if mode == Mode.MUTE_8H else None + ChatNotificationPreference.objects.update_or_create( + user=user, + **{_chat_field(chat_model): chat.pk}, + defaults={"mode": mode, "muted_until": muted_until}, + ) + return {"mode": mode, "muted_until": muted_until} + + +def chat_mention_recipients(chat): + users = get_user_model().objects + if isinstance(chat, ProjectChat): + return users.filter( + Q(pk=chat.project.leader_id) | Q(collaborations__project_id=chat.project_id) + ).distinct() + return users.filter( + Q(pk__in=chat.program.managers.values("pk")) + | Q(partner_program_profiles__partner_program_id=chat.program_id) + ).distinct() + + +def validate_mentions(chat, author, mentioned_user_ids): + if not isinstance(mentioned_user_ids, list) or len(mentioned_user_ids) > MAX_MENTIONS: + raise MessageValidationException("Можно упомянуть до 20 участников.") + if any(type(pk) is not int or not 0 < pk < 2**63 for pk in mentioned_user_ids): + raise MessageValidationException("Некорректный список упоминаний.") + ids = set(mentioned_user_ids) + ids.discard(author.pk) + if ids and chat_mention_recipients(chat).filter(pk__in=ids).count() != len(ids): + raise MessageValidationException("Упоминать можно только участников этого чата.") + return sorted(ids) + + +def should_notify(user, chat_model, chat_id, *, author_id, mentioned_user_ids): + if user.pk == author_id: + return False + mode = notification_settings(user, chat_model, chat_id)["mode"] + return mode == Mode.ALL or (mode == Mode.MENTIONS and user.pk in mentioned_user_ids) diff --git a/chats/permissions.py b/chats/permissions.py index d004cd47..4c5537bf 100644 --- a/chats/permissions.py +++ b/chats/permissions.py @@ -1,11 +1,45 @@ from django.contrib.auth import get_user_model +from django.db.models import Q from rest_framework.permissions import BasePermission +from partner_programs.models import PartnerProgram +from partner_programs.permissions import active_programs from projects.models import Project User = get_user_model() +def accessible_chats(user, chat_model): + from chats.models import DirectChat, ProgramChannel + + if not user.is_authenticated: + return chat_model.objects.none() + if chat_model is DirectChat: + return user.direct_chats.all() + if chat_model is ProgramChannel: + programs = active_programs(PartnerProgram.objects.filter(draft=False)) + programs = programs.filter( + Q(managers=user) | Q(partner_program_profiles__user=user) + ) + return ProgramChannel.objects.filter(pk__in=programs.values("pk")) + return user.get_project_chats() + + +def can_publish_in_program_channel(user, program): + return ( + user.is_authenticated + and active_programs( + PartnerProgram.objects.filter(pk=program.pk, draft=False, managers=user) + ).exists() + ) + + +def direct_chat_is_accessible(user, chat_id): + from chats.models import DirectChat + + return accessible_chats(user, DirectChat).filter(pk=chat_id).exists() + + def get_lookup_kwarg(request, view, *names): view_kwargs = getattr(view, "kwargs", {}) or {} parser_kwargs = (getattr(request, "parser_context", None) or {}).get("kwargs", {}) diff --git a/chats/program_channels.py b/chats/program_channels.py new file mode 100644 index 00000000..006b939a --- /dev/null +++ b/chats/program_channels.py @@ -0,0 +1,90 @@ +from django.db import transaction +from django.db.models import BigIntegerField, Count, OuterRef, Subquery, Value +from django.db.models.functions import Coalesce + +from chats.channel_events import notify_channel_change +from chats.exceptions import MessageValidationException +from chats.models import ProgramChannel, ProgramChannelMessage, ProgramChannelRead +from chats.permissions import accessible_chats, can_publish_in_program_channel +from chats.serializers import ProgramChannelMessageSerializer +from chats.services.messages import _create_message +from files.service import is_storage_url + +MAX_CHANNEL_ATTACHMENTS = 20 + + +def channel_unread_messages(user_id): + read = ProgramChannelRead.objects.filter( + channel_id=OuterRef("chat_id"), user_id=user_id + ).values("last_message_id") + return ( + ProgramChannelMessage.objects.filter( + is_deleted=False, + pk__gt=Coalesce(Subquery(read), Value(0), output_field=BigIntegerField()), + ) + .exclude(author_id=user_id) + .order_by() + ) + + +def with_channel_unreads(channels, user_id): + unread = channel_unread_messages(user_id).filter(chat_id=OuterRef("pk")) + totals = unread.values("chat_id").annotate(total=Count("pk")).values("total") + return channels.annotate( + unread_count=Coalesce(Subquery(totals), Value(0)), + first_unread_message_id=Subquery(unread.order_by("pk").values("pk")[:1]), + ) + + +def _locked_channel(user, channel_id): + return ( + ProgramChannel.objects.select_related("program") + .select_for_update(of=("self",)) + .filter(pk=channel_id, pk__in=accessible_chats(user, ProgramChannel).values("pk")) + .first() + ) + + +@transaction.atomic +def publish_channel_message( + *, channel_id, author, text, file_urls, client_message_id, mentioned_user_ids=None +): + channel = _locked_channel(author, channel_id) + if channel is None or not can_publish_in_program_channel(author, channel.program): + raise MessageValidationException("Публикации доступны менеджерам программы.") + if len(file_urls) > MAX_CHANNEL_ATTACHMENTS: + raise MessageValidationException("К публикации можно прикрепить до 20 файлов.") + if not all(is_storage_url(url) for url in file_urls): + raise MessageValidationException( + "Один или несколько файлов недоступны для отправки." + ) + publication, created = _create_message( + message_model=ProgramChannelMessage, + serializer_class=ProgramChannelMessageSerializer, + message_link_field="channel_message", + chat_id=channel.pk, + author=author, + text=text, + reply_to_id=None, + file_urls=file_urls, + client_message_id=client_message_id, + mention_chat=channel, + mentioned_user_ids=mentioned_user_ids, + ) + if created: + notify_channel_change(channel.pk, new_message_id=publication["id"]) + return publication, created + + +@transaction.atomic +def mark_channel_read(*, channel_id, user, message_id): + channel = _locked_channel(user, channel_id) + if channel is None or not channel.visible_messages().filter(pk=message_id).exists(): + raise MessageValidationException("Публикация недоступна.") + receipt, created = ProgramChannelRead.objects.get_or_create( + channel=channel, user=user, defaults={"last_message_id": message_id} + ) + if not created and receipt.last_message_id < message_id: + receipt.last_message_id = message_id + receipt.save(update_fields=["last_message_id"]) + return receipt.last_message_id diff --git a/chats/project_reads.py b/chats/project_reads.py new file mode 100644 index 00000000..a073310c --- /dev/null +++ b/chats/project_reads.py @@ -0,0 +1,122 @@ +from channels.layers import get_channel_layer +from django.db import transaction +from django.db.models import Count, Exists, OuterRef, Prefetch, Subquery, Value +from django.db.models.functions import Coalesce + +from chats.exceptions import MessageValidationException +from chats.models import ProjectChatMessage, ProjectChatMessageRead +from chats.permissions import is_project_member +from chats.websockets_settings import ChatType, EventGroupType, EventType + + +def unread_project_messages(user_id): + receipts = ProjectChatMessageRead.objects.filter( + message_id=OuterRef("pk"), user_id=user_id + ) + # The old shared flag cannot identify readers. Preserve it as a legacy baseline. + return ( + ProjectChatMessage.objects.filter(is_deleted=False, is_read=False) + .exclude(author_id=user_id) + .filter(~Exists(receipts)) + ) + + +def with_project_chat_unreads(queryset, user_id): + unread = unread_project_messages(user_id).filter(chat_id=OuterRef("pk")) + totals = unread.order_by().values("chat_id").annotate(total=Count("pk")) + return queryset.annotate( + unread_count=Coalesce(Subquery(totals.values("total")), Value(0)), + first_unread_message_id=Subquery( + unread.order_by("created_at", "pk").values("pk")[:1] + ), + ) + + +def with_project_message_reads(queryset, user_id, *, include_reply=False): + receipts = ProjectChatMessageRead.objects.filter(message_id=OuterRef("pk")) + totals = receipts.order_by().values("message_id").annotate(total=Count("pk")) + queryset = queryset.annotate( + read_count=Coalesce(Subquery(totals.values("total")), Value(0)), + read_by_me=Exists(receipts.filter(user_id=user_id)), + ) + if include_reply: + replies = with_project_message_reads( + ProjectChatMessage.objects.select_related("author"), user_id + ) + queryset = queryset.prefetch_related(Prefetch("reply_to", queryset=replies)) + return queryset + + +def project_message_is_read(*, legacy_read, is_author, read_by_me, read_count) -> bool: + return legacy_read or (read_count > 0 if is_author else read_by_me) + + +def personalize_project_message_event(message, user_id): + # New and edited messages are broadcast with a shared payload. Project the + # personal flags at the socket so an edit cannot reset somebody's read state. + message = dict(message) + message_parts = [message] + if message.get("reply_to") is not None: + message["reply_to"] = dict(message["reply_to"]) + message_parts.append(message["reply_to"]) + states = with_project_message_reads( + ProjectChatMessage.objects.filter(pk__in=[part["id"] for part in message_parts]), + user_id, + ).values("id", "author_id", "is_read", "read_by_me", "read_count") + states_by_id = {state["id"]: state for state in states} + for part in message_parts: + state = states_by_id.get(part["id"]) + if state is None: + continue + part["read_count"] = state["read_count"] + part["is_read"] = project_message_is_read( + legacy_read=state["is_read"], + is_author=state["author_id"] == user_id, + read_by_me=state["read_by_me"], + read_count=state["read_count"], + ) + return message + + +@transaction.atomic +def mark_project_message_read(*, chat_id, message_id, user): + if type(message_id) not in (int, str): + raise MessageValidationException("Некорректный идентификатор сообщения.") + try: + message = ( + ProjectChatMessage.objects.select_related("chat__project") + .select_for_update(of=("self",)) + .get(pk=message_id, chat_id=chat_id, is_deleted=False) + ) + except (ProjectChatMessage.DoesNotExist, TypeError, ValueError) as error: + raise MessageValidationException("Сообщение недоступно для прочтения.") from error + + if not is_project_member(user, message.chat.project): + raise MessageValidationException("У вас нет доступа к чату проекта.") + + if message.author_id == user.pk: + return message.author_id, 0, False + _, created = ProjectChatMessageRead.objects.get_or_create(message=message, user=user) + return message.author_id, message.read_receipts.count(), created + + +async def publish_project_message_read( + *, chat_id, message_id, user_id, author_id, read_count, sender_channel=None +): + event = { + "type": EventType.READ_MESSAGE, + "content": { + "chat_id": chat_id, + "chat_type": ChatType.PROJECT, + "user_id": user_id, + "message_id": message_id, + "read_count": read_count, + }, + "_project_chat_id": chat_id, + "_project_read_audience": [user_id, author_id], + } + layer = get_channel_layer() + if sender_channel is None: + await layer.group_send(f"{EventGroupType.CHATS_RELATED}_{int(chat_id)}", event) + else: + await layer.send(sender_channel, event) diff --git a/chats/search.py b/chats/search.py new file mode 100644 index 00000000..42d6ff9e --- /dev/null +++ b/chats/search.py @@ -0,0 +1,82 @@ +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.attachments import ATTACHMENT_FIELDS, chat_attachment_field +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 search_chat_messages(chat, filters): + query = filters["q"] + scope = filters["scope"] + author_id = filters["author_id"] + messages = chat.visible_messages() + 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): + message_field = chat_attachment_field(chat) + filters = { + "file_to_message__" + field + "__isnull": True + for field in ATTACHMENT_FIELDS.values() + if field != message_field + } + filters["file_to_message__" + message_field + "_id"] = OuterRef("pk") + return UserFile.objects.filter(**filters) diff --git a/chats/serializers.py b/chats/serializers.py index 27c8cc70..52b04e26 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -1,16 +1,61 @@ from rest_framework import serializers +from chats.attachments import attachment_download_url from chats.models import ( DirectChat, + FileToMessage, ProjectChat, DirectChatMessage, ProjectChatMessage, + ProgramChannelMessage, ) +from chats.project_reads import project_message_is_read, with_project_message_reads from files.serializers import UserFileSerializer from users.serializers import UserListSerializer, UserDetailSerializer, UserChatSerializer +class ChatAttachmentSerializer(serializers.ModelSerializer): + name = serializers.CharField(source="file.name", read_only=True) + extension = serializers.CharField(source="file.extension", read_only=True) + mime_type = serializers.CharField(source="file.mime_type", read_only=True) + size = serializers.IntegerField(source="file.size", read_only=True) + user = serializers.IntegerField( + source="file.user_id", read_only=True, allow_null=True + ) + datetime_uploaded = serializers.DateTimeField( + source="file.datetime_uploaded", read_only=True + ) + link = serializers.SerializerMethodField() + + def get_link(self, attachment) -> str: + message = ( + self.context.get("message") + or attachment.channel_message + or attachment.project_message + or attachment.direct_message + ) + return attachment_download_url(attachment, message) + + class Meta: + model = FileToMessage + fields = UserFileSerializer.Meta.fields + + +def serialize_message_attachments(message): + if message.is_deleted: + return [] + attachments = [ + item for item in message.file_to_message.all() if item.is_linked_to_one_message + ] + return ChatAttachmentSerializer( + attachments, many=True, context={"message": message} + ).data + + class DirectChatListSerializer(serializers.ModelSerializer): + has_draft = serializers.BooleanField(read_only=True) + unread_count = serializers.IntegerField(read_only=True) + first_unread_message_id = serializers.IntegerField(read_only=True, allow_null=True) last_message = serializers.SerializerMethodField() opponent = serializers.SerializerMethodField() name = serializers.SerializerMethodField(read_only=True) @@ -32,15 +77,54 @@ def get_image_address(self, chat: DirectChat): @classmethod def get_last_message(cls, chat: DirectChat): - return DirectChatMessageListSerializer(chat.get_last_message()).data + message = ( + chat.visible_messages() + .select_related("author", "reply_to__author") + .prefetch_related("file_to_message__file") + .first() + ) + return DirectChatMessageListSerializer(message).data class Meta: model = DirectChat - fields = ["id", "opponent", "last_message", "name", "image_address"] + fields = [ + "id", + "opponent", + "last_message", + "name", + "image_address", + "has_draft", + "unread_count", + "first_unread_message_id", + ] + + +class ProgramChannelMessageSerializer(serializers.ModelSerializer): + author = UserChatSerializer(allow_null=True) + files = serializers.SerializerMethodField() + + @staticmethod + def get_files(message): + return serialize_message_attachments(message) + + class Meta: + model = ProgramChannelMessage + fields = [ + "id", + "author", + "text", + "client_message_id", + "mentioned_user_ids", + "files", + "is_edited", + "created_at", + ] class DirectChatDetailSerializer(serializers.ModelSerializer): opponent = serializers.SerializerMethodField() + unread_count = serializers.IntegerField(read_only=True) + first_unread_message_id = serializers.IntegerField(read_only=True, allow_null=True) def get_opponent(self, chat: DirectChat): user = self.context.get("opponent") @@ -53,13 +137,18 @@ class Meta: fields = [ "id", "opponent", + "unread_count", + "first_unread_message_id", ] class ProjectChatListSerializer(serializers.ModelSerializer): + has_draft = serializers.BooleanField(read_only=True) last_message = serializers.SerializerMethodField(read_only=True) name = serializers.SerializerMethodField(read_only=True) image_address = serializers.SerializerMethodField(read_only=True) + unread_count = serializers.IntegerField(read_only=True) + first_unread_message_id = serializers.IntegerField(read_only=True, allow_null=True) @classmethod def get_image_address(cls, chat: ProjectChat): @@ -69,19 +158,34 @@ def get_image_address(cls, chat: ProjectChat): def get_name(cls, chat: ProjectChat): return chat.project.name - @classmethod - def get_last_message(cls, chat: ProjectChat): - return ProjectChatMessageListSerializer(chat.get_last_message()).data + def get_last_message(self, chat: ProjectChat): + user_id = self.context["request"].user.pk + messages = chat.visible_messages() + message = with_project_message_reads( + messages, user_id, include_reply=True + ).first() + return ProjectChatMessageListSerializer(message, context=self.context).data class Meta: model = ProjectChat - fields = ["id", "project", "last_message", "image_address", "name"] + fields = [ + "id", + "project", + "last_message", + "image_address", + "name", + "unread_count", + "first_unread_message_id", + "has_draft", + ] class ProjectChatDetailSerializer(serializers.ModelSerializer): users = serializers.SerializerMethodField(read_only=True) name = serializers.SerializerMethodField(read_only=True) image_address = serializers.SerializerMethodField(read_only=True) + unread_count = serializers.IntegerField(read_only=True) + first_unread_message_id = serializers.IntegerField(read_only=True, allow_null=True) @classmethod def get_image_address(cls, chat: ProjectChat): @@ -103,10 +207,20 @@ class Meta: "name", "image_address", "users", + "unread_count", + "first_unread_message_id", ] -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. """ @@ -135,11 +249,7 @@ class DirectChatMessageListSerializer(serializers.ModelSerializer): @classmethod def get_files(cls, message: DirectChatMessage): - data = [] - for file_to_message in message.file_to_message.all(): - file_data = UserFileSerializer(file_to_message.file).data - data.append(file_data) - return data + return serialize_message_attachments(message) class Meta: model = DirectChatMessage @@ -147,6 +257,7 @@ class Meta: "id", "author", "text", + "client_message_id", "reply_to", "files", "is_edited", @@ -162,6 +273,17 @@ class ProjectChatMessageSerializer(serializers.ModelSerializer): """ author = UserDetailSerializer() + is_read = serializers.SerializerMethodField() + read_count = serializers.IntegerField(read_only=True, default=0) + + def get_is_read(self, message) -> bool: + request = self.context.get("request") + return project_message_is_read( + legacy_read=message.is_read, + is_author=request is not None and message.author_id == request.user.pk, + read_by_me=getattr(message, "read_by_me", False), + read_count=getattr(message, "read_count", 0), + ) class Meta: model = ProjectChatMessage @@ -172,23 +294,23 @@ class Meta: "reply_to", "is_edited", "is_read", + "read_count", "is_deleted", "created_at", ] -class ProjectChatMessageListSerializer(serializers.ModelSerializer): - author = UserDetailSerializer() - reply_to = ProjectChatMessageSerializer(allow_null=True) +class ProjectChatReplySerializer(ProjectChatMessageSerializer, ChatReplySerializer): + pass + + +class ProjectChatMessageListSerializer(ProjectChatMessageSerializer): + reply_to = ProjectChatReplySerializer(allow_null=True) files = serializers.SerializerMethodField() @classmethod - def get_files(cls, message: DirectChatMessage): - data = [] - for file_to_message in message.file_to_message.all(): - file_data = UserFileSerializer(file_to_message.file).data - data.append(file_data) - return data + def get_files(cls, message: ProjectChatMessage): + return serialize_message_attachments(message) class Meta: model = ProjectChatMessage @@ -196,10 +318,13 @@ class Meta: "id", "author", "text", + "client_message_id", + "mentioned_user_ids", "files", "reply_to", "is_edited", "is_read", + "read_count", "is_deleted", "created_at", ] diff --git a/chats/services/__init__.py b/chats/services/__init__.py new file mode 100644 index 00000000..c3ec9741 --- /dev/null +++ b/chats/services/__init__.py @@ -0,0 +1,6 @@ +from chats.services.messages import ( + create_direct_message, + create_project_message, +) + +__all__ = ["create_direct_message", "create_project_message"] diff --git a/chats/services/messages.py b/chats/services/messages.py new file mode 100644 index 00000000..b35ee91e --- /dev/null +++ b/chats/services/messages.py @@ -0,0 +1,311 @@ +from uuid import UUID + +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, + DirectChatMessage, + FileToMessage, + ProgramChannel, + ProgramChannelMessage, + ProjectChat, + ProjectChatMessage, +) +from chats.permissions import ( + accessible_chats, + can_publish_in_program_channel, + is_project_member, +) +from chats.notifications import validate_mentions +from chats.serializers import ( + DirectChatMessageListSerializer, + ProgramChannelMessageSerializer, + ProjectChatMessageListSerializer, +) +from files.models import UserFile + + +def _normalize_client_message_id(value): + if value in (None, ""): + return None + try: + return UUID(str(value)) + except (TypeError, ValueError, AttributeError) as error: + raise MessageValidationException( + "Некорректный идентификатор отправляемого сообщения." + ) from error + + +def _normalize_message(text, file_urls): + if not isinstance(text, str) or "\x00" in text: + raise MessageValidationException("Некорректный текст сообщения.") + if file_urls is None: + file_urls = [] + if not isinstance(file_urls, list) or any( + not isinstance(url, str) or not url or "\x00" in url for url in file_urls + ): + raise MessageValidationException("Некорректный список вложений.") + if not text.strip() and not file_urls: + raise MessageValidationException("Сообщение не может быть пустым.") + if len(text) > DirectChatMessage._meta.get_field("text").max_length: + raise MessageValidationException( + "Текст сообщения не должен превышать 8192 символа." + ) + return list(dict.fromkeys(file_urls)) + + +def _get_reply(message_model, chat_id, reply_to_id): + if reply_to_id is None: + return None + try: + return message_model.objects.get( + pk=reply_to_id, + chat_id=chat_id, + is_deleted=False, + ) + except (message_model.DoesNotExist, TypeError, ValueError) as error: + raise MessageValidationException( + "Нельзя ответить на сообщение из другого чата или удаленное сообщение." + ) from error + + +def _get_files(file_urls, author): + if not file_urls: + return [] + files = { + user_file.pk: user_file + for user_file in UserFile.objects.select_for_update() + .filter( + pk__in=file_urls, + user_id=author.pk, + ) + .order_by("pk") + } + if len(files) != len(file_urls): + raise MessageValidationException( + "Один или несколько файлов недоступны для отправки." + ) + if FileToMessage.objects.filter(file_id__in=file_urls).exists(): + raise MessageValidationException( + "Один или несколько файлов уже прикреплены к сообщению." + ) + return [files[url] for url in file_urls] + + +def _serialize_message(message_model, serializer_class, message_id): + messages = message_model.objects.select_related("author").prefetch_related( + "file_to_message__file" + ) + if message_model is not ProgramChannelMessage: + messages = messages.select_related("reply_to__author") + message = messages.get(pk=message_id) + return serializer_class(message).data + + +def _create_message( + *, + message_model, + serializer_class, + message_link_field, + chat_id, + author, + text, + reply_to_id, + file_urls, + client_message_id, + validate_new_message=None, + mention_chat=None, + mentioned_user_ids=None, +): + client_message_id = _normalize_client_message_id(client_message_id) + if client_message_id is not None: + existing_message = message_model.objects.filter( + chat_id=chat_id, + author=author, + client_message_id=client_message_id, + ).first() + if existing_message is not None: + if existing_message.is_deleted: + raise MessageValidationException("Сообщение уже удалено.") + return ( + _serialize_message( + message_model, + serializer_class, + existing_message.pk, + ), + 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: + reply["reply_to"] = _get_reply(message_model, chat_id, reply_to_id) + files = _get_files(file_urls, author) + if mention_chat is not None: + reply["mentioned_user_ids"] = validate_mentions( + mention_chat, author, [] if mentioned_user_ids is None else mentioned_user_ids + ) + message = message_model.objects.create( + chat_id=chat_id, + author=author, + text=text, + client_message_id=client_message_id, + **reply, + ) + for user_file in files: + FileToMessage.objects.create(file=user_file, **{message_link_field: message}) + return _serialize_message(message_model, serializer_class, message.pk), True + + +@transaction.atomic +def create_direct_message( + *, + author, + other_user, + text, + reply_to_id=None, + 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) + return _create_message( + message_model=DirectChatMessage, + serializer_class=DirectChatMessageListSerializer, + message_link_field="direct_message", + chat_id=chat.pk, + author=author, + text=text, + 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), + ) + + +@transaction.atomic +def get_direct_chat_for_participants(user, other_user): + chat = DirectChat.get_chat(user, other_user) + participants = set(chat.users.values_list("pk", flat=True)) + if participants != {user.pk, other_user.pk}: + raise MessageValidationException("Чат недоступен.") + 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) + + +@transaction.atomic +def create_project_message( + *, + chat_id, + author, + text, + reply_to_id=None, + file_urls=None, + client_message_id=None, + mentioned_user_ids=None, +): + try: + chat = ( + ProjectChat.objects.select_related("project") + .select_for_update(of=("self",)) + .get(pk=chat_id) + ) + except (ProjectChat.DoesNotExist, TypeError, ValueError) as error: + raise MessageValidationException("У вас нет доступа к чату проекта.") from error + if not is_project_member(author, chat.project): + raise MessageValidationException("У вас нет доступа к чату проекта.") + return _create_message( + message_model=ProjectChatMessage, + serializer_class=ProjectChatMessageListSerializer, + message_link_field="project_message", + chat_id=chat.pk, + author=author, + text=text, + reply_to_id=reply_to_id, + file_urls=file_urls, + client_message_id=client_message_id, + mention_chat=chat, + mentioned_user_ids=mentioned_user_ids, + ) + + +def _get_message_for_change(chat_model, chat_id, message_id, author): + if type(message_id) not in (str, int): + raise MessageValidationException("Некорректный идентификатор сообщения.") + chat = ( + chat_model.objects.select_for_update() + .filter(pk=chat_id, pk__in=accessible_chats(author, chat_model).values("pk")) + .first() + ) + if chat is None: + raise MessageValidationException("Чат недоступен.") + if chat_model is ProgramChannel: + if not can_publish_in_program_channel(author, chat.program): + raise MessageValidationException("Публикации доступны менеджерам программы.") + try: + message = chat.messages.filter(pk=message_id, author=author).first() + except (TypeError, ValueError): + message = None + if message is None: + raise MessageValidationException("Сообщение недоступно для изменения.") + return message + + +@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("Сообщение уже удалено.") + if not isinstance(text, str) or "\x00" in text: + raise MessageValidationException("Некорректный текст сообщения.") + if len(text) > message._meta.get_field("text").max_length: + raise MessageValidationException( + "Текст сообщения не должен превышать 8192 символа." + ) + if not text.strip() and not message.file_to_message.exists(): + raise MessageValidationException("Сообщение не может быть пустым.") + if message.text != text or not message.is_edited: + message.text = text + message.is_edited = True + message.save(update_fields=["text", "is_edited"]) + if chat_model is ProgramChannel: + notify_channel_change(chat_id) + serializer_class = { + DirectChat: DirectChatMessageListSerializer, + ProjectChat: ProjectChatMessageListSerializer, + ProgramChannel: ProgramChannelMessageSerializer, + }[chat_model] + return _serialize_message(type(message), serializer_class, message.pk) + + +@transaction.atomic +def delete_message(*, chat_model, chat_id, message_id, author): + message = _get_message_for_change(chat_model, chat_id, message_id, author) + if not message.is_deleted: + message.is_deleted = True + message.save(update_fields=["is_deleted"]) + if chat_model is ProgramChannel: + notify_channel_change(chat_id) + return message.pk diff --git a/chats/signals.py b/chats/signals.py new file mode 100644 index 00000000..f2d5f822 --- /dev/null +++ b/chats/signals.py @@ -0,0 +1,21 @@ +from django.db.models.deletion import ProtectedError +from django.db.models.signals import post_save, pre_delete +from django.dispatch import receiver + +from chats.attachments import ATTACHMENT_IN_USE, used_by_visible_chat_message +from chats.models import ProgramChannel +from files.models import UserFile +from partner_programs.models import PartnerProgram + + +@receiver(post_save, sender=PartnerProgram) +def create_program_channel(sender, instance, created, raw, using, **kwargs): + if created and not raw: + ProgramChannel.objects.using(using).get_or_create(program=instance) + + +@receiver(pre_delete, sender=UserFile, dispatch_uid="protect_visible_chat_attachment") +def protect_visible_chat_attachment(sender, instance, using, **kwargs): + sender.objects.using(using).select_for_update().filter(pk=instance.pk).first() + if used_by_visible_chat_message(instance.pk, using=using): + raise ProtectedError(ATTACHMENT_IN_USE, [instance]) diff --git a/chats/tests/test_attachment_download.py b/chats/tests/test_attachment_download.py new file mode 100644 index 00000000..eea860fc --- /dev/null +++ b/chats/tests/test_attachment_download.py @@ -0,0 +1,463 @@ +from unittest.mock import Mock, patch +from uuid import uuid4 + +import requests +from channels.testing import HttpCommunicator, WebsocketCommunicator +from django.contrib.auth import get_user_model +from django.contrib.admin import AdminSite +from django.core.exceptions import PermissionDenied +from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import transaction +from django.db.models.deletion import ProtectedError +from django.test import ( + AsyncClient, + AsyncRequestFactory, + SimpleTestCase, + TestCase, + TransactionTestCase, + override_settings, +) +from django.urls import reverse +from django.test import RequestFactory +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +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.downloads import download_stored_file +from files.admin import UserFileAdmin +from files.models import UserFile +from files.views import FileView +from procollab.asgi import application +from projects.models import Collaborator + +STORAGE = "https://storage.example.test/v1/account/media/" +TOKEN = "test-storage-token" + + +def upstream_file(status=200): + upstream = Mock(status_code=status) + upstream.headers = {"Content-Length": "6"} + upstream.iter_content.return_value = iter([b"abc", b"def"]) + return upstream + + +@override_settings(ALLOWED_HOSTS=["testserver"], SELECTEL_SWIFT_URL=STORAGE) +class ChatAttachmentDownloadTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.author = users.create(**TEST_USER1, is_active=True) + 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 + ) + direct = DirectChat.get_chat(cls.author, cls.member) + project = create_project_chat(cls.author, cls.member) + cls.project = project + cls.direct_message = DirectChatMessage.objects.create( + chat=direct, author=cls.author, text="Личное сообщение" + ) + cls.project_message = ProjectChatMessage.objects.create( + chat=project, author=cls.author, text="Сообщение проекта" + ) + cls.direct_attachment = cls.attach(cls.direct_message, "direct") + cls.project_attachment = cls.attach(cls.project_message, "project") + cls.other_chat = create_project_chat(cls.author, cls.member) + + @classmethod + def attach(cls, message, label): + file = UserFile.objects.create( + link=STORAGE + label + ".pdf", + user=cls.author, + name="План проекта", + extension="pdf", + mime_type="application/pdf", + ) + key = ( + "direct_message" + if isinstance(message, DirectChatMessage) + else "project_message" + ) + return FileToMessage.objects.create(file=file, **{key: message}) + + def setUp(self): + self.api = APIClient() + + def authenticate_member(self): + self.api.credentials( + HTTP_AUTHORIZATION=f"Bearer {AccessToken.for_user(self.member)}" + ) + + @staticmethod + def download_url(kind, message, attachment, chat_id=None): + return reverse( + "chats:" + kind + "-chat-attachment-download", + args=[chat_id or message.chat_id, message.pk, attachment.download_id], + ) + + def test_history_and_file_list_return_the_same_protected_link(self): + self.authenticate_member() + for kind, message, attachment in ( + ("direct", self.direct_message, self.direct_attachment), + ("project", self.project_message, self.project_attachment), + ): + with self.subTest(kind=kind): + args = [message.chat_id] + history = self.api.get( + reverse("chats:" + kind + "-chat-messages", args=args), secure=True + ).json() + files = self.api.get( + reverse("chats:" + kind + "-chat-files", args=args), secure=True + ).json() + link = self.download_url(kind, message, attachment) + self.assertEqual(history["results"][0]["files"][0]["link"], link) + self.assertEqual(files[0]["link"], link) + self.assertEqual(files[0]["name"], "План проекта") + self.assertNotIn(STORAGE, str(history) + str(files)) + + def test_published_attachment_is_retained_until_message_is_deleted(self): + self.api.force_authenticate(self.author) + for attachment, message in ( + (self.direct_attachment, self.direct_message), + (self.project_attachment, self.project_message), + ): + with self.subTest(message=message): + query = {"QUERY_STRING": "link=" + attachment.file_id} + with patch.object(FileView.cdn.storage, "delete") as storage_delete: + response = self.api.delete("/files/", secure=True, **query) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["code"], "chat_attachment_in_use") + storage_delete.assert_not_called() + with self.assertRaises(ProtectedError), transaction.atomic(): + UserFile.objects.filter(pk=attachment.file_id).delete() + self.assertTrue( + FileToMessage.objects.filter(pk=attachment.pk).exists() + ) + + message.is_deleted = True + message.save(update_fields=["is_deleted"]) + self.assertEqual( + self.api.delete("/files/", secure=True, **query).status_code, 204 + ) + storage_delete.assert_called_once_with(attachment.file_id) + self.assertFalse(UserFile.objects.filter(pk=attachment.file_id).exists()) + + def test_admin_bulk_delete_checks_every_file_before_contacting_storage(self): + unused = UserFile.objects.create(link=STORAGE + "unused.pdf", user=self.author) + files = UserFile.objects.filter( + pk__in=[unused.pk, self.project_attachment.file_id] + ) + request = RequestFactory().post("/admin/files/userfile/") + request.user = self.member + file_admin = UserFileAdmin(UserFile, AdminSite()) + with patch.object(file_admin.cdn, "delete") as storage_delete: + with self.assertRaises(PermissionDenied): + file_admin.delete_queryset(request, files) + storage_delete.assert_not_called() + self.assertEqual(files.count(), 2) + + def test_attachment_link_admin_is_read_only(self): + self.client.force_login(self.member) + route = "admin:chats_filetomessage" + self.assertEqual( + self.client.get(reverse(route + "_changelist"), secure=True).status_code, 200 + ) + url = reverse(route + "_delete", args=[self.project_attachment.pk]) + self.assertEqual( + self.client.post(url, {"post": "yes"}, secure=True).status_code, 403 + ) + self.assertTrue( + FileToMessage.objects.filter(pk=self.project_attachment.pk).exists() + ) + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) + @patch("files.downloads.requests.get") + def test_members_can_download_through_api_and_admin(self, storage_get, _token): + self.authenticate_member() + self.client.force_login(self.member) + admin_url = reverse( + "admin:chats_projectchat_message_search_download", + args=[ + self.project.pk, + self.project_message.pk, + self.project_attachment.download_id, + ], + ) + search = self.client.get( + reverse("admin:chats_projectchat_message_search", args=[self.project.pk]), + secure=True, + ) + self.assertContains(search, admin_url) + for client, url, expected_file in ( + ( + self.api, + self.download_url("direct", self.direct_message, self.direct_attachment), + self.direct_attachment.file_id, + ), + ( + self.api, + self.download_url( + "project", self.project_message, self.project_attachment + ), + self.project_attachment.file_id, + ), + (self.client, admin_url, self.project_attachment.file_id), + ): + with self.subTest(url=url): + upstream = upstream_file() + storage_get.return_value = upstream + response = client.get(url, secure=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/octet-stream") + self.assertIn("filename*=utf-8''", response["Content-Disposition"]) + self.assertIn("no-store", response["Cache-Control"]) + self.assertEqual(response["Content-Length"], "6") + self.assertEqual(b"".join(response.streaming_content), b"abcdef") + upstream.close.assert_called_once() + storage_get.assert_called_with( + expected_file, + headers={"X-Auth-Token": TOKEN, "Accept-Encoding": "identity"}, + stream=True, + timeout=(5, 15), + allow_redirects=False, + ) + + upstream = upstream_file() + storage_get.return_value = upstream + response = self.api.head( + self.download_url("direct", self.direct_message, self.direct_attachment), + secure=True, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Length"], "6") + self.assertEqual(response.content, b"") + upstream.iter_content.assert_not_called() + upstream.close.assert_called_once() + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token") + @patch("files.downloads.requests.get") + def test_access_and_message_checks_run_before_storage(self, storage_get, token): + url = self.download_url("project", self.project_message, self.project_attachment) + self.assertEqual(self.api.get(url, secure=True).status_code, 401) + self.api.force_authenticate(self.outsider) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + direct_url = self.download_url( + "direct", self.direct_message, self.direct_attachment + ) + self.assertEqual(self.api.get(direct_url, secure=True).status_code, 404) + self.client.force_login(self.outsider) + admin_url = reverse( + "admin:chats_projectchat_message_search_download", + args=[ + self.project.pk, + self.project_message.pk, + self.project_attachment.download_id, + ], + ) + self.assertEqual(self.client.get(admin_url, secure=True).status_code, 404) + + self.api.force_authenticate(self.member) + wrong_chat = self.download_url( + "project", + self.project_message, + self.project_attachment, + chat_id=self.other_chat.pk, + ) + wrong_file = self.download_url( + "project", self.project_message, self.direct_attachment + ) + for unavailable in (wrong_chat, wrong_file): + self.assertEqual(self.api.get(unavailable, secure=True).status_code, 404) + Collaborator.objects.filter( + project=self.project.project, user=self.member + ).delete() + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + self.api.force_authenticate(self.author) + self.project_message.is_deleted = True + self.project_message.save(update_fields=["is_deleted"]) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + files_url = reverse("chats:project-chat-files", args=[self.project.pk]) + self.assertEqual(self.api.get(files_url, secure=True).json(), []) + token.assert_not_called() + storage_get.assert_not_called() + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) + @patch("files.downloads.requests.get") + def test_storage_errors_do_not_redirect_the_client(self, storage_get, _token): + self.authenticate_member() + url = self.download_url("direct", self.direct_message, self.direct_attachment) + for upstream_status, expected in ((404, 404), (302, 502), (503, 502)): + with self.subTest(upstream_status=upstream_status): + upstream = upstream_file(upstream_status) + upstream.headers["Location"] = "https://storage.example.test/redirect" + storage_get.return_value = upstream + response = self.api.get(url, secure=True) + self.assertEqual(response.status_code, expected) + self.assertNotIn("Location", response) + self.assertIn("no-store", response["Cache-Control"]) + upstream.close.assert_called_once() + storage_get.side_effect = requests.Timeout + self.assertEqual(self.api.get(url, secure=True).status_code, 502) + + @patch("files.downloads.SelectelSwiftStorage._get_auth_token") + @patch("files.downloads.requests.get") + def test_download_uses_only_configured_storage(self, storage_get, token): + self.authenticate_member() + file = UserFile.objects.create( + link="https://other.example.test/files/file.pdf", + user=self.author, + ) + attachment = FileToMessage.objects.create( + file=file, direct_message=self.direct_message + ) + url = self.download_url("direct", self.direct_message, attachment) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + token.assert_not_called() + storage_get.assert_not_called() + + +@override_settings(SELECTEL_SWIFT_URL=STORAGE) +class AsyncAttachmentDownloadTests(SimpleTestCase): + @patch("files.downloads.SelectelSwiftStorage._get_auth_token", return_value=TOKEN) + @patch("files.downloads.requests.get") + async def test_asgi_consumes_the_upstream_one_chunk_at_a_time( + self, storage_get, _token + ): + upstream = upstream_file() + storage_get.return_value = upstream + file = UserFile(link=STORAGE + "object", name="План", extension="pdf") + response = download_stored_file(AsyncRequestFactory().get("/"), file) + self.assertTrue(response.is_async) + self.assertEqual( + [chunk async for chunk in response.streaming_content], [b"abc", b"def"] + ) + response.close() + upstream.close.assert_called_once() + + +@override_settings(ALLOWED_HOSTS=["testserver"], SELECTEL_SWIFT_URL=STORAGE.rstrip("/")) +class ChatAttachmentRoundTripTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.author = users.create(**TEST_USER1, is_active=True) + self.reader = users.create(**TEST_USER2, is_active=True) + self.chat = create_project_chat(self.author, self.reader) + + async def send(self, token, payload): + socket = WebsocketCommunicator( + application, "/ws/chat/", subprotocols=["Bearer", token] + ) + self.assertEqual(await socket.connect(timeout=5), (True, "Bearer")) + try: + await socket.send_json_to(payload) + event = await socket.receive_json_from(timeout=5) + self.assertEqual(event["type"], "new_message") + return event["content"] + finally: + await socket.disconnect() + + async def test_upload_reconnect_and_download_keep_original_file(self): + author_token = str(AccessToken.for_user(self.author)) + reader_token = str(AccessToken.for_user(self.reader)) + reader = AsyncClient(authorization=f"Bearer {reader_token}") + history_url = reverse("chats:project-chat-messages", args=[self.chat.pk]) + history = await reader.get(history_url, secure=True) + self.assertEqual(history.status_code, 200) + cursor = history.json()["sync_cursor"] + original = bytes(range(256)) * 300 + uploaded = {} + + def store(url, *, data, **kwargs): + self.assertTrue(url.startswith(STORAGE)) + self.assertEqual(kwargs["headers"]["X-Auth-Token"], TOKEN) + uploaded[url] = data.read() + return Mock(status_code=201) + + def fetch(url, **kwargs): + self.assertEqual(kwargs["headers"]["X-Auth-Token"], TOKEN) + content = uploaded[url] + upstream = Mock( + status_code=200, headers={"Content-Length": str(len(content))} + ) + view = memoryview(content) + upstream.iter_content.side_effect = lambda chunk_size: ( + bytes(view[offset:][:chunk_size]) + for offset in range(0, len(content), chunk_size) + ) + return upstream + + with ( + patch( + "files.service.requests.post", + return_value=Mock(status_code=201, headers={"x-subject-token": TOKEN}), + ), + patch("files.service.requests.put", side_effect=store) as upload, + patch("files.downloads.requests.get", side_effect=fetch) as download, + ): + sender = AsyncClient(authorization=f"Bearer {author_token}") + response = await sender.post( + "/files/?preserve_original=true", + {"file": SimpleUploadedFile("План.bin", original)}, + secure=True, + ) + self.assertEqual(response.status_code, 201) + storage_url = response.json()["url"] + payload = { + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": self.chat.pk, + "client_message_id": str(uuid4()), + "file_urls": [storage_url], + }, + } + first = await self.send(author_token, payload) + replay = await self.send(author_token, payload) + self.assertFalse(first["is_replay"]) + self.assertTrue(replay["is_replay"]) + self.assertEqual(first["message"], replay["message"]) + + changes_url = reverse( + "chats:project-chat-message-changes", args=[self.chat.pk] + ) + changes = await reader.get(changes_url, {"after": cursor}, secure=True) + self.assertEqual(changes.status_code, 200) + (change,) = changes.json()["results"] + self.assertEqual(change["message_id"], first["message"]["id"]) + link = change["message"]["files"][0]["link"] + self.assertTrue(link.startswith("/chats/")) + download_request = HttpCommunicator( + application, + "GET", + link, + headers=[ + (b"host", b"testserver"), + (b"authorization", f"Bearer {reader_token}".encode()), + ], + ) + download_request.scope["scheme"] = "https" + await download_request.send_input({"type": "http.request", "body": b""}) + result = await download_request.receive_output(timeout=5) + self.assertEqual(result["type"], "http.response.start") + self.assertEqual(result["status"], 200) + headers = {name.lower(): value for name, value in result["headers"]} + chunks = [] + while True: + chunk = await download_request.receive_output(timeout=5) + self.assertEqual(chunk["type"], "http.response.body") + chunks.append(chunk.get("body", b"")) + if not chunk.get("more_body"): + break + await download_request.wait(timeout=5) + self.assertGreater(sum(bool(chunk) for chunk in chunks), 1) + self.assertEqual(b"".join(chunks), original) + self.assertEqual(headers[b"content-length"], str(len(original)).encode()) + self.assertIn(b"filename*=utf-8''", headers[b"content-disposition"]) + self.assertIn(b"no-store", headers[b"cache-control"]) + upload.assert_called_once() + download.assert_called_once() + self.assertEqual(await self.chat.messages.acount(), 1) 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/tests/test_drafts.py b/chats/tests/test_drafts.py new file mode 100644 index 00000000..9cf82838 --- /dev/null +++ b/chats/tests/test_drafts.py @@ -0,0 +1,167 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from uuid import uuid4 + +from django.contrib.auth import get_user_model +from django.db import connections +from django.test import ( + TestCase, + TransactionTestCase, + override_settings, + skipUnlessDBFeature, +) +from django.urls import reverse +from rest_framework.test import APIClient + +from chats.drafts import DraftConflict, save_draft +from chats.models import DirectChat +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_program_channels import create_program +from chats.tests.test_project_reads import create_project_chat +from projects.models import Collaborator + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class ChatDraftTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.author = users.create(**TEST_USER1, is_staff=True, is_active=True) + cls.member = users.create(**TEST_USER2, is_active=True) + cls.outsider = users.create(**TEST_USER3, is_active=True) + cls.direct = DirectChat.get_chat(cls.author, cls.member) + cls.project = create_project_chat(cls.author, cls.member) + cls.program, _ = create_program(cls.author, cls.member) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.author) + + def put(self, url, text, revision): + return self.api.put( + url, {"text": text, "revision": revision}, format="json", secure=True + ) + + def test_drafts_are_private_and_list_exposes_only_the_marker(self): + for kind, chat_id in ( + ("direct-chat", self.direct.pk), + ("project-chat", self.project.pk), + ("program-channel", self.program.pk), + ): + with self.subTest(kind=kind): + route = "chats:" + kind + url = reverse(route + "-draft", args=[chat_id]) + listing_url = reverse(route + "-list") + self.api.force_authenticate(self.author) + initial = self.api.get(url, secure=True) + self.assertEqual( + initial.json(), {"text": "", "revision": 0, "updated_at": None} + ) + self.assertIn("no-store", initial["Cache-Control"]) + written = self.put(url, "Мой черновик", 0) + self.assertEqual(written.status_code, 200) + self.assertEqual(written.json()["revision"], 1) + self.assertEqual(self.api.get(url, secure=True).json(), written.json()) + listing = self.api.get(listing_url, secure=True) + items = ( + listing.json()["results"] + if kind == "program-channel" + else listing.json() + ) + self.assertTrue(items[0]["has_draft"]) + self.assertNotContains(listing, "Мой черновик") + + self.api.force_authenticate(self.member) + response = self.api.get(url, secure=True) + if kind == "program-channel": + self.assertEqual(response.status_code, 403) + else: + self.assertEqual(response.json()["text"], "") + listing = self.api.get(listing_url, secure=True).json() + items = listing["results"] if kind == "program-channel" else listing + self.assertFalse(items[0]["has_draft"]) + self.api.force_authenticate(self.outsider) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + + def test_clearing_draft_does_not_allow_an_older_write_to_restore_it(self): + url = reverse("chats:direct-chat-draft", args=[self.direct.pk]) + first = self.put(url, "Старый текст", 0).json() + self.assertEqual(self.put(url, "Старый текст", 0).json(), first) + cleared = self.put(url, "", first["revision"]).json() + conflict = self.put(url, "Отложенная правка", first["revision"]) + self.assertEqual(conflict.status_code, 409) + self.assertEqual(conflict.json(), cleared) + self.assertEqual(self.api.get(url, secure=True).json()["text"], "") + + def test_admin_and_api_use_the_same_draft_version_and_channel_permission(self): + self.client.force_login(self.author) + admin_url = reverse( + "admin:chats_programchannel_message_search_draft", args=[self.program.pk] + ) + self.assertContains(self.client.get(admin_url, secure=True), "Личный черновик") + response = self.client.post( + admin_url, {"text": "Текст из админки", "revision": 0}, secure=True + ) + self.assertEqual(response.status_code, 302) + url = reverse("chats:program-channel-draft", args=[self.program.pk]) + self.assertEqual( + self.api.get(url, secure=True).json()["text"], "Текст из админки" + ) + publish_url = reverse( + "admin:chats_programchannel_message_search_publish", args=[self.program.pk] + ) + self.assertContains(self.client.get(publish_url, secure=True), "Текст из админки") + second = self.put(url, "Правка с другого устройства", 1).json() + for revision, text, expected_text in ( + (1, "Текст из админки", second["text"]), + (second["revision"], second["text"], ""), + ): + response = self.client.post( + publish_url, + { + "text": text, + "draft_revision": revision, + "client_message_id": str(uuid4()), + }, + secure=True, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(self.api.get(url, secure=True).json()["text"], expected_text) + self.program.managers.remove(self.author) + self.assertEqual(self.client.get(admin_url, secure=True).status_code, 404) + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + + def test_removed_project_member_cannot_read_or_replace_saved_draft(self): + self.api.force_authenticate(self.member) + url = reverse("chats:project-chat-draft", args=[self.project.pk]) + self.assertEqual(self.put(url, "Черновик участника", 0).status_code, 200) + Collaborator.objects.filter(project_id=self.project.pk, user=self.member).delete() + self.assertEqual(self.api.get(url, secure=True).status_code, 404) + self.assertEqual(self.put(url, "Правка", 1).status_code, 404) + + +class ChatDraftConcurrencyTests(TransactionTestCase): + @skipUnlessDBFeature("has_select_for_update") + def test_only_one_device_can_commit_a_new_draft_at_the_same_revision(self): + user = get_user_model().objects.create(**TEST_USER1) + chat = DirectChat.get_chat(user, user) + ready = Barrier(2) + + def write(text): + try: + ready.wait(timeout=10) + return save_draft(user, DirectChat, chat.pk, text=text, revision=0) + except DraftConflict as error: + return error + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + requests = [ + pool.submit(write, text) for text in ("Первый текст", "Второй текст") + ] + results = [request.result(timeout=10) for request in requests] + (saved,) = [result for result in results if isinstance(result, dict)] + (conflict,) = [result for result in results if isinstance(result, DraftConflict)] + self.assertEqual(saved["revision"], 1) + self.assertEqual(conflict.current, saved) diff --git a/chats/tests/test_message_changes.py b/chats/tests/test_message_changes.py new file mode 100644 index 00000000..e63169e1 --- /dev/null +++ b/chats/tests/test_message_changes.py @@ -0,0 +1,203 @@ +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.contrib.auth.models import Permission +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, ProjectChatMessageRead +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_project_reads import create_project_chat +from projects.models import Collaborator + + +def chat_url(kind, chat, suffix): + name = f"{kind}-chat-{suffix}" + return reverse("chats:" + name, args=[chat.pk]) + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class MessageChangesTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.author = users.create(**TEST_USER1) + cls.member = users.create(**TEST_USER2) + cls.outsider = users.create(**TEST_USER3) + cls.direct = DirectChat.get_chat(cls.author, cls.member) + cls.project = create_project_chat(cls.author, cls.member) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.member) + + def changes(self, kind, chat, **params): + return self.api.get(chat_url(kind, chat, "message-changes"), params, secure=True) + + def test_pages_include_current_state_and_personal_project_reads(self): + for kind in ("direct", "project"): + with self.subTest(kind=kind): + chat = getattr(self, kind) + old = chat.messages.create(author=self.author, text="История") + deleted = chat.messages.create(author=self.author, text="Будет удалено") + history = self.api.get(chat_url(kind, chat, "messages"), secure=True) + cursor = history.json()["sync_cursor"] + + new = chat.messages.create(author=self.author, text="Новое") + if kind == "project": + ProjectChatMessageRead.objects.create(message=new, user=self.member) + old.text = "Исправленная история" + old.save(update_fields=["text"]) + deleted.is_deleted = True + deleted.save(update_fields=["is_deleted"]) + + first = self.changes(kind, chat, after=cursor, limit=2) + self.assertEqual(first.status_code, 200) + self.assertIn("no-store", first["Cache-Control"]) + page = first.json() + self.assertTrue(page["has_more"]) + self.assertEqual( + [item["message_id"] for item in page["results"]], [new.pk, old.pk] + ) + self.assertEqual( + page["results"][0]["message"]["is_read"], kind == "project" + ) + self.assertEqual( + page["results"][1]["message"]["text"], "Исправленная история" + ) + final = self.changes(kind, chat, after=page["next_cursor"]).json() + self.assertEqual( + final["results"], [{"type": "delete", "message_id": deleted.pk}] + ) + self.assertFalse(final["has_more"]) + self.assertEqual( + self.changes(kind, chat, after=final["next_cursor"]).json(), + { + "results": [], + "next_cursor": final["next_cursor"], + "has_more": False, + }, + ) + self.assertEqual(ProjectChatMessageRead.objects.count(), 1) + + def test_validation_and_project_membership_use_current_state(self): + for kind in ("direct", "project"): + chat = getattr(self, kind) + with self.subTest(kind=kind): + self.api.force_authenticate(self.member) + for params in ({}, {"after": -1}, {"after": 0, "limit": 101}): + self.assertEqual(self.changes(kind, chat, **params).status_code, 400) + self.assertEqual(self.changes(kind, chat, after=1).status_code, 409) + self.api.force_authenticate(self.outsider) + self.assertEqual(self.changes(kind, chat, after=0).status_code, 404) + Collaborator.objects.filter(project_id=self.project.pk, user=self.member).delete() + self.api.force_authenticate(self.member) + self.assertEqual(self.changes("project", self.project, after=0).status_code, 404) + + def test_saving_chat_metadata_preserves_cursor(self): + stale = DirectChat.objects.get(pk=self.direct.pk) + self.direct.messages.create(author=self.author, text="Новое") + stale.save() + self.assertEqual( + self.changes("direct", self.direct, after=0).json()["next_cursor"], 1 + ) + + def test_admin_organizer_edits_and_deletes_own_message(self): + self.author.is_staff = True + self.author.is_active = True + self.author.save(update_fields=["is_staff", "is_active"]) + self.author.user_permissions.add( + Permission.objects.get( + content_type__app_label="chats", codename="view_projectchat" + ) + ) + self.client.force_login(self.author) + own = self.project.messages.create(author=self.author, text="До правки") + other = self.project.messages.create(author=self.member, text="Чужое сообщение") + route = "admin:chats_projectchat_message_search_change" + own_url = reverse(route, args=[self.project.pk, own.pk]) + other_url = reverse(route, args=[self.project.pk, other.pk]) + history = self.api.get(chat_url("project", self.project, "messages"), secure=True) + cursor = history.json()["sync_cursor"] + self.assertContains(self.client.get(own_url, secure=True), "До правки") + self.assertEqual(self.client.get(other_url, secure=True).status_code, 404) + invalid = self.client.post(own_url, {"action": "edit", "text": ""}, secure=True) + self.assertContains(invalid, "Сообщение не может быть пустым.", status_code=400) + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.client.post( + own_url, {"action": "edit", "text": "После правки"}, secure=True + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(callbacks), 1) + changed = self.changes("project", self.project, after=cursor).json() + self.assertEqual(changed["results"][0]["message"]["text"], "После правки") + self.assertEqual( + self.client.post(own_url, {"action": "delete"}, secure=True).status_code, 302 + ) + removed = self.changes( + "project", self.project, after=changed["next_cursor"] + ).json() + self.assertEqual(removed["results"], [{"type": "delete", "message_id": own.pk}]) + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class ReconnectTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.author = users.create(**TEST_USER1) + self.member = users.create(**TEST_USER2) + self.chat = create_project_chat(self.author, self.member) + self.old = self.chat.messages.create(author=self.author, text="Первое") + self.api = APIClient() + self.api.force_authenticate(self.member) + + async def test_disconnected_member_can_recover_websocket_writes(self): + history = await database_sync_to_async(self.api.get)( + chat_url("project", self.chat, "messages"), secure=True + ) + cursor = history.json()["sync_cursor"] + writer = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + writer.scope["user"] = self.author + connected, _ = await writer.connect(timeout=5) + self.assertTrue(connected) + + async def send(kind, **content): + await writer.send_json_to( + { + "type": kind, + "content": { + "chat_type": "project", + "chat_id": self.chat.pk, + **content, + }, + } + ) + response = await writer.receive_json_from(timeout=5) + self.assertEqual(response["type"], kind) + return response["content"] + + try: + send_params = {"text": "Новое", "client_message_id": str(uuid4())} + new = await send("new_message", **send_params) + replay = await send("new_message", **send_params) + self.assertTrue(replay["is_replay"]) + await send("edit_message", message_id=self.old.pk, text="Правка") + await send("delete_message", message_id=new["message"]["id"]) + finally: + await writer.disconnect() + + changes = await database_sync_to_async(self.api.get)( + chat_url("project", self.chat, "message-changes"), + {"after": cursor}, + secure=True, + ) + self.assertEqual(changes.status_code, 200) + data = changes.json() + edited, removed = data["results"] + self.assertEqual(edited["message"]["text"], "Правка") + self.assertEqual(removed, {"type": "delete", "message_id": new["message"]["id"]}) + self.assertEqual(data["next_cursor"], cursor + 3) 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/tests/test_message_delivery.py b/chats/tests/test_message_delivery.py new file mode 100644 index 00000000..6b483438 --- /dev/null +++ b/chats/tests/test_message_delivery.py @@ -0,0 +1,279 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest.mock import patch +from uuid import uuid4 + +from channels.testing import WebsocketCommunicator +from django.contrib.auth import get_user_model +from django.db import IntegrityError, connections +from django.test import TransactionTestCase, override_settings, skipUnlessDBFeature + +from chats.consumers import ChatConsumer +from chats.models import DirectChat, DirectChatMessage, FileToMessage, ProjectChat +from chats.models import ProjectChatMessage +from chats.services import create_direct_message, create_project_message +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from files.models import UserFile +from projects.models import Collaborator, Project + + +class MessageDeliveryTests(TransactionTestCase): + reset_sequences = True + + def setUp(self): + super().setUp() + self.user = get_user_model().objects.create(**TEST_USER1) + + async def connect(self, user=None): + communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + communicator.scope["user"] = user or self.user + connected, _ = await communicator.connect(timeout=5) + self.assertTrue(connected) + return communicator + + @staticmethod + def direct_message(chat_id, **overrides): + content = { + "chat_type": "direct", + "chat_id": chat_id, + "text": "Тестовое сообщение", + "reply_to": None, + "file_urls": [], + "client_message_id": str(uuid4()), + } + content.update(overrides) + return {"type": "new_message", "content": content} + + async def test_direct_retry_returns_original_message_without_duplicate(self): + communicator = await self.connect() + data = self.direct_message("1_1") + + await communicator.send_json_to(data) + first_response = await communicator.receive_json_from() + await communicator.send_json_to(data) + second_response = await communicator.receive_json_from() + + self.assertEqual(DirectChatMessage.objects.count(), 1) + self.assertEqual( + first_response["content"]["message"]["id"], + second_response["content"]["message"]["id"], + ) + self.assertFalse(first_response["content"]["is_replay"]) + self.assertTrue(second_response["content"]["is_replay"]) + self.assertEqual( + second_response["content"]["message"]["client_message_id"], + data["content"]["client_message_id"], + ) + self.assertTrue(await communicator.receive_nothing()) + await communicator.disconnect() + + async def test_file_only_message_can_be_retried(self): + user_file = UserFile.objects.create( + link="https://cdn.example.test/chat-file.pdf", + user=self.user, + name="chat-file", + extension="pdf", + mime_type="application/pdf", + detected_mime_type="application/pdf", + size=128, + ) + communicator = await self.connect() + data = self.direct_message( + "1_1", + text="", + file_urls=[user_file.pk], + ) + + await communicator.send_json_to(data) + response = await communicator.receive_json_from() + await communicator.send_json_to(data) + replay = await communicator.receive_json_from() + + self.assertNotIn("error", response) + self.assertEqual(DirectChatMessage.objects.count(), 1) + self.assertEqual(FileToMessage.objects.count(), 1) + self.assertEqual(len(response["content"]["message"]["files"]), 1) + self.assertTrue(replay["content"]["is_replay"]) + self.assertEqual(replay["content"]["message"], response["content"]["message"]) + await communicator.disconnect() + + async def test_file_from_another_account_is_rejected(self): + other_user = get_user_model().objects.create(**TEST_USER2) + user_file = UserFile.objects.create( + link="https://cdn.example.test/foreign-file.pdf", + user=other_user, + name="foreign-file", + extension="pdf", + mime_type="application/pdf", + detected_mime_type="application/pdf", + size=128, + ) + communicator = await self.connect() + data = self.direct_message("1_1", file_urls=[user_file.pk]) + + await communicator.send_json_to(data) + response = await communicator.receive_json_from() + + self.assertIn("error", response) + self.assertEqual(DirectChatMessage.objects.count(), 0) + self.assertEqual(DirectChat.objects.count(), 0) + await communicator.disconnect() + + async def test_reply_to_another_chat_is_rejected(self): + get_user_model().objects.create(**TEST_USER2) + get_user_model().objects.create(**TEST_USER3) + communicator = await self.connect() + + await communicator.send_json_to(self.direct_message("1_2")) + first_response = await communicator.receive_json_from() + reply_id = first_response["content"]["message"]["id"] + await communicator.send_json_to(self.direct_message("1_3", reply_to=reply_id)) + response = await communicator.receive_json_from() + + self.assertIn("error", response) + self.assertEqual(DirectChatMessage.objects.count(), 1) + await communicator.disconnect() + + +class ProjectMessageDeliveryTests(TransactionTestCase): + reset_sequences = True + + def setUp(self): + super().setUp() + self.leader = get_user_model().objects.create(**TEST_USER1) + self.member = get_user_model().objects.create(**TEST_USER2) + self.project = Project.objects.create(leader=self.leader) + self.chat, _ = ProjectChat.objects.get_or_create( + id=self.project.pk, + defaults={"project": self.project}, + ) + Collaborator.objects.create( + user=self.member, + project=self.project, + role="User", + ) + + async def connect(self, user): + communicator = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + communicator.scope["user"] = user + connected, _ = await communicator.connect(timeout=5) + self.assertTrue(connected) + return communicator + + @override_settings(RUNNING_TESTS=False) + async def test_project_retry_is_not_broadcast_as_a_new_message(self): + client_message_id = str(uuid4()) + data = { + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": str(self.chat.pk), + "text": "Сообщение проекта", + "reply_to": None, + "file_urls": [], + "client_message_id": client_message_id, + }, + } + communicator = await self.connect(self.member) + recipient = await self.connect(self.leader) + self.assertEqual((await communicator.receive_json_from())["type"], "set_online") + + await communicator.send_json_to(data) + first_response = await communicator.receive_json_from() + received = await recipient.receive_json_from() + self.assertEqual( + received["content"]["message"], first_response["content"]["message"] + ) + self.assertTrue(received["content"]["notify"]) + self.assertFalse(first_response["content"]["notify"]) + await communicator.send_json_to(data) + second_response = await communicator.receive_json_from() + + self.assertEqual(ProjectChatMessage.objects.count(), 1) + self.assertEqual( + first_response["content"]["message"]["id"], + second_response["content"]["message"]["id"], + ) + self.assertFalse(first_response["content"]["is_replay"]) + self.assertTrue(second_response["content"]["is_replay"]) + self.assertTrue(await recipient.receive_nothing()) + await communicator.disconnect() + await recipient.disconnect() + + @skipUnlessDBFeature("has_select_for_update") + def test_simultaneous_project_retries_keep_one_message_and_one_attachment(self): + user_file = UserFile.objects.create( + link="https://cdn.example.test/concurrent.pdf", user=self.member + ) + client_message_id = uuid4() + ready = Barrier(2) + + def send(): + try: + ready.wait(timeout=10) + return create_project_message( + chat_id=self.chat.pk, + author=self.member, + text="", + file_urls=[user_file.pk], + client_message_id=client_message_id, + ) + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = [pool.submit(send) for _ in range(2)] + (first, first_created), (second, second_created) = [ + result.result(timeout=10) for result in results + ] + + self.assertCountEqual([first_created, second_created], [True, False]) + self.assertEqual(first["id"], second["id"]) + self.assertEqual(ProjectChatMessage.objects.count(), 1) + self.assertEqual(FileToMessage.objects.count(), 1) + self.chat.refresh_from_db() + self.assertEqual(self.chat.message_revision, 1) + self.assertEqual(ProjectChatMessage.objects.get().sync_revision, 1) + + @skipUnlessDBFeature("has_select_for_update") + def test_simultaneous_distinct_messages_have_distinct_sync_revisions(self): + ready = Barrier(2) + + def send(text): + try: + ready.wait(timeout=10) + message, _ = create_project_message( + chat_id=self.chat.pk, author=self.member, text=text + ) + return message["id"] + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + requests = [pool.submit(send, text) for text in ("Первое", "Второе")] + sent = [request.result(timeout=10) for request in requests] + + messages = self.chat.messages.order_by("sync_revision") + self.assertCountEqual(messages.values_list("pk", flat=True), sent) + self.assertEqual(list(messages.values_list("sync_revision", flat=True)), [1, 2]) + self.chat.refresh_from_db() + self.assertEqual(self.chat.message_revision, 2) + + def test_failed_attachment_write_rolls_back_new_direct_chat_and_message(self): + user_file = UserFile.objects.create( + link="https://cdn.example.test/rollback.pdf", user=self.member + ) + with ( + patch.object(FileToMessage.objects, "create", side_effect=IntegrityError), + self.assertRaises(IntegrityError), + ): + create_direct_message( + author=self.member, + other_user=self.leader, + text="", + file_urls=[user_file.pk], + client_message_id=uuid4(), + ) + self.assertFalse(DirectChat.objects.exists()) + self.assertFalse(DirectChatMessage.objects.exists()) + self.assertFalse(FileToMessage.objects.exists()) diff --git a/chats/tests/test_notifications.py b/chats/tests/test_notifications.py new file mode 100644 index 00000000..44ef16dc --- /dev/null +++ b/chats/tests/test_notifications.py @@ -0,0 +1,143 @@ +from datetime import timedelta +from unittest.mock import patch +from uuid import uuid4 + +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 ProgramChannel, ProjectChat +from chats.exceptions import MessageValidationException +from chats.notifications import should_notify +from chats.services.messages import create_project_message +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_program_channels import create_program +from chats.tests.test_project_reads import create_project_chat +from projects.models import Collaborator + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class ChatNotificationTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.manager = users.create(**TEST_USER1, is_staff=True, is_active=True) + cls.member = users.create(**TEST_USER2, is_active=True) + cls.outsider = users.create(**TEST_USER3, is_active=True) + cls.project_chat = create_project_chat(cls.manager, cls.member) + cls.program, cls.profile = create_program(cls.manager, cls.member) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.member) + + def test_modes_are_personal_and_temporary_mute_expires(self): + for kind, model, chat_id in ( + ("project-chat", ProjectChat, self.project_chat.pk), + ("program-channel", ProgramChannel, self.program.pk), + ): + with self.subTest(kind=kind): + url = reverse("chats:" + kind + "-notification-settings", args=[chat_id]) + clock = timezone.now() + decision = dict( + author_id=self.manager.pk, mentioned_user_ids=[self.member.pk] + ) + for mode, notify in (("mentions", True), ("muted", False), ("all", True)): + response = self.api.put( + url, {"mode": mode}, format="json", secure=True + ) + self.assertEqual(response.json(), {"mode": mode, "muted_until": None}) + self.assertIs( + should_notify(self.member, model, chat_id, **decision), notify + ) + with patch("chats.notifications.timezone.now", return_value=clock): + response = self.api.put( + url, {"mode": "mute_8h"}, format="json", secure=True + ) + self.assertEqual(response.json()["mode"], "mute_8h") + self.assertFalse( + should_notify(self.member, model, chat_id, **decision) + ) + self.assertTrue( + should_notify( + self.manager, + model, + chat_id, + author_id=self.member.pk, + mentioned_user_ids=[], + ) + ) + with patch( + "chats.notifications.timezone.now", + return_value=clock + timedelta(hours=8), + ): + self.assertEqual( + self.api.get(url, secure=True).json(), + {"mode": "all", "muted_until": None}, + ) + self.assertTrue( + should_notify(self.member, model, chat_id, **decision) + ) + + def test_admin_sends_mentions_and_uses_same_personal_preference(self): + self.client.force_login(self.manager) + chat_id = self.project_chat.pk + admin_name = "admin:chats_projectchat_message_search" + settings_url = reverse(admin_name + "_notifications", args=[chat_id]) + self.assertContains( + self.client.get(settings_url, secure=True), "Только упоминания" + ) + self.assertEqual( + self.client.post(settings_url, {"mode": "mentions"}, secure=True).status_code, + 302, + ) + api_url = reverse("chats:project-chat-notification-settings", args=[chat_id]) + self.api.force_authenticate(self.manager) + self.assertEqual(self.api.get(api_url, secure=True).json()["mode"], "mentions") + publish = reverse(admin_name + "_publish", args=[chat_id]) + response = self.client.post( + publish, + { + "text": "Объявление", + "mentions": [self.member.pk], + "client_message_id": str(uuid4()), + }, + secure=True, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + self.project_chat.messages.get().mentioned_user_ids, [self.member.pk] + ) + recipients_url = reverse("chats:project-chat-mention-recipients", args=[chat_id]) + self.assertEqual( + [ + user["id"] + for user in self.api.get(recipients_url, secure=True).json()["results"] + ], + [self.member.pk], + ) + Collaborator.objects.filter(project_id=chat_id, user=self.member).delete() + self.api.force_authenticate(self.member) + self.assertEqual(self.api.get(api_url, secure=True).status_code, 404) + self.assertEqual(self.api.get(recipients_url, secure=True).status_code, 404) + + def test_project_message_cannot_mention_a_user_outside_the_chat(self): + with self.assertRaises(MessageValidationException): + create_project_message( + chat_id=self.project_chat.pk, + author=self.manager, + text="Объявление", + mentioned_user_ids=[self.outsider.pk], + ) + self.assertFalse(self.project_chat.messages.exists()) + + def test_channel_recipient_list_is_for_publishers(self): + url = reverse("chats:program-channel-mention-recipients", args=[self.program.pk]) + self.assertEqual(self.api.get(url, secure=True).status_code, 403) + self.api.force_authenticate(self.manager) + self.assertEqual( + [user["id"] for user in self.api.get(url, secure=True).json()["results"]], + [self.member.pk], + ) diff --git a/chats/tests/test_permissions.py b/chats/tests/test_permissions.py index 525971e1..6b0867d2 100644 --- a/chats/tests/test_permissions.py +++ b/chats/tests/test_permissions.py @@ -1,11 +1,17 @@ +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, override_settings -from rest_framework.test import APIRequestFactory, force_authenticate +from django.test import TestCase, TransactionTestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient, APIRequestFactory, force_authenticate +from rest_framework_simplejwt.tokens import AccessToken -from chats.models import ProjectChat +from chats.models import DirectChat, ProjectChat from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_project_reads import create_project_chat from chats.views import ProjectChatDetail from projects.models import Collaborator, Project +from procollab.asgi import application @override_settings(ALLOWED_HOSTS=["testserver", "dev.procollab.ru", "127.0.0.1"]) @@ -71,3 +77,176 @@ def test_project_chat_detail_is_forbidden_for_outsider(self): response = ProjectChatDetail.as_view()(request, pk=self.chat.id) self.assertEqual(response.status_code, 403) + + def test_direct_chat_preview_and_unreads_use_visible_incoming_messages(self): + chat = DirectChat.get_chat(self.leader, self.collaborator) + chat.messages.create(author=self.leader, text="Свое сообщение") + first = chat.messages.create( + author=self.collaborator, text="Первое непрочитанное" + ) + newest = chat.messages.create(author=self.collaborator, text="Новое сообщение") + chat.messages.create( + author=self.collaborator, text="Удаленное сообщение", is_deleted=True + ) + api = APIClient() + api.force_authenticate(self.leader) + listing_url = reverse("chats:direct-chat-list") + detail_url = reverse("chats:direct-chat-detail", args=[chat.pk]) + unreads_url = reverse("chats:has-chat-unreads") + + item = api.get(listing_url, secure=True).json()[0] + self.assertEqual(item["last_message"]["id"], newest.pk) + detail = api.get(detail_url, secure=True).json() + for summary in (item, detail): + self.assertEqual(summary["unread_count"], 2) + self.assertEqual(summary["first_unread_message_id"], first.pk) + context_url = reverse( + "chats:direct-chat-message-context", args=[chat.pk, first.pk] + ) + self.assertIn( + first.pk, + [item["id"] for item in api.get(context_url, secure=True).json()["results"]], + ) + first.refresh_from_db() + self.assertFalse(first.is_read) + self.assertTrue(api.get(unreads_url, secure=True).json()["has_unreads"]) + first.is_read = True + first.save(update_fields=["is_read"]) + newest.is_deleted = True + newest.save(update_fields=["is_deleted"]) + item = api.get(listing_url, secure=True).json()[0] + self.assertEqual(item["last_message"]["id"], first.pk) + self.assertEqual(item["unread_count"], 0) + self.assertIsNone(item["first_unread_message_id"]) + self.assertFalse(api.get(unreads_url, secure=True).json()["has_unreads"]) + + def test_admin_opens_first_unread_and_marks_it_for_the_recipient(self): + chat = DirectChat.get_chat(self.staff, self.collaborator) + incoming = chat.messages.create(author=self.collaborator, text="Входящее") + own = chat.messages.create(author=self.staff, text="Исходящее") + self.client.force_login(self.staff) + route = "admin:chats_directchat_message_search" + search_url = reverse(route, args=[chat.pk]) + context_url = reverse(route + "_context", args=[chat.pk, incoming.pk]) + self.assertContains( + self.client.get(search_url, secure=True), "К первому непрочитанному" + ) + self.assertContains( + self.client.get(search_url, secure=True), context_url + "#selected-message" + ) + url = reverse(route + "_read", args=[chat.pk, incoming.pk]) + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.client.post(url, secure=True) + self.assertRedirects(response, context_url, fetch_redirect_response=False) + self.assertEqual(len(callbacks), 1) + incoming.refresh_from_db() + self.assertTrue(incoming.is_read) + own_url = reverse(route + "_read", args=[chat.pk, own.pk]) + self.assertEqual(self.client.post(own_url, secure=True).status_code, 404) + self.assertNotContains( + self.client.get(search_url, secure=True), "К первому непрочитанному" + ) + + def test_direct_and_project_history_are_read_only(self): + direct = DirectChat.get_chat(self.leader, self.collaborator) + api = APIClient() + api.force_authenticate(self.leader) + for route, chat in ( + ("direct-chat-messages", direct), + ("project-chat-messages", self.chat), + ): + with self.subTest(route=route): + url = reverse("chats:" + route, args=[chat.pk]) + history = api.get(url, secure=True) + self.assertEqual(history.status_code, 200) + self.assertIn("no-store", history["Cache-Control"]) + response = api.post( + url, {"text": "Сообщение"}, format="json", secure=True + ) + self.assertEqual(response.status_code, 405) + self.assertFalse(chat.messages.exists()) + + +@override_settings(RUNNING_TESTS=False) +class ChatSocketAccessTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.leader = users.create(**TEST_USER1, is_active=True) + self.member = users.create(**TEST_USER2, is_active=True) + self.chat = create_project_chat(self.leader, self.member) + Collaborator.objects.filter(project_id=self.chat.pk, user=self.leader).delete() + + def open_chat(self, user): + return WebsocketCommunicator( + application, + "/ws/chat/", + subprotocols=["Bearer", str(AccessToken.for_user(user))], + ) + + async def receive_chat_event(self, socket): + while True: + event = await socket.receive_json_from(timeout=5) + if event.get("type") not in {"set_online", "set_offline"}: + return event + + async def send_project_message(self, socket, text): + await socket.send_json_to( + { + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": self.chat.pk, + "text": text, + }, + } + ) + + async def test_leader_receives_updates_and_removed_member_stops_receiving_them(self): + leader = self.open_chat(self.leader) + member = self.open_chat(self.member) + self.assertEqual(await leader.connect(), (True, "Bearer")) + self.assertEqual(await member.connect(), (True, "Bearer")) + try: + await self.send_project_message(member, "Первое сообщение") + self.assertEqual( + (await self.receive_chat_event(leader))["content"]["message"]["text"], + "Первое сообщение", + ) + self.assertEqual( + (await self.receive_chat_event(member))["type"], "new_message" + ) + await database_sync_to_async( + Collaborator.objects.filter( + project_id=self.chat.pk, user=self.member + ).delete + )() + await self.send_project_message(leader, "Сообщение после изменения состава") + self.assertEqual( + (await self.receive_chat_event(leader))["type"], "new_message" + ) + self.assertTrue(await member.receive_nothing()) + finally: + await member.disconnect() + await leader.disconnect() + + async def test_inactive_account_cannot_open_chat_socket(self): + await database_sync_to_async( + get_user_model().objects.filter(pk=self.member.pk).update + )(is_active=False) + socket = self.open_chat(self.member) + connected, _ = await socket.connect() + self.assertFalse(connected) + await socket.disconnect() + + async def test_connection_stays_available_after_invalid_event(self): + socket = self.open_chat(self.leader) + self.assertEqual(await socket.connect(), (True, "Bearer")) + try: + await socket.send_to(text_data="{") + self.assertIn("error", await self.receive_chat_event(socket)) + await self.send_project_message(socket, "Сообщение проекта") + self.assertEqual( + (await self.receive_chat_event(socket))["type"], "new_message" + ) + finally: + await socket.disconnect() diff --git a/chats/tests/test_program_channels.py b/chats/tests/test_program_channels.py new file mode 100644 index 00000000..da888052 --- /dev/null +++ b/chats/tests/test_program_channels.py @@ -0,0 +1,321 @@ +from unittest.mock import patch +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.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, TransactionTestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import AccessToken + +from chats.models import ProgramChannel +from chats.notifications import set_notification_mode +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from chats.tests.test_attachment_download import STORAGE, upstream_file +from files.models import UserFile +from files.typings import FileInfo +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from procollab.asgi import application + + +def create_program(manager, member): + now = timezone.now() + program = PartnerProgram.objects.create( + name="Программа", + draft=False, + datetime_started=now, + datetime_registration_ends=now, + datetime_finished=now, + ) + program.managers.add(manager) + profile = PartnerProgramUserProfile.objects.create( + partner_program=program, user=member, partner_program_data={} + ) + return program, profile + + +@override_settings(ALLOWED_HOSTS=["testserver"], SELECTEL_SWIFT_URL=STORAGE) +class ProgramChannelTests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.manager = users.create(**TEST_USER1, is_staff=True, is_active=True) + cls.member = users.create(**TEST_USER2, is_active=True) + cls.outsider = users.create(**TEST_USER3, is_staff=True, is_active=True) + cls.program, cls.profile = create_program(cls.manager, cls.member) + cls.channel = ProgramChannel.objects.get(pk=cls.program.pk) + cls.file = UserFile.objects.create( + link=STORAGE + "program.pdf", + user=cls.manager, + name="План программы", + extension="pdf", + ) + + def setUp(self): + self.api = APIClient() + self.api.force_authenticate(self.manager) + self.history = self.url("messages") + + def url(self, suffix, *extra): + return reverse("chats:program-channel-" + suffix, args=[self.channel.pk, *extra]) + + def publish(self, text="Анонс", **payload): + return self.api.post( + self.history, {"text": text, **payload}, format="json", secure=True + ) + + def list_channels(self): + url = reverse("chats:program-channel-list") + return self.api.get(url, secure=True).json()["results"] + + def test_permissions_and_protected_attachment_follow_current_membership(self): + self.api.force_authenticate(self.member) + self.assertEqual(self.publish().status_code, 403) + self.assertFalse(self.list_channels()[0]["can_publish"]) + self.api.force_authenticate(self.manager) + self.assertTrue(self.list_channels()[0]["can_publish"]) + publication = self.publish(file_urls=[self.file.pk]).json() + link = publication["files"][0]["link"] + self.assertNotIn(STORAGE, link) + + self.api.force_authenticate(self.member) + summary = self.api.get(self.url("detail"), secure=True).json() + self.assertEqual(summary["last_message"]["id"], publication["id"]) + result = self.api.get( + self.history, {"q": "план программы", "scope": "files"}, secure=True + ).json() + self.assertEqual([item["id"] for item in result["results"]], [publication["id"]]) + context = self.api.get( + self.url("message-context", publication["id"]), secure=True + ).json() + self.assertEqual(context["results"][0]["id"], publication["id"]) + self.assertEqual( + self.api.get(self.url("files"), secure=True).json()[0]["link"], link + ) + with ( + patch( + "files.downloads.SelectelSwiftStorage._get_auth_token", + return_value="test", + ), + patch( + "files.downloads.requests.get", return_value=upstream_file() + ) as storage, + ): + response = self.api.get(link, secure=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(b"".join(response.streaming_content), b"abcdef") + self.profile.delete() + self.assertEqual(self.api.get(self.history, secure=True).status_code, 404) + self.assertEqual(self.api.get(link, secure=True).status_code, 404) + self.assertEqual(self.list_channels(), []) + storage.assert_called_once() + self.api.force_authenticate(self.outsider) + self.assertEqual(self.publish().status_code, 404) + + def test_read_cursor_is_personal_and_publication_retries_keep_same_id(self): + key = str(uuid4()) + first = self.publish(client_message_id=key, file_urls=[self.file.pk]) + self.assertEqual(first.status_code, 201) + replay = self.publish(client_message_id=key, file_urls=[self.file.pk]) + self.assertEqual(replay.status_code, 200) + self.assertEqual(first.json(), replay.json()) + second = self.publish("Вторая публикация").json()["id"] + self.assertEqual(self.list_channels()[0]["unread_count"], 0) + + self.api.force_authenticate(self.member) + listing = self.list_channels()[0] + self.assertEqual(listing["unread_count"], 2) + self.assertEqual(listing["first_unread_message_id"], first.json()["id"]) + for message_id in (second, first.json()["id"]): + read = self.api.post( + self.url("read"), {"message_id": message_id}, format="json", secure=True + ) + self.assertEqual(read.json(), {"last_message_id": second}) + self.assertEqual(self.list_channels()[0]["unread_count"], 0) + self.assertIsNone(self.list_channels()[0]["first_unread_message_id"]) + + def test_edit_and_delete_are_visible_in_sync_and_withdraw_attachment(self): + published = self.publish(file_urls=[self.file.pk]).json() + message_id = published["id"] + change = self.url("message-change", message_id) + cursor = self.api.get(self.history, secure=True).json()["sync_cursor"] + self.assertEqual( + self.api.patch( + change, {"text": "Правка"}, format="json", secure=True + ).status_code, + 200, + ) + changes = self.url("message-changes") + delta = self.api.get(changes, {"after": cursor}, secure=True).json() + self.assertEqual(delta["results"][0]["message"]["text"], "Правка") + self.assertEqual(self.api.delete(change, secure=True).status_code, 204) + self.api.force_authenticate(self.member) + deleted = self.api.get( + changes, {"after": delta["next_cursor"]}, secure=True + ).json() + self.assertEqual( + deleted["results"], [{"type": "delete", "message_id": message_id}] + ) + self.assertEqual( + self.api.get(published["files"][0]["link"], secure=True).status_code, 404 + ) + + def test_manager_can_publish_and_edit_in_admin_with_shared_validation(self): + self.client.force_login(self.manager) + name = "admin:chats_programchannel_message_search" + search_url = reverse(name, args=[self.channel.pk]) + publish_url = reverse(name + "_publish", args=[self.channel.pk]) + self.assertContains(self.client.get(search_url, secure=True), publish_url) + uploaded_url = STORAGE + "admin.txt" + with patch( + "chats.admin.ProgramChannelAdmin.cdn.upload", + return_value=FileInfo( + url=uploaded_url, + name="Объявление", + extension="txt", + mime_type="text/plain", + detected_mime_type="text/plain", + size=5, + ), + ): + upload_response = self.client.post( + publish_url, + {"action": "upload", "file": SimpleUploadedFile("note.txt", b"hello")}, + secure=True, + ) + self.assertContains(upload_response, "Файл загружен и выбран для публикации.") + self.assertTrue( + UserFile.objects.filter(pk=uploaded_url, user=self.manager).exists() + ) + payload = { + "text": "", + "files": [uploaded_url], + "client_message_id": str(uuid4()), + } + for _ in range(2): + response = self.client.post(publish_url, payload, secure=True) + self.assertEqual(response.status_code, 302) + message = self.channel.messages.get() + change_url = reverse(name + "_change", args=[self.channel.pk, message.pk]) + response = self.client.post( + change_url, {"action": "edit", "text": "Изменено в админке"}, secure=True + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + self.api.get(self.history, secure=True).json()["results"][0]["text"], + "Изменено в админке", + ) + self.program.managers.remove(self.manager) + self.assertEqual(self.client.get(search_url, secure=True).status_code, 404) + + def test_program_return_to_draft_hides_channel_without_losing_history(self): + self.assertEqual(self.publish().status_code, 201) + PartnerProgram.objects.filter(pk=self.program.pk).update(draft=True) + self.assertEqual(self.api.get(self.history, secure=True).status_code, 404) + self.assertEqual(self.list_channels(), []) + PartnerProgram.objects.filter(pk=self.program.pk).update(draft=False) + self.assertEqual(self.api.get(self.history, secure=True).json()["count"], 1) + + def test_uploaded_file_cannot_be_removed_from_a_visible_publication(self): + published = self.publish(file_urls=[self.file.pk]) + self.assertEqual(published.status_code, 201) + with patch("files.views.FileView.cdn.delete") as storage_delete: + response = self.api.delete( + "/files/", QUERY_STRING="link=" + self.file.pk, secure=True + ) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["code"], "chat_attachment_in_use") + storage_delete.assert_not_called() + history = self.api.get(self.history, secure=True).json() + self.assertEqual(history["results"][0]["files"], published.json()["files"]) + + @patch("files.service.requests.put") + @patch("files.service.SelectelSwiftStorage._get_auth_token", return_value="test") + def test_failed_storage_upload_does_not_create_an_attachment(self, _token, put): + put.return_value.status_code = 503 + before = UserFile.objects.count() + response = self.api.post( + "/files/?preserve_original=true", + {"file": SimpleUploadedFile("note.txt", b"hello")}, + secure=True, + ) + self.assertEqual(response.status_code, 502) + self.assertEqual(UserFile.objects.count(), before) + + +@override_settings(ALLOWED_HOSTS=["testserver"], RUNNING_TESTS=False) +class ProgramChannelSocketTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.manager = users.create(**TEST_USER1, is_active=True) + self.member = users.create(**TEST_USER2, is_active=True) + self.program, self.profile = create_program(self.manager, self.member) + + async def test_channel_change_is_delivered_only_while_member_has_access(self): + socket = WebsocketCommunicator( + application, + "/ws/chat/", + subprotocols=["Bearer", str(AccessToken.for_user(self.member))], + ) + api = APIClient() + api.force_authenticate(self.manager) + url = reverse("chats:program-channel-messages", args=[self.program.pk]) + self.assertEqual(await socket.connect(), (True, "Bearer")) + try: + await socket.send_json_to( + { + "type": "subscribe_program_channel", + "content": {"channel_id": self.program.pk}, + } + ) + self.assertEqual( + (await socket.receive_json_from(timeout=5))["type"], + "subscribe_program_channel", + ) + response = await database_sync_to_async(api.post)( + url, {"text": "Публикация"}, format="json", secure=True + ) + self.assertEqual(response.status_code, 201) + self.assertEqual( + await socket.receive_json_from(timeout=5), + { + "type": "program_channel_changed", + "content": { + "channel_id": self.program.pk, + "notify": True, + "message_id": response.json()["id"], + }, + }, + ) + await database_sync_to_async(set_notification_mode)( + self.member, ProgramChannel, self.program.pk, "mentions" + ) + post = database_sync_to_async(api.post) + ordinary = await post( + url, {"text": "Без упоминания"}, format="json", secure=True + ) + self.assertEqual(ordinary.status_code, 201) + self.assertFalse( + (await socket.receive_json_from(timeout=5))["content"]["notify"] + ) + mentioned = await post( + url, + {"text": "С упоминанием", "mentioned_user_ids": [self.member.pk]}, + format="json", + secure=True, + ) + self.assertEqual(mentioned.status_code, 201) + self.assertTrue( + (await socket.receive_json_from(timeout=5))["content"]["notify"] + ) + await database_sync_to_async(self.profile.delete)() + response = await database_sync_to_async(api.post)( + url, {"text": "Следующая публикация"}, format="json", secure=True + ) + self.assertEqual(response.status_code, 201) + self.assertTrue(await socket.receive_nothing()) + finally: + await socket.disconnect() diff --git a/chats/tests/test_project.py b/chats/tests/test_project.py index 68ee42dc..276e1667 100644 --- a/chats/tests/test_project.py +++ b/chats/tests/test_project.py @@ -3,7 +3,7 @@ from django.test import TransactionTestCase from chats.consumers import ChatConsumer -from chats.models import ProjectChat, ProjectChatMessage +from chats.models import ProjectChat, ProjectChatMessage, ProjectChatMessageRead from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 from chats.websockets_settings import EventType @@ -73,7 +73,14 @@ async def test_read_message_in_my_project_other_message(self): await self.communicator.send_json_to(self.data) response = await self.communicator.receive_json_from() project_message = ProjectChatMessage.objects.get(pk=1) - self.assertTrue(project_message.is_read) + self.assertFalse(project_message.is_read) + self.assertTrue( + ProjectChatMessageRead.objects.filter( + message=project_message, user=self.leader + ).exists() + ) + self.assertEqual(response["content"]["read_count"], 1) + await self.communicator.disconnect() async def test_read_message_in_other_project_other_message(self): await self.connect(self.user) @@ -88,6 +95,8 @@ async def test_read_message_in_other_project_other_message(self): response = await self.communicator.receive_json_from() project_message = ProjectChatMessage.objects.get(pk=1) self.assertFalse(project_message.is_read) + self.assertFalse(ProjectChatMessageRead.objects.exists()) + await self.communicator.disconnect() async def test_delete_message_in_my_project_my_message(self): await self.connect(self.user) diff --git a/chats/tests/test_project_reads.py b/chats/tests/test_project_reads.py new file mode 100644 index 00000000..eb77f5c2 --- /dev/null +++ b/chats/tests/test_project_reads.py @@ -0,0 +1,214 @@ +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 ProjectChat, ProjectChatMessage, ProjectChatMessageRead +from chats.project_reads import mark_project_message_read +from chats.tests.constants import TEST_USER1, TEST_USER2, TEST_USER3 +from projects.models import Collaborator, Project + + +def create_project_chat(leader, *members): + project = Project.objects.create(leader=leader) + chat, _ = ProjectChat.objects.get_or_create( + pk=project.pk, defaults={"project": project} + ) + for member in members: + Collaborator.objects.create(project=project, user=member, role="User") + return chat + + +@override_settings(ALLOWED_HOSTS=["testserver"]) +class ProjectReadsAPITests(TestCase): + @classmethod + def setUpTestData(cls): + users = get_user_model().objects + cls.leader = users.create(**TEST_USER1) + cls.reader = users.create( + **TEST_USER2, is_staff=True, is_superuser=True, is_active=True + ) + cls.other_reader = users.create(**TEST_USER3) + cls.chat = create_project_chat(cls.leader, cls.reader, cls.other_reader) + + def setUp(self): + self.api = APIClient() + + def get(self, user, route, **kwargs): + self.api.force_authenticate(user=user) + url = reverse("chats:" + route, kwargs=kwargs) + response = self.api.get(url, secure=True) + self.assertEqual(response.status_code, 200) + return response.json() + + def create_message(self, **overrides): + return ProjectChatMessage.objects.create( + **( + {"chat": self.chat, "author": self.leader, "text": "Сообщение"} + | overrides + ) + ) + + def test_list_detail_and_global_indicator_use_the_same_personal_unreads(self): + first = self.create_message() + latest = self.create_message() + self.create_message(is_read=True) + self.create_message(is_deleted=True) + self.create_message(author=self.reader) + mark_project_message_read( + chat_id=self.chat.pk, message_id=latest.pk, user=self.reader + ) + + detail = self.get(self.reader, "project-chat-detail", pk=self.chat.pk) + item = self.get(self.reader, "project-chat-list")[0] + for data in (detail, item): + self.assertEqual(data["unread_count"], 1) + self.assertEqual(data["first_unread_message_id"], first.pk) + self.assertTrue(self.get(self.reader, "has-chat-unreads")["has_unreads"]) + other_detail = self.get(self.other_reader, "project-chat-detail", pk=self.chat.pk) + self.assertEqual(other_detail["unread_count"], 3) + self.assertEqual(other_detail["first_unread_message_id"], first.pk) + + mark_project_message_read( + chat_id=self.chat.pk, message_id=first.pk, user=self.reader + ) + detail = self.get(self.reader, "project-chat-detail", pk=self.chat.pk) + self.assertEqual(detail["unread_count"], 0) + self.assertIsNone(detail["first_unread_message_id"]) + self.assertFalse(self.get(self.reader, "has-chat-unreads")["has_unreads"]) + self.assertTrue(self.get(self.other_reader, "has-chat-unreads")["has_unreads"]) + + def test_history_personalizes_read_flag_for_messages_and_replies(self): + original = self.create_message() + reply = self.create_message(reply_to=original) + mark_project_message_read( + chat_id=self.chat.pk, message_id=original.pk, user=self.reader + ) + + for user, is_read in ( + (self.leader, True), + (self.reader, True), + (self.other_reader, False), + ): + with self.subTest(user=user.pk): + data = self.get(user, "project-chat-messages", id=self.chat.pk) + by_id = {message["id"]: message for message in data["results"]} + self.assertFalse(by_id[reply.pk]["is_read"]) + for item in (by_id[original.pk], by_id[reply.pk]["reply_to"]): + self.assertIs(item["is_read"], is_read) + self.assertEqual(item["read_count"], 1) + + def test_admin_marks_only_for_the_current_project_member(self): + message = self.create_message() + self.client.force_login(self.reader) + url = reverse("admin:chats_projectchatmessage_changelist") + form = {"action": "mark_read_for_me", "_selected_action": [message.pk]} + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.client.post(url, form, secure=True) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(callbacks), 1) + self.assertTrue( + ProjectChatMessageRead.objects.filter( + message=message, user=self.reader + ).exists() + ) + + Collaborator.objects.filter(project=self.chat.project, user=self.reader).delete() + another = self.create_message() + response = self.client.post( + url, {**form, "_selected_action": [another.pk]}, secure=True + ) + self.assertEqual(response.status_code, 302) + self.assertFalse(ProjectChatMessageRead.objects.filter(message=another).exists()) + + +class ProjectReadEventsTests(TransactionTestCase): + def setUp(self): + users = get_user_model().objects + self.author = users.create(**TEST_USER1) + self.reader = users.create(**TEST_USER2) + self.other_reader = users.create(**TEST_USER3) + self.chat = create_project_chat(self.author, self.reader, self.other_reader) + self.message = ProjectChatMessage.objects.create( + chat=self.chat, author=self.author, text="Сообщение" + ) + + async def connect(self, user): + socket = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + socket.scope["user"] = user + connected, _ = await socket.connect() + self.assertTrue(connected) + return socket + + async def receive_chat_event(self, socket): + while True: + response = await socket.receive_json_from() + if response.get("type") not in {"set_online", "set_offline"}: + return response + + def event(self, event_type="message_read", **overrides): + return { + "type": event_type, + "content": { + "chat_type": "project", + "chat_id": str(self.chat.pk), + "message_id": self.message.pk, + **overrides, + }, + } + + @override_settings(RUNNING_TESTS=False) + async def test_read_events_and_edits_preserve_the_other_members_unread_state(self): + author = await self.connect(self.author) + reader = await self.connect(self.reader) + other = await self.connect(self.other_reader) + try: + await reader.send_json_to(self.event()) + receipt = await self.receive_chat_event(reader) + self.assertEqual(receipt, await self.receive_chat_event(author)) + self.assertEqual(receipt["content"]["read_count"], 1) + self.assertTrue(await other.receive_nothing()) + + await reader.send_json_to(self.event()) + self.assertEqual(await self.receive_chat_event(reader), receipt) + self.assertTrue(await author.receive_nothing()) + + await author.send_json_to(self.event("edit_message", text="Исправлено")) + for socket, expected in ((author, True), (reader, True), (other, False)): + edit = await self.receive_chat_event(socket) + self.assertEqual(edit["type"], "edit_message") + self.assertIs(edit["content"]["message"]["is_read"], expected) + self.assertEqual(edit["content"]["message"]["read_count"], 1) + finally: + await other.disconnect() + await reader.disconnect() + await author.disconnect() + self.assertEqual(ProjectChatMessageRead.objects.count(), 1) + + async def test_read_rejects_deleted_messages_and_messages_from_another_chat(self): + another = create_project_chat(self.author, self.reader) + deleted = ProjectChatMessage.objects.create( + chat=self.chat, author=self.author, text="Удалено", is_deleted=True + ) + socket = await self.connect(self.reader) + try: + for event in ( + self.event(chat_id=str(another.pk)), + self.event(message_id=deleted.pk), + ): + await socket.send_json_to(event) + self.assertIn("error", await self.receive_chat_event(socket)) + finally: + await socket.disconnect() + self.assertFalse(ProjectChatMessageRead.objects.exists()) + + async def test_author_read_does_not_create_a_receipt_or_notify_members(self): + author = await self.connect(self.author) + try: + await author.send_json_to(self.event()) + self.assertTrue(await author.receive_nothing()) + finally: + await author.disconnect() + self.assertFalse(ProjectChatMessageRead.objects.exists()) 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/urls.py b/chats/urls.py index f670d400..401fe3ff 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,6 +1,29 @@ from django.urls import path +from chats.channel_views import ( + ProgramChannelDetail, + ProgramChannelHistory, + ProgramChannelList, + ProgramChannelMessageChange, + ProgramChannelReadView, +) +from chats.direct_views import DirectBlockView, DirectPrivacyView, DirectSpamReportView +from chats.draft_views import ChatDraftView +from chats.models import DirectChat, ProgramChannel, ProjectChat +from chats.notification_views import ( + ChatMentionRecipientsView, + ChatNotificationSettingsView, +) +from chats.serializers import ( + DirectChatMessageListSerializer, + ProgramChannelMessageSerializer, + ProjectChatMessageListSerializer, +) from chats.views import ( + ChatAttachmentDownload, + ChatMessageChanges, + ChatMessageContext, + ChatFileList, DirectChatList, DirectChatMessageList, HasChatUnreadsView, @@ -15,8 +38,95 @@ app_name = "chats" urlpatterns = [ + path( + "directs//draft/", + ChatDraftView.as_view(chat_model=DirectChat), + name="direct-chat-draft", + ), + path( + "projects//draft/", + ChatDraftView.as_view(chat_model=ProjectChat), + name="project-chat-draft", + ), + path( + "channels//draft/", + ChatDraftView.as_view(chat_model=ProgramChannel), + name="program-channel-draft", + ), + path( + "channels//notification-settings/", + ChatNotificationSettingsView.as_view(chat_model=ProgramChannel), + name="program-channel-notification-settings", + ), + path( + "channels//mention-recipients/", + ChatMentionRecipientsView.as_view(chat_model=ProgramChannel), + name="program-channel-mention-recipients", + ), + path( + "projects//notification-settings/", + ChatNotificationSettingsView.as_view(chat_model=ProjectChat), + name="project-chat-notification-settings", + ), + path( + "projects//mention-recipients/", + ChatMentionRecipientsView.as_view(chat_model=ProjectChat), + name="project-chat-mention-recipients", + ), + path("channels/", ProgramChannelList.as_view(), name="program-channel-list"), + path( + "channels//", + ProgramChannelDetail.as_view(), + name="program-channel-detail", + ), + path( + "channels//messages/", + ProgramChannelHistory.as_view(), + name="program-channel-messages", + ), + path( + "channels//read/", + ProgramChannelReadView.as_view(), + name="program-channel-read", + ), + path( + "channels//messages//", + ProgramChannelMessageChange.as_view(), + name="program-channel-message-change", + ), + path( + "channels//messages/changes/", + ChatMessageChanges.as_view( + chat_model=ProgramChannel, serializer_class=ProgramChannelMessageSerializer + ), + name="program-channel-message-changes", + ), + path( + "channels//messages//context/", + ChatMessageContext.as_view( + chat_model=ProgramChannel, serializer_class=ProgramChannelMessageSerializer + ), + name="program-channel-message-context", + ), + path( + "channels//files/", + ChatFileList.as_view(chat_model=ProgramChannel), + name="program-channel-files", + ), + path( + "channels//messages//files//download/", + ChatAttachmentDownload.as_view(chat_model=ProgramChannel), + 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(), @@ -27,6 +137,25 @@ DirectChatFileList.as_view(), name="direct-chat-files", ), + path( + "directs//messages/changes/", + ChatMessageChanges.as_view( + chat_model=DirectChat, serializer_class=DirectChatMessageListSerializer + ), + name="direct-chat-message-changes", + ), + 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), + name="direct-chat-attachment-download", + ), path("projects/", ProjectChatList.as_view(), name="project-chat-list"), path("projects//", ProjectChatDetail.as_view(), name="project-chat-detail"), path( @@ -39,6 +168,25 @@ ProjectChatFileList.as_view(), name="project-chat-files", ), + path( + "projects//messages/changes/", + ChatMessageChanges.as_view( + chat_model=ProjectChat, serializer_class=ProjectChatMessageListSerializer + ), + name="project-chat-message-changes", + ), + 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), + name="project-chat-attachment-download", + ), path( "has-unreads/", HasChatUnreadsView.as_view(), diff --git a/chats/utils.py b/chats/utils.py index e92ffc05..c31d9198 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -1,16 +1,10 @@ -from typing import Union, Type - from django.conf import settings from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError from chats.exceptions import ( - NonMatchingReplyChatIdException, WrongChatIdException, NonMatchingDirectChatIdException, ) -from chats.models import DirectChatMessage, ProjectChatMessage, FileToMessage -from files.models import UserFile User = get_user_model() @@ -21,97 +15,17 @@ async def orm_get(queryset, **kwargs): return await queryset.aget(**kwargs) -async def orm_create(manager, **kwargs): - if settings.RUNNING_TESTS: - return manager.create(**kwargs) - return await manager.acreate(**kwargs) - - -async def orm_exists(queryset) -> bool: - if settings.RUNNING_TESTS: - return queryset.exists() - return await queryset.aexists() - - -async def orm_save(instance, update_fields=None): - if settings.RUNNING_TESTS: - instance.save(update_fields=update_fields) - return - await instance.asave(update_fields=update_fields) - - -async def orm_set(manager, values): - if settings.RUNNING_TESTS: - manager.set(values) - return - await manager.aset(values) - - -def clean_message_text(text: str) -> str: - """ - Cleans message text. - - """ - - return text.strip() - - -def validate_message_text(text: str) -> bool: - """ - Validates message text. - - """ - # TODO: add bad word filter - return 0 < len(text) <= 8192 - - def get_user_channel_cache_key(user: User) -> str: return f"user_channel_{user.pk}" -async def create_message( - chat_id: Union[str, int], - chat_model: Union[Type[DirectChatMessage], Type[ProjectChatMessage]], - author: User, - text: str, - reply_to: int = None, -) -> Union[DirectChatMessage, ProjectChatMessage]: - """ - Creates message. - - Args: - chat_id: An integer representing chat id. - chat_model: A chat model. - author: A user instance. - text: A string representing message text. - reply_to: An integer representing message id to reply to. - - Returns: - A message instance. - - Raises: - NonMatchingReplyChatIdException: If reply_to message is not in chat. - """ - - try: - return await orm_create( - chat_model.objects, - chat_id=chat_id, - author=author, - text=text, - reply_to=reply_to, - ) - except ValidationError: - raise NonMatchingReplyChatIdException( - f"Message {reply_to} is not in chat {chat_id}" - ) - - async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str, User]: chat_id = content["chat_id"] # check if chat_id is in the format of _ try: user1_id, user2_id = map(int, chat_id.split("_")) - except ValueError: + except (AttributeError, ValueError): raise WrongChatIdException( f'Chat id "{chat_id}" is not in the format of' f" _, where user1_id < user2_id" @@ -119,47 +33,14 @@ async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str # check if user is a member of this chat and get other user if user1_id == current_user.id or user2_id == current_user.id: - other_user = await orm_get( - User.objects, - id=user1_id if user1_id != current_user.id else user2_id - ) + try: + other_user = await orm_get( + User.objects, id=user1_id if user1_id != current_user.id else user2_id + ) + except User.DoesNotExist as error: + raise WrongChatIdException("Собеседник не найден.") from error else: raise NonMatchingDirectChatIdException( f"User {current_user.id} is not a member of chat {chat_id}" ) return chat_id, other_user - - -async def create_file_to_message( - direct_message: Union[str, None, DirectChatMessage], - project_message: Union[str, None, ProjectChatMessage], - file: str, -) -> FileToMessage: - return await orm_create( - FileToMessage.objects, - direct_message=direct_message, project_message=project_message, file=file - ) - - -async def match_files_and_messages(file_urls, messages): - for url in file_urls: - file = await orm_get(UserFile.objects, pk=url) - # implicitly matches a file and a message - await create_file_to_message( - direct_message=messages["direct_message"], - project_message=messages["project_message"], - file=file, - ) - - -def get_all_files(messages): - # looks like something bad - - files = [] - for message in messages: - if hasattr(message, "file_to_message"): - files_in_message = [ - file_to_message.file for file_to_message in message.file_to_message.all() - ] - files.extend(files_in_message) - - return files diff --git a/chats/views.py b/chats/views.py index a0a43b33..3c7c6ead 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,20 +1,46 @@ +from django import forms from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache from rest_framework import status from rest_framework.generics import ( GenericAPIView, ListAPIView, - ListCreateAPIView, RetrieveAPIView, ) 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 rest_framework.views import APIView -from chats.models import ProjectChat, DirectChat +from chats.attachments import ( + chat_attachments, + download_chat_attachment, + get_accessible_chat, +) +from chats.exceptions import MessageValidationException +from chats.drafts import with_draft_marker +from chats.direct_privacy import direct_contact +from chats.direct_reads import unread_direct_messages, with_direct_chat_unreads +from chats.message_context import ( + MAX_CONTEXT_LIMIT, + MessageContextForm, + get_message_context, +) +from chats.models import DirectChat, ProgramChannel, ProjectChat from chats.pagination import MessageListPagination -from chats.permissions import IsProjectChatMember, IsChatMember +from chats.permissions import IsProjectChatMember, IsChatMember, accessible_chats +from chats.program_channels import channel_unread_messages +from chats.project_reads import ( + unread_project_messages, + with_project_chat_unreads, + with_project_message_reads, +) +from chats.search import ChatMessageSearchForm, search_chat_messages from chats.serializers import ( DirectChatListSerializer, DirectChatMessageListSerializer, @@ -22,50 +48,53 @@ ProjectChatMessageListSerializer, ProjectChatDetailSerializer, DirectChatDetailSerializer, + ChatAttachmentSerializer, ) -from chats.utils import get_all_files -from files.models import UserFile -from files.serializers import UserFileSerializer +from chats.services.messages import get_direct_chat_for_participants User = get_user_model() +@method_decorator(never_cache, name="dispatch") class DirectChatList(ListAPIView): serializer_class = DirectChatListSerializer permission_classes = [IsAuthenticated] def get_queryset(self): - return DirectChat.objects.prefetch_related("users").filter( - users=self.request.user - ) + user = self.request.user + chats = accessible_chats(user, DirectChat).prefetch_related("users") + return with_direct_chat_unreads(with_draft_marker(chats, user), user.pk) def get(self, request, *args, **kwargs): - chats = self.get_queryset() serialized_chats = [] - for chat in chats: - try: - user1_id, _ = map(int, chat.id.split("_")) - # TODO сделать проверку на удаление профиля - if user1_id == request.user.id: - opponent = chat.users.all()[1] - else: - opponent = chat.users.first() - - context = {"opponent": opponent} - serialized_chat = DirectChatListSerializer(chat, context=context).data - serialized_chats.append(serialized_chat) - except IndexError: - pass + for chat in self.get_queryset(): + others = [user for user in chat.users.all() if user.pk != request.user.pk] + if len(others) == 1: + opponent = others[0] + elif not others and chat.pk == DirectChat.get_chat_id_from_users( + request.user, request.user + ): + opponent = request.user + else: + continue + context = {**self.get_serializer_context(), "opponent": opponent} + serialized_chats.append(self.get_serializer(chat, context=context).data) return Response(serialized_chats, status=status.HTTP_200_OK) +@method_decorator(never_cache, name="dispatch") class ProjectChatList(ListAPIView): serializer_class = ProjectChatListSerializer permission_classes = [IsAuthenticated] def get_queryset(self): user = self.request.user - return user.get_project_chats() + if not user.is_authenticated: + return ProjectChat.objects.none() + return with_project_chat_unreads( + with_draft_marker(user.get_project_chats().select_related("project"), user), + user.pk, + ) class ProjectChatDetail(RetrieveAPIView): @@ -73,7 +102,13 @@ class ProjectChatDetail(RetrieveAPIView): serializer_class = ProjectChatDetailSerializer permission_classes = [IsProjectChatMember] + def get_queryset(self): + return with_project_chat_unreads( + super().get_queryset().select_related("project"), self.request.user.pk + ) + +@method_decorator(never_cache, name="dispatch") class DirectChatDetail(RetrieveAPIView): queryset = DirectChat.objects.all() serializer_class = DirectChatDetailSerializer @@ -97,12 +132,17 @@ def get(self, request, *args, **kwargs) -> Response: else: opponent = user1 context = {"opponent": opponent} - data = DirectChatDetailSerializer( - DirectChat.get_chat(user1, user2), context=context - ).data + chat = get_direct_chat_for_participants(user1, user2) + chat = with_direct_chat_unreads( + accessible_chats(request.user, DirectChat), request.user.pk + ).get(pk=chat.pk) + data = self.get_serializer(chat, context=context).data 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, @@ -128,69 +168,281 @@ def get(self, request, *args, **kwargs) -> Response: status=status.HTTP_404_NOT_FOUND, data={"detail": "Direct chat does not exist"}, ) + except MessageValidationException as error: + return Response({"detail": str(error)}, status=status.HTTP_404_NOT_FOUND) -class DirectChatMessageList(ListCreateAPIView): +@method_decorator(never_cache, name="dispatch") +class ChatMessageHistory(ListAPIView): + 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): + response = super().get(request, *args, **kwargs) + if hasattr(self, "sync_cursor"): + response.data["sync_cursor"] = self.sync_cursor + return response + + 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) + if ( + not form.cleaned_data["q"] + and form.cleaned_data["author_id"] is None + and form.cleaned_data["scope"] != "files" + ): + self.sync_cursor = chat.message_revision + 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): - try: - return ( - ProjectChat.objects.get(id=self.kwargs["id"]) - .messages.filter(is_deleted=False) - .order_by("-created_at") - .all() + messages = ( + super() + .get_queryset() + .select_related("author") + .prefetch_related("file_to_message__file") + ) + return with_project_message_reads( + messages, self.request.user.pk, include_reply=True + ) + + +@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 ) - except ProjectChat.DoesNotExist: - return ProjectChat.objects.none() + elif isinstance(chat, DirectChat): + 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, + } + ) - def post(self, request, *args, **kwargs): - # TODO: try to create a message in a chat. If chat doesn't exist, create it and then create a message. - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) +MAX_SYNC_LIMIT = 100 +MAX_SYNC_CURSOR = 2**63 - 1 -class DirectChatFileList(ListCreateAPIView): - serializer_class = UserFileSerializer + +class ChatMessageChangesForm(forms.Form): + after = forms.IntegerField(min_value=0, max_value=MAX_SYNC_CURSOR) + limit = forms.IntegerField(required=False, min_value=1, max_value=MAX_SYNC_LIMIT) + + +@method_decorator(never_cache, name="dispatch") +class ChatMessageChanges(GenericAPIView): permission_classes = [IsAuthenticated] + chat_model = None - def get_queryset(self): - messages = self.request.user.direct_chats.get(id=self.kwargs["id"]).messages.all() + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + "after", + openapi.IN_QUERY, + description="Курсор из sync_cursor истории или next_cursor изменений", + type=openapi.TYPE_INTEGER, + required=True, + minimum=0, + ), + openapi.Parameter( + "limit", + openapi.IN_QUERY, + description="Количество изменений на странице", + type=openapi.TYPE_INTEGER, + minimum=1, + maximum=MAX_SYNC_LIMIT, + default=MAX_SYNC_LIMIT, + ), + ], + responses={ + 200: "Изменения, next_cursor и has_more", + 409: "Курсор устарел. Нужно заново загрузить историю.", + }, + ) + def get(self, request, id): + chat = get_accessible_chat(request.user, self.chat_model, id) + form = ChatMessageChangesForm(request.query_params) + if not form.is_valid(): + raise ValidationError(form.errors) + after = form.cleaned_data["after"] + limit = form.cleaned_data["limit"] or MAX_SYNC_LIMIT + high_water = chat.message_revision + if after > high_water: + return Response( + {"detail": "Курсор недействителен. Загрузите историю чата заново."}, + status=status.HTTP_409_CONFLICT, + ) - return get_all_files(messages) + changed = ( + chat.messages.filter(sync_revision__gt=after, sync_revision__lte=high_water) + .order_by("sync_revision") + .select_related("author") + .prefetch_related("file_to_message__file") + ) + if isinstance(chat, ProjectChat): + changed = with_project_message_reads( + changed, request.user.pk, include_reply=True + ) + elif isinstance(chat, DirectChat): + changed = changed.select_related("reply_to__author") + page = list(changed[: limit + 1]) + has_more = len(page) > limit + results = [] + for message in page[:limit]: + if message.is_deleted: + results.append({"type": "delete", "message_id": message.pk}) + else: + results.append( + { + "type": "upsert", + "message_id": message.pk, + "message": self.get_serializer(message).data, + } + ) + # A message may move past this watermark if edited during the read. + # That newer revision will be fetched on the following request. + return Response( + { + "results": results, + "next_cursor": page[limit - 1].sync_revision if has_more else high_water, + "has_more": has_more, + } + ) -class ProjectChatFileList(ListCreateAPIView): - serializer_class = UserFileSerializer - permission_classes = [IsProjectChatMember] +@method_decorator(never_cache, name="dispatch") +class ChatFileList(ListAPIView): + chat_model = None + serializer_class = ChatAttachmentSerializer + permission_classes = [IsAuthenticated] def get_queryset(self): - try: - messages = ProjectChat.objects.get(id=self.kwargs["id"]).messages.all() - return get_all_files(messages) - except ProjectChat.DoesNotExist: - return UserFile.objects.none() + if getattr(self, "swagger_fake_view", False): + return self.serializer_class.Meta.model.objects.none() + chat = get_accessible_chat(self.request.user, self.chat_model, self.kwargs["id"]) + return ( + chat_attachments(chat) + .select_related( + "file", "direct_message", "project_message", "channel_message" + ) + .order_by("file__datetime_uploaded", "pk") + ) + + +class DirectChatFileList(ChatFileList): + chat_model = DirectChat +class ProjectChatFileList(ChatFileList): + chat_model = ProjectChat + + +@method_decorator(never_cache, name="dispatch") +class ChatAttachmentDownload(APIView): + permission_classes = [IsAuthenticated] + chat_model = None + + @swagger_auto_schema( + produces=["application/octet-stream"], + responses={ + 200: openapi.Response("Файл", schema=openapi.Schema(type=openapi.TYPE_FILE)), + 404: "Вложение недоступно", + 502: "Хранилище недоступно", + }, + ) + def get(self, request, id, message_id, attachment_id): + chat = get_accessible_chat(request.user, self.chat_model, id) + return download_chat_attachment(request, chat, message_id, attachment_id) + + +@method_decorator(never_cache, name="dispatch") class HasChatUnreadsView(GenericAPIView): """Returns True if user has unread messages""" @@ -214,22 +466,27 @@ class HasChatUnreadsView(GenericAPIView): ) def get(self, request, *args, **kwargs): user = request.user - # get all user chats - direct_messages = user.direct_chats.all().prefetch_related("messages") - project_chats = user.get_project_chats().prefetch_related("messages") - has_direct_messages_unread = ( - direct_messages.filter(messages__is_read=False) - .exclude(messages__is_deleted=True) - .distinct() + unread_direct_messages(user.pk) + .filter(chat_id__in=accessible_chats(user, DirectChat).values("pk")) .exists() ) has_project_messages_unread = ( - project_chats.filter(messages__is_read=False) - .exclude(messages__is_deleted=True) - .distinct() + unread_project_messages(user.pk) + .filter(chat_id__in=user.get_project_chats().values("pk")) + .exists() + ) + has_channel_messages_unread = ( + channel_unread_messages(user.pk) + .filter(chat_id__in=accessible_chats(user, ProgramChannel).values("pk")) .exists() ) return Response( - {"has_unreads": has_direct_messages_unread or has_project_messages_unread} + { + "has_unreads": ( + has_direct_messages_unread + or has_project_messages_unread + or has_channel_messages_unread + ) + } ) diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index d03205e7..84d448d8 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -14,6 +14,8 @@ class EventType(str, Enum): READ_MESSAGE = "message_read" TYPING = "user_typing" EDIT_MESSAGE = "edit_message" + PROGRAM_CHANNEL_CHANGED = "program_channel_changed" + SUBSCRIBE_PROGRAM_CHANNEL = "subscribe_program_channel" # GENERAL EVENTS SET_ONLINE = "set_online" @@ -23,6 +25,7 @@ class EventType(str, Enum): class EventGroupType(str, Enum): CHATS_RELATED = "CHATS_RELATED" GENERAL_EVENTS = "GENERAL_EVENTS" + PROGRAM_CHANNEL = "PROGRAM_CHANNEL" @dataclass(frozen=True) diff --git a/core/auth/middleware.py b/core/auth/middleware.py index 247baeea..6f77dc74 100644 --- a/core/auth/middleware.py +++ b/core/auth/middleware.py @@ -1,71 +1,6 @@ -import jwt from channels.db import database_sync_to_async -from django.conf import settings -from django.contrib.auth import get_user_model -from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import AuthenticationFailed - -User = get_user_model() - - -class TokenAuthentication: - """ - Simple token based authentication. - - Clients should authenticate by passing the token key in the query parameters. - For example: - - ?token=401f7ac837da42b97f613d789819ff93537bee6a - """ - - model = None - - def get_model(self): - if self.model is not None: - return self.model - from rest_framework.authtoken.models import Token - - return Token - - """ - A custom token model may be used, but must have the following properties. - - * key -- The string identifying the token - * user -- The user to which the token belongs - """ - - def authenticate_credentials(self, key): - model = self.get_model() - try: - token = model.objects.select_related("user").get(key=key) - except model.DoesNotExist: - raise AuthenticationFailed(_("Invalid token.")) - - if not token.user.is_active: - raise AuthenticationFailed(_("User inactive or deleted.")) - - return token.user - - def authenticate(self, token): - """ - Returns a `User` if a correct username and password have been supplied - Args: - token: token key - - Returns: - User: A user instance. - """ - try: - user_id = jwt.decode( - jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"] - )["user_id"] - except jwt.exceptions.DecodeError: - raise AuthenticationFailed(_("Invalid token.")) - except jwt.exceptions.ExpiredSignatureError: - raise AuthenticationFailed(_("Token expired.")) - - user = User.objects.get(pk=user_id) - return user +from rest_framework_simplejwt.authentication import JWTAuthentication @database_sync_to_async @@ -84,13 +19,11 @@ def get_user(scope): "TokenAuthMiddleware." ) token = scope["token"] - user = None try: - auth = TokenAuthentication() - user = auth.authenticate(token) + auth = JWTAuthentication() + return auth.get_user(auth.get_validated_token(token)) except AuthenticationFailed: - pass - return user or AnonymousUser() + return AnonymousUser() class TokenAuthMiddleware: 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/docs/modules/chats.md b/docs/modules/chats.md index 4918c2b6..a5f15219 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -18,10 +18,9 @@ Модуль рабочий и подключен в публичный API через `/chats/`, а WebSocket route подключен через ASGI routing на `/ws/chat/`. -Критичные WebSocket-flow покрыты тестами для личных и проектных чатов. При этом -модуль остается технически сложным: REST API и WebSocket-логика частично -дублируют ответственность, а часть старых views технически допускает `POST`, но -основной рабочий сценарий создания сообщений идет через WebSocket. +Личные и проектные сообщения отправляются через WebSocket. Публикации канала +создаются через REST API. Организаторы также могут отправлять сообщения в +проектные чаты и публиковать записи канала из Django-админки. ## Основные возможности @@ -85,11 +84,109 @@ - `GET /chats/projects//files/` - файлы из сообщений проектного чата. - `GET /chats/has-unreads/` - признак наличия непрочитанных сообщений. +Список и карточка каждого типа чата возвращают персональные `unread_count` +и `first_unread_message_id`. Если непрочитанных нет, ID равен `null`. +Чтобы открыть переписку у первого непрочитанного, используйте этот ID в +`GET /chats///messages//context/`. +Чтение истории и контекста не отмечает сообщения прочитанными. В личных чатах +для этого используется `message_read` или действие в Django-админке. + История сообщений использует `MessageListPagination`: - `limit`, по умолчанию `20`; - `offset`. +## Личные черновики + +Маршрут `/chats/directs//draft/`, `/chats/projects//draft/` или +`/chats/channels//draft/` возвращает через GET поля `text`, `revision` +и `updated_at`. Для нового черновика текст пуст, версия равна нулю. +PUT принимает текст и версию, полученную последним чтением. Ответ `409` +содержит сохраненный черновик, если другой клиент уже его изменил. При совпадении +текста повтор сохранения возвращает текущую версию. + +Пустой текст очищает черновик. После подтверждения отправки клиент может +очистить отправленную версию тем же PUT. Если текст уже обновился на другом +устройстве, сервер сохраняет его. Списки чатов возвращают только `has_draft`. +Для каналов черновики доступны менеджерам программы. Django-админка сохраняет +черновики по тем же правилам и подставляет их в форму сообщения команды или +публикации в канале. + +## Настройки личных сообщений + +- `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//` возвращают сведения о канале, +последнюю видимую публикацию, `can_publish`, персональные `unread_count` и +`first_unread_message_id`. ID канала совпадает с ID программы. История и +вложения доступны зарегистрированным участникам и менеджерам опубликованной программы. +При возврате программы в черновик доступ закрывается, история сохраняется. + +- `GET /chats/channels//messages/` возвращает историю и поддерживает + `q`, `scope` и `author_id`. Переход к совпадению использует + `GET /chats/channels//messages//context/`. +- `POST /chats/channels//messages/` принимает `text`, `file_urls` из + загрузки `POST /files/?preserve_original=true` и необязательный UUID + `client_message_id`. Повтор с тем же UUID возвращает исходную публикацию. + Менеджер может изменить или удалить свою публикацию через `PATCH` или + `DELETE /chats/channels//messages//`. Эти действия также + доступны из раздела «Каналы программ» в Django-админке. +- `POST /chats/channels//read/` с `message_id` отмечает историю до этой + публикации включительно для текущего пользователя. +- `GET /chats/channels//files/` возвращает вложения. Для скачивания + используйте `link` с авторизацией пользователя. + +WebSocket посылает `program_channel_changed` с `content.channel_id` после +сохранения публикации, правки или удаления. После события или переподключения +клиент вызывает `GET /chats/channels//messages/changes/?after=`. +Первый курсор берется из `sync_cursor` обычной истории, последующие из +`next_cursor` ответа. Клиент применяет изменения по `message_id` и продолжает, +пока `has_more` равно `true`. При `409` клиент заново загружает историю. +Если канал стал доступен уже после подключения, отправьте событие +`subscribe_program_channel` с `content.channel_id` и дождитесь подтверждения +того же типа. + +## Уведомления командных чатов и каналов + +Маршруты `/chats/projects//notification-settings/` и +`/chats/channels//notification-settings/` читают персональную настройку +через GET и сохраняют ее через PUT с полем `mode`. Ответ содержит `mode` +и `muted_until` в ISO 8601 или `null`. Режимы `all`, `mentions`, `mute_8h` +и `muted` соответствуют всем сообщениям, только упоминаниям, паузе на восемь +часов и отключению до ручного включения. После паузы действует `all`. + +GET по маршруту `mention-recipients/` в чате или канале возвращает +доступных для упоминания участников. В каналах этот список доступен менеджерам +программы. Клиент передает до 20 числовых ID в +`mentioned_user_ids` при отправке командного сообщения или публикации. +Сервер проверяет участие и сохраняет список вместе с сообщением. + +События `new_message` командного чата и `program_channel_changed` содержат +персональное поле `content.notify`. Для уведомления о публикации в канале +также приходит `content.message_id`. Клиент обновляет историю и непрочитанные +по событиям в любом режиме, а уведомление показывает при `notify: true`. +Повторы отправки, собственные сообщения, правки и удаления не создают нового +уведомления. Настройки и отправка с упоминаниями доступны и в Django-админке. + ## WebSocket WebSocket endpoint: @@ -98,8 +195,8 @@ WebSocket endpoint: /ws/chat/ ``` -Аутентификация выполняется через `TokenAuthMiddleware`. Клиент должен передать -Bearer token так же, как для REST API. +Браузер открывает соединение с подпротоколами `["Bearer", accessToken]`. +Сервер проверяет access token по тем же правилам, что и REST API. Формат входящего события: @@ -248,10 +345,9 @@ Backend: ### 4. Пользователь читает сообщение -`message_read` переводит `is_read=True`. - -Для личного чата прочитать можно только сообщение второго пользователя в своем -чате. Для проектного чата пользователь должен быть участником проекта. +В личном чате `message_read` отмечает входящее сообщение прочитанным. В +проектном чате сервер сохраняет прочтение для текущего участника. Автор видит +число прочитавших. ### 5. Пользователь редактирует или удаляет сообщение @@ -284,27 +380,41 @@ REST API фильтрует такие сообщения. `ChatConsumer`. - `core` - constants/cache helpers для online users. -## Ограничения и риски - -- `DirectChatMessageList` и `ProjectChatMessageList` технически наследуются от - `ListCreateAPIView`, но основной рабочий сценарий создания сообщений сейчас - WebSocket, а не REST `POST`. -- `DirectChatDetail` создает чат при `GET`, то есть чтение имеет side effect. -- `ProjectChatMessageList.get_queryset()` для несуществующего проектного чата - возвращает пустой список, а не 404. -- `DirectChatList.get()` молча пропускает чат, если у него некорректный состав - пользователей. -- `FileToMessage` допускает одновременную пустоту или неоднозначность - `direct_message` / `project_message` на уровне модели; это держится на - вызывающем коде. -- `match_files_and_messages()` прикрепляет `UserFile` по id без явной проверки, - что файл принадлежит текущему пользователю. -- `get_all_files()` собирает файлы Python-циклом по сообщениям и может быть - дорогим на больших историях. -- `DirectChat.get_avatar()` ожидает второго пользователя и может сломаться на - некорректном личном чате. -- `NotificationConsumer` существует как заготовка и не реализован. -- В `chats/managers.py` остался закомментированный legacy-код. +## Синхронизация и вложения + +- При повторе отправки клиент передает тот же `client_message_id`. Сервер + возвращает сохраненное сообщение с прежним ID. При новой отправке нужен + новый UUID. Событие WebSocket подтверждает сохранение на сервере. +- История личных и проектных чатов поддерживает тот же протокол `sync_cursor` + и `messages/changes/`, что и каналы. Курсор берется из нефильтрованной + истории. Пропущенные отметки прочтения нужно обновить из истории и счетчиков. +- Ответы истории и списка вложений содержат защищенный `link`. Клиент + отправляет авторизацию при скачивании. Доступ проверяется по актуальному + членству и видимости сообщения. В браузере загрузку надо выполнить через + авторизованный HTTP-клиент, который получает файл как `Blob` и передает его + на сохранение. Переход по обычному `href` не передает JWT. +- Если файл уже прикреплен к видимому сообщению или публикации, удаление + загруженного файла через `DELETE /files/` возвращает `409` с кодом + `chat_attachment_in_use`. Очистка черновика или очереди после отправки + сохраняет опубликованное вложение. Сброс обложки проекта также сохраняет + файл, если он прикреплен к видимому сообщению. +- Если хранилище не подтвердило удаление, `DELETE /files/` возвращает `502` + с кодом `file_delete_failed`. Запись файла сохраняется для повтора. Если + хранилище уже удалило объект и отвечает `404`, API завершает очистку с `204`. + +Для приемки на dev загрузите контрольный файл через +`POST /files/?preserve_original=true`, отправьте его в тестовый проектный чат, +повторите отправку с тем же `client_message_id` после переподключения и +сравните ID сообщения. Участник скачивает вложение по `link` из истории. +Сравните байты скачанного файла с оригиналом, затем удалите тестового +участника из проекта и проверьте ответ `404` по тому же `link`. + +Отдельно проверьте в Selectel тип контейнера с вложениями и настройки его +публичных доменов. На собственном контрольном объекте проверьте, что без +авторизации Selectel не выдает содержимое. Прямые ссылки на старые объекты +регулируются настройками хранилища. Если общий контейнер обслуживает +публичные обложки или другие общедоступные файлы, закрывать его целиком +нельзя без проверки этих сценариев. ## Тесты diff --git a/files/admin.py b/files/admin.py index 39e14de1..7956bb5c 100644 --- a/files/admin.py +++ b/files/admin.py @@ -1,13 +1,26 @@ import reprlib -from django.contrib import admin +from django.contrib import admin, messages +from django.contrib.admin.actions import delete_selected from django.db import transaction from django.core.exceptions import PermissionDenied from django.forms import ModelForm, FileField from django.db.models import QuerySet +from django.shortcuts import redirect +from chats.attachments import ATTACHMENT_IN_USE, used_by_visible_chat_message +from files.exceptions import SelectelDeleteError from files.service import CDN, SelectelSwiftStorage from files.models import UserFile +from partner_programs.solution_files import used_by_submitted_solution + + +def file_deletion_error(file): + if used_by_submitted_solution(file.pk): + return "Файл используется в сохраненном решении." + if used_by_visible_chat_message(file.pk): + return ATTACHMENT_IN_USE + return None class UserFileForm(ModelForm): @@ -22,16 +35,15 @@ class Meta: class UserFileAdmin(admin.ModelAdmin): form = UserFileForm readonly_fields = ("detected_mime_type",) + actions = ("delete_selected",) def get_deleted_objects(self, objs, request): - from partner_programs.solution_files import used_by_submitted_solution - deleted, counts, permissions_needed, protected = super().get_deleted_objects( objs, request ) for obj in objs: - if used_by_submitted_solution(obj.pk): - protected.append(f"Файл сохраненного решения: {obj}") + if error := file_deletion_error(obj): + protected.append(f"{obj} — {error}") return deleted, counts, permissions_needed, protected list_display = ( @@ -90,26 +102,58 @@ def save_model(self, request, obj, form, change): obj.detected_mime_type = info.detected_mime_type super().save_model(request, obj, form, change) + def delete_view(self, request, object_id, extra_context=None): + try: + return super().delete_view(request, object_id, extra_context) + except SelectelDeleteError as error: + self.message_user(request, str(error), level=messages.ERROR) + return redirect(request.get_full_path()) + @transaction.atomic def delete_model(self, request, obj): - from partner_programs.solution_files import used_by_submitted_solution - obj = UserFile.objects.select_for_update().get(pk=obj.pk) - if used_by_submitted_solution(obj.pk): - raise PermissionDenied("Файл используется в сохраненном решении.") + if error := file_deletion_error(obj): + raise PermissionDenied(error) self.cdn.delete(obj.link) obj.delete() @transaction.atomic def delete_queryset(self, request, queryset): - from partner_programs.solution_files import used_by_submitted_solution - rows = list(queryset.select_for_update().order_by("pk")) - if any(used_by_submitted_solution(obj.pk) for obj in rows): - raise PermissionDenied("Файл используется в сохраненном решении.") for obj in rows: - self.cdn.delete(obj.link) - queryset.delete() + if error := file_deletion_error(obj): + raise PermissionDenied(error) + deleted = [] + for obj in rows: + try: + self.cdn.delete(obj.link) + except SelectelDeleteError: + continue + deleted.append(obj) + if deleted: + self.log_deletions(request, deleted) + queryset.filter(pk__in=[obj.pk for obj in deleted]).delete() + return len(deleted), len(rows) - len(deleted) + + @admin.action(permissions=["delete"], description="Удалить выбранные файлы") + def delete_selected(self, request, queryset): + if not request.POST.get("post"): + return delete_selected(self, request, queryset) + _, _, permissions_needed, protected = self.get_deleted_objects(queryset, request) + if protected: + return delete_selected(self, request, queryset) + if permissions_needed: + raise PermissionDenied + deleted, failed = self.delete_queryset(request, queryset) + if deleted: + self.message_user(request, f"Удалено файлов: {deleted}.", messages.SUCCESS) + if failed: + self.message_user( + request, + f"Не удалось удалить файлов: {failed}. Повторите попытку.", + messages.ERROR, + ) + return None def get_queryset(self, request) -> QuerySet[UserFile]: qs = super().get_queryset(request) diff --git a/files/downloads.py b/files/downloads.py new file mode 100644 index 00000000..24fde90b --- /dev/null +++ b/files/downloads.py @@ -0,0 +1,104 @@ +import requests +from asgiref.sync import sync_to_async +from django.core.handlers.asgi import ASGIRequest +from django.http import Http404, HttpResponse, StreamingHttpResponse +from django.utils.http import content_disposition_header + +from files.exceptions import SelectelUploadError +from files.service import SelectelSwiftStorage, is_storage_url + + +def download_filename(file): + name = file.name + (f".{file.extension}" if file.extension else "") + name = "".join( + char if char not in "/\\" and ord(char) >= 32 and ord(char) != 127 else "_" + for char in name + ).strip(" .") + return name or "Вложение" + + +class DownloadBody: + def __init__(self, upstream): + self.upstream = upstream + self.chunks = upstream.iter_content(chunk_size=64 * 1024) + + def __iter__(self): + return iter(self.chunks) + + async def iter_async(self): + # Django buffers synchronous iterators under ASGI. Pull one chunk at a time. + next_chunk = sync_to_async(next, thread_sensitive=False) + while (chunk := await next_chunk(self.chunks, None)) is not None: + yield chunk + + def close(self): + self.upstream.close() + + +def download_stored_file(request, file): + if not is_storage_url(file.link): + raise Http404("Вложение недоступно.") + try: + upstream = requests.get( + file.link, + headers={ + "X-Auth-Token": SelectelSwiftStorage._get_auth_token(), + "Accept-Encoding": "identity", + }, + stream=True, + timeout=(5, 15), + allow_redirects=False, + ) + except (requests.RequestException, SelectelUploadError, KeyError): + return storage_unavailable() + + if upstream.status_code != 200: + upstream.close() + if upstream.status_code == 404: + raise Http404("Файл не найден в хранилище.") + return storage_unavailable() + + raw_request = getattr(request, "_request", request) + content_type = "application/octet-stream" + if raw_request.method == "HEAD": + upstream.close() + response = HttpResponse(content_type=content_type) + else: + body = DownloadBody(upstream) + if isinstance(raw_request, ASGIRequest): + body = AsyncDownloadBody(body) + response = StreamingHttpResponse(body, content_type=content_type) + response["Content-Disposition"] = content_disposition_header( + as_attachment=True, filename=download_filename(file) + ) + response["Cache-Control"] = "private, no-store" + response["X-Content-Type-Options"] = "nosniff" + length = upstream.headers.get("Content-Length", "") + if ( + length.isascii() + and length.isdecimal() + and not upstream.headers.get("Content-Encoding") + ): + response["Content-Length"] = length + return response + + +class AsyncDownloadBody: + def __init__(self, body): + self.body = body + + def __aiter__(self): + return self.body.iter_async() + + def close(self): + self.body.close() + + +def storage_unavailable(): + response = HttpResponse( + "Не удалось скачать файл. Повторите попытку.", + status=502, + content_type="text/plain; charset=utf-8", + ) + response["Cache-Control"] = "private, no-store" + return response diff --git a/files/exceptions.py b/files/exceptions.py index 0d8778ba..7ecf991a 100644 --- a/files/exceptions.py +++ b/files/exceptions.py @@ -1,2 +1,6 @@ class SelectelUploadError(Exception): pass + + +class SelectelDeleteError(Exception): + pass diff --git a/files/service.py b/files/service.py index 1304af6c..ef36ae96 100644 --- a/files/service.py +++ b/files/service.py @@ -1,5 +1,6 @@ import time from abc import ABC, abstractmethod +from urllib.parse import unquote, urlsplit import requests from django.conf import settings @@ -9,14 +10,38 @@ from files.constants import SUPPORTED_IMAGES_TYPES from files.content import detect_content_type -from files.exceptions import SelectelUploadError +from files.exceptions import SelectelDeleteError, SelectelUploadError from files.helpers import convert_image_to_webp +from files.models import UserFile from files.typings import FileInfo -from procollab.settings import SELECTEL_SWIFT_URL User = get_user_model() +def is_storage_url(link): + try: + base = urlsplit(settings.SELECTEL_SWIFT_URL) + target = urlsplit(link) + path = unquote(target.path) + prefix = unquote(base.path).rstrip("/") + "/" + return ( + target.scheme == base.scheme == "https" + and target.netloc.lower() == base.netloc.lower() + and not target.username + and not target.password + and not target.query + and not target.fragment + and path.startswith(prefix) + and len(path) > len(prefix) + and "\\" not in path + and "%" not in path + and not any(ord(char) < 32 or ord(char) == 127 for char in path) + and all(segment not in ("", ".", "..") for segment in path[1:].split("/")) + ) + except ValueError: + return False + + class File: def __init__( self, @@ -68,8 +93,21 @@ def upload(self, file: File, user: User) -> FileInfo: class SelectelSwiftStorage(Storage): def delete(self, url: str) -> Response: - token = self._get_auth_token() - return requests.delete(url, headers={"X-Auth-Token": token}) + error_message = "Не удалось удалить файл из хранилища." + if not is_storage_url(url): + raise SelectelDeleteError(error_message) + try: + response = requests.delete( + url, + headers={"X-Auth-Token": self._get_auth_token()}, + timeout=(5, 15), + allow_redirects=False, + ) + except (requests.RequestException, SelectelUploadError, KeyError) as error: + raise SelectelDeleteError(error_message) from error + if not (200 <= response.status_code < 300 or response.status_code == 404): + raise SelectelDeleteError(error_message) + return response def upload(self, file: File, user: User) -> FileInfo: url = self._upload(file, user) @@ -86,14 +124,18 @@ def _upload(self, file: File, user: User) -> str: token = self._get_auth_token() url = self._generate_url(file, user) - requests.put( + response = requests.put( url, headers={ "X-Auth-Token": token, "Content-Type": file.content_type, }, data=file.buffer, + timeout=(5, 60), + allow_redirects=False, ) + if not 200 <= response.status_code < 300: + raise SelectelUploadError("Не удалось загрузить файл в хранилище.") return url @@ -104,7 +146,7 @@ def _generate_url(self, file: File, user: User) -> str: url: str looks like /hashedEmail/hashedFilename_hashedTime.extension """ return ( - f"{SELECTEL_SWIFT_URL}" + f"{settings.SELECTEL_SWIFT_URL.rstrip('/')}/" f"{abs(hash(user.email))}" f"/{abs(hash(file.name))}" f"_{abs(hash(time.time()))}" @@ -130,7 +172,12 @@ def _get_auth_token(): } } } - response = requests.post(settings.SELECTEL_AUTH_TOKEN_URL, json=data) + response = requests.post( + settings.SELECTEL_AUTH_TOKEN_URL, + json=data, + timeout=(5, 15), + allow_redirects=False, + ) if response.status_code not in [200, 201]: raise SelectelUploadError( "Couldn't generate a token for Selectel Swift API (selcdn)" @@ -156,3 +203,20 @@ def upload( return self.storage.upload( File(file, quality, preserve_original=preserve_original), user ) + + +def create_user_upload(cdn, file, user, *, preserve_original=False): + info = ( + cdn.upload(file, user, preserve_original=True) + if preserve_original + else cdn.upload(file, user) + ) + return UserFile.objects.create( + user=user, + link=info.url, + name=info.name, + size=info.size, + extension=info.extension, + mime_type=info.mime_type, + detected_mime_type=info.detected_mime_type, + ) diff --git a/files/tests.py b/files/tests.py index e69de29b..52deb2ec 100644 --- a/files/tests.py +++ b/files/tests.py @@ -0,0 +1,104 @@ +from unittest.mock import Mock, patch +from urllib.parse import urlencode + +import requests +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME +from django.contrib.admin.models import DELETION, LogEntry +from django.contrib.admin.utils import quote +from django.test import TestCase, override_settings +from django.urls import reverse +from rest_framework.test import APIClient + +from files.models import UserFile +from projects.tests.helpers import create_staff_user + +STORAGE = "https://storage.example.test/v1/account/media/" + + +@override_settings(ALLOWED_HOSTS=["testserver"], SELECTEL_SWIFT_URL=STORAGE) +class FileDeletionTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = create_staff_user(prefix="file-deletion") + cls.file = UserFile.objects.create(link=STORAGE + "a.pdf", user=cls.user) + + @patch("files.service.SelectelSwiftStorage._get_auth_token", return_value="test") + @patch("files.service.requests.delete") + def test_api_keeps_upload_until_storage_confirms_delete(self, delete, _token): + api = APIClient() + api.force_authenticate(self.user) + url = "/files/?" + urlencode({"link": self.file.pk}) + delete.side_effect = [ + requests.Timeout(), + Mock(status_code=302), + Mock(status_code=503), + Mock(status_code=404), + ] + + for _ in range(3): + response = api.delete(url, secure=True) + self.assertEqual(response.status_code, 502) + self.assertEqual(response.json()["code"], "file_delete_failed") + self.assertTrue(UserFile.objects.filter(pk=self.file.pk).exists()) + self.assertEqual(api.delete(url, secure=True).status_code, 204) + self.assertFalse(UserFile.objects.filter(pk=self.file.pk).exists()) + delete.assert_called_with( + self.file.pk, + headers={"X-Auth-Token": "test"}, + timeout=(5, 15), + allow_redirects=False, + ) + + @patch("files.service.SelectelSwiftStorage._get_auth_token") + @patch("files.service.requests.delete") + def test_api_does_not_send_credentials_for_unmanaged_upload(self, delete, token): + file = UserFile.objects.create( + link="https://external.example.test/a.pdf", user=self.user + ) + api = APIClient() + api.force_authenticate(self.user) + response = api.delete("/files/?" + urlencode({"link": file.pk}), secure=True) + self.assertEqual(response.status_code, 502) + self.assertTrue(UserFile.objects.filter(pk=file.pk).exists()) + token.assert_not_called() + delete.assert_not_called() + + @patch("files.service.SelectelSwiftStorage._get_auth_token", return_value="test") + @patch("files.service.requests.delete", return_value=Mock(status_code=503)) + def test_admin_reports_failure_and_preserves_file(self, delete, _token): + self.client.force_login(self.user) + url = reverse("admin:files_userfile_delete", args=[quote(self.file.pk)]) + response = self.client.post(url, {"post": "yes"}, secure=True, follow=True) + self.assertContains(response, "Не удалось удалить файл из хранилища.") + self.assertTrue(UserFile.objects.filter(pk=self.file.pk).exists()) + self.assertFalse(LogEntry.objects.filter(action_flag=DELETION).exists()) + delete.assert_called_once() + + @patch("files.service.SelectelSwiftStorage._get_auth_token", return_value="test") + @patch("files.service.requests.delete") + def test_admin_bulk_records_only_confirmed_deletions(self, delete, _token): + second = UserFile.objects.create(link=STORAGE + "b.pdf", user=self.user) + delete.side_effect = [Mock(status_code=204), Mock(status_code=503)] + self.client.force_login(self.user) + response = self.client.post( + reverse("admin:files_userfile_changelist"), + { + "action": "delete_selected", + "post": "yes", + ACTION_CHECKBOX_NAME: [self.file.pk, second.pk], + }, + secure=True, + follow=True, + ) + self.assertContains(response, "Удалено файлов: 1.") + self.assertContains(response, "Не удалось удалить файлов: 1.") + self.assertFalse(UserFile.objects.filter(pk=self.file.pk).exists()) + self.assertTrue(UserFile.objects.filter(pk=second.pk).exists()) + self.assertEqual( + list( + LogEntry.objects.filter(action_flag=DELETION).values_list( + "object_id", flat=True + ) + ), + [self.file.pk], + ) diff --git a/files/views.py b/files/views.py index 776ab8a5..3b506ccc 100644 --- a/files/views.py +++ b/files/views.py @@ -1,10 +1,13 @@ +import requests from django.db import transaction from rest_framework import generics from rest_framework import permissions, status from rest_framework.generics import get_object_or_404 from rest_framework.response import Response -from files.service import CDN, SelectelSwiftStorage +from chats.attachments import ATTACHMENT_IN_USE, used_by_visible_chat_message +from files.exceptions import SelectelDeleteError, SelectelUploadError +from files.service import CDN, SelectelSwiftStorage, create_user_upload from files.models import UserFile from files.serializers import UserFileSerializer @@ -43,21 +46,19 @@ def post(self, request): }, status=400, ) - info = ( - self.cdn.upload(request.FILES["file"], request.user, preserve_original=True) - if original == "true" - else self.cdn.upload(request.FILES["file"], request.user) - ) - UserFile.objects.create( - user=request.user, - link=info.url, - name=info.name, - size=info.size, - extension=info.extension, - mime_type=info.mime_type, - detected_mime_type=info.detected_mime_type, - ) - return Response({"url": info.url}, status=status.HTTP_201_CREATED) + try: + upload = create_user_upload( + self.cdn, + request.FILES["file"], + request.user, + preserve_original=original == "true", + ) + except (requests.RequestException, SelectelUploadError, KeyError): + return Response( + {"detail": "Не удалось загрузить файл. Повторите попытку."}, + status=status.HTTP_502_BAD_GATEWAY, + ) + return Response({"url": upload.pk}, status=status.HTTP_201_CREATED) @transaction.atomic def delete(self, request, *args, **kwargs): @@ -84,6 +85,17 @@ def delete(self, request, *args, **kwargs): protect_solution_file(instance) except SolutionError as error: return Response(error.payload, status=error.status_code) - self.cdn.delete(instance.link) # delete the file via api - instance.delete() # delete the UserFile object + if used_by_visible_chat_message(instance.pk): + return Response( + {"code": "chat_attachment_in_use", "detail": ATTACHMENT_IN_USE}, + status=status.HTTP_409_CONFLICT, + ) + try: + self.cdn.delete(instance.link) + except SelectelDeleteError as error: + return Response( + {"code": "file_delete_failed", "detail": str(error)}, + status=status.HTTP_502_BAD_GATEWAY, + ) + instance.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 77633333..1083e631 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -12,6 +12,8 @@ from django.urls import path from django.utils import timezone +from chats.models import ProgramChannel +from chats.permissions import accessible_chats from core.utils import XlsxFileToExport, build_xlsx_download_response from mailing.views import MailingTemplateRender from partner_programs.models import ( @@ -189,6 +191,12 @@ def save_related(self, request, form, formsets, change): form.instance.managers.add(request.user) def change_view(self, request, object_id, form_url="", extra_context=None): + extra_context = { + **(extra_context or {}), + "can_open_program_channel": accessible_chats(request.user, ProgramChannel) + .filter(pk=self._program_id(object_id)) + .exists(), + } if "Руководитель программы" in request.user.groups.all().values_list( "name", flat=True ): @@ -200,6 +208,13 @@ def change_view(self, request, object_id, form_url="", extra_context=None): return super().change_view(request, object_id, form_url, extra_context) + @staticmethod + def _program_id(object_id): + try: + return int(object_id) + except (ValueError, TypeError): + return None + def get_urls(self): default_urls = super(PartnerProgramAdmin, self).get_urls() custom_urls = [ diff --git a/projects/cover_reset.py b/projects/cover_reset.py index c8d6eaa4..4bf1d6fc 100644 --- a/projects/cover_reset.py +++ b/projects/cover_reset.py @@ -7,6 +7,7 @@ from django.db.models import Q from rest_framework.exceptions import APIException +from chats.attachments import used_by_visible_chat_message from files.models import UserFile from files.service import CDN, SelectelSwiftStorage from projects.models import DefaultProjectAvatar, DefaultProjectCover, Project @@ -69,6 +70,7 @@ def cleanup_previous_cover(old_url: str | None, user_id: int) -> None: if ( DefaultProjectCover.objects.filter(image_id=old_url).exists() or DefaultProjectAvatar.objects.filter(image_id=old_url).exists() + or used_by_visible_chat_message(old_url) or Project.objects.filter( Q(cover_image_address=old_url) | Q(image_address=old_url) diff --git a/projects/tests/test_project_cover_reset.py b/projects/tests/test_project_cover_reset.py index 128df9e8..b3a3be59 100644 --- a/projects/tests/test_project_cover_reset.py +++ b/projects/tests/test_project_cover_reset.py @@ -7,6 +7,7 @@ from django.test.utils import CaptureQueriesContext from rest_framework.test import APIClient +from chats.models import FileToMessage, ProjectChat, ProjectChatMessage from files.models import UserFile from invites.models import Invite from project_rates.tests.helpers import create_rate_expert @@ -121,6 +122,21 @@ def test_file_referenced_by_another_project_is_preserved(self): self.cdn.delete.assert_not_called() self.assertTrue(UserFile.objects.filter(pk=self.custom.pk).exists()) + def test_file_attached_to_a_chat_message_is_preserved(self): + chat, _ = ProjectChat.objects.get_or_create(project=self.project) + message = ProjectChatMessage.objects.create( + chat=chat, author=self.leader, text="Файл проекта" + ) + attachment = FileToMessage.objects.create( + file=self.custom, project_message=message + ) + + self.assertEqual(self.reset().status_code, 200) + self.project.refresh_from_db() + self.assertEqual(self.project.cover_image_address, self.default.link) + self.cdn.delete.assert_not_called() + self.assertTrue(FileToMessage.objects.filter(pk=attachment.pk).exists()) + def test_cleanup_network_failure_keeps_success_and_does_not_log_exception(self): self.cdn.delete.side_effect = RuntimeError("private-url-or-token") with self.assertLogs("projects.cover_reset", level="WARNING") as logs: diff --git a/templates/admin/chats/_message.html b/templates/admin/chats/_message.html new file mode 100644 index 00000000..d13c39da --- /dev/null +++ b/templates/admin/chats/_message.html @@ -0,0 +1,46 @@ + +

Сообщение №{{ 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.author_id == request.user.pk and message_change_route %} + {% if not channel_read_route or channel_publish_url %} +

Изменить или удалить

+ {% endif %} + {% endif %} + {% if channel_read_route %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if direct_privacy_url and message.author_id != request.user.pk %} + {% if direct_read_route and not message.is_read %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% csrf_token %} + + +
+ {% 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/channel_publish.html b/templates/admin/chats/channel_publish.html new file mode 100644 index 00000000..f9581f48 --- /dev/null +++ b/templates/admin/chats/channel_publish.html @@ -0,0 +1,15 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

К переписке

+
+ {% csrf_token %} + {{ upload_form.as_p }} + +
+
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} 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/draft.html b/templates/admin/chats/draft.html new file mode 100644 index 00000000..be69ae62 --- /dev/null +++ b/templates/admin/chats/draft.html @@ -0,0 +1,14 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

    К переписке

    + {% if current %} +

    Сохраненная версия

    +

    {{ current.text|linebreaksbr }}

    + {% endif %} +
    + {% csrf_token %} + {{ form.as_p }} + +
    +{% endblock %} diff --git a/templates/admin/chats/message_change.html b/templates/admin/chats/message_change.html new file mode 100644 index 00000000..55544d89 --- /dev/null +++ b/templates/admin/chats/message_change.html @@ -0,0 +1,18 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

    К сообщению в переписке

    +
    + {% csrf_token %} + {{ form.non_field_errors }} + {{ form.action.errors }} +

    {{ form.text.label_tag }} {{ form.text }}

    + {{ form.text.errors }} + +
    +
    + {% csrf_token %} +

    Сообщение будет удалено из переписки у всех участников чата.

    + +
    +{% endblock %} 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 new file mode 100644 index 00000000..6521a222 --- /dev/null +++ b/templates/admin/chats/message_search.html @@ -0,0 +1,45 @@ +{% extends "admin/base_site.html" %} + +{% block content %} + {% if chat.first_unread_message_id %} +

    + Непрочитанных сообщений {{ chat.unread_count }}. + К первому непрочитанному +

    + {% endif %} + {% if draft_url %} +

    Личный черновик

    + {% endif %} + {% if notification_settings_url %} +

    Настройки уведомлений

    + {% endif %} + {% if direct_privacy_url %} +

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

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

    {% if channel_read_route %}Опубликовать в канале{% else %}Написать в чат{% endif %}

    + {% endif %} +
    + {{ form.as_p }} + +
    + {% if page %} +

    Найдено сообщений: {{ page.paginator.count }}

    + {% for message in page %} + {% include "admin/chats/_message.html" with show_context_link=True %} + {% empty %} +

    Сообщения не найдены.

    + {% endfor %} + {% if page.has_other_pages %} + + {% endif %} + {% endif %} +{% endblock %} diff --git a/templates/admin/chats/notification_settings.html b/templates/admin/chats/notification_settings.html new file mode 100644 index 00000000..59301366 --- /dev/null +++ b/templates/admin/chats/notification_settings.html @@ -0,0 +1,11 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

    К переписке

    + {% if muted_until %}

    Уведомления без звука до {{ muted_until|date:"d.m.Y H:i" }}.

    {% endif %} +
    + {% csrf_token %} + {{ form.as_p }} + +
    +{% endblock %} diff --git a/templates/partner_programs/admin/program_manager_change_form.html b/templates/partner_programs/admin/program_manager_change_form.html index 97aa84dd..6be0014d 100644 --- a/templates/partner_programs/admin/program_manager_change_form.html +++ b/templates/partner_programs/admin/program_manager_change_form.html @@ -6,6 +6,9 @@
  • Регистрация
  • Шрифты сертификата
  • {% if not original.draft %} + {% if can_open_program_channel %} +
  • Канал программы
  • + {% endif %}
  • Вернуть в черновик
  • {% endif %} {% endif %} diff --git a/templates/partner_programs/admin/programs_change_form.html b/templates/partner_programs/admin/programs_change_form.html index 136a58e7..eca342d5 100644 --- a/templates/partner_programs/admin/programs_change_form.html +++ b/templates/partner_programs/admin/programs_change_form.html @@ -6,6 +6,9 @@
  • Регистрация
  • Шрифты сертификата
  • {% if not original.draft %} + {% if can_open_program_channel %} +
  • Канал программы
  • + {% endif %}
  • Вернуть в черновик
  • {% endif %} {% endif %} 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): diff --git a/users/models.py b/users/models.py index c4035bb5..db736557 100644 --- a/users/models.py +++ b/users/models.py @@ -230,7 +230,9 @@ def get_project_chats(self) -> QuerySet: from chats.models import ProjectChat user_project_ids = self.collaborations.all().values_list("project_id", flat=True) - return ProjectChat.objects.filter(project__in=user_project_ids) + return ProjectChat.objects.filter( + models.Q(project__leader=self) | models.Q(project_id__in=user_project_ids) + ) def get_full_name(self) -> str: return f"{self.first_name} {self.last_name}"