From c235e367631b92583dab4b8867ff3f77a6b4776a Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 13 Nov 2026 22:05:33 +0300 Subject: [PATCH] Add program channels to the chat backend --- chats/admin.py | 185 +++++++++++- chats/apps.py | 3 + chats/attachments.py | 46 +-- chats/channel_events.py | 25 ++ chats/channel_views.py | 213 +++++++++++++ chats/consumers/chat.py | 42 ++- chats/migrations/0015_program_channels.py | 171 +++++++++++ chats/models.py | 73 ++++- chats/permissions.py | 20 +- chats/program_channels.py | 86 ++++++ chats/search.py | 13 +- chats/serializers.py | 31 +- chats/services/messages.py | 42 ++- chats/signals.py | 11 + chats/tests/test_program_channels.py | 282 ++++++++++++++++++ chats/urls.py | 56 +++- chats/views.py | 25 +- chats/websockets_settings.py | 3 + docs/modules/chats.md | 32 ++ files/service.py | 24 +- files/views.py | 32 +- partner_programs/admin.py | 15 + templates/admin/chats/_message.html | 8 + templates/admin/chats/channel_publish.html | 15 + templates/admin/chats/message_search.html | 3 + .../admin/program_manager_change_form.html | 3 + .../admin/programs_change_form.html | 3 + 27 files changed, 1393 insertions(+), 69 deletions(-) create mode 100644 chats/channel_events.py create mode 100644 chats/channel_views.py create mode 100644 chats/migrations/0015_program_channels.py create mode 100644 chats/program_channels.py create mode 100644 chats/signals.py create mode 100644 chats/tests/test_program_channels.py create mode 100644 templates/admin/chats/channel_publish.html diff --git a/chats/admin.py b/chats/admin.py index bab1f592..077b539d 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -1,5 +1,7 @@ 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 @@ -8,6 +10,7 @@ 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 @@ -23,13 +26,18 @@ ProjectChatMessage, DirectChatMessage, FileToMessage, + ProgramChannel, ) -from chats.permissions import accessible_chats +from chats.permissions import accessible_chats, can_publish_in_program_channel +from chats.program_channels import mark_channel_read, publish_channel_message from chats.project_reads import mark_project_message_read, publish_project_message_read from chats.search import ChatMessageSearchForm, search_chat_messages from chats.services.messages import 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="Пользователи чата") @@ -51,7 +59,7 @@ def message_search_url_name(self): def get_urls(self): object_path = ( - "" if self.model is ProjectChat else "" + "" if self.model is DirectChat else "" ) return [ path( @@ -110,6 +118,7 @@ def message_search(self, request, object_id): "form": form, "page": page, "chat": chat, + **self.extra_channel_context(request, chat), "message_context_route": "admin:" + self.message_search_url_name + "_context", @@ -128,6 +137,9 @@ def get_chat_for_member(self, request, object_id): raise PermissionDenied return chat + def extra_channel_context(self, request, chat): + return {} + 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) @@ -162,10 +174,11 @@ def change_own_message(self, request, object_id, message_id): **change, text=form.cleaned_data["text"] ) } - transaction.on_commit( - partial(self.publish_change, chat, event_type, content), - robust=True, - ) + 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: @@ -183,6 +196,7 @@ def change_own_message(self, request, object_id, message_id): "opts": self.model._meta, "title": "Изменение сообщения", "chat": chat, + **self.extra_channel_context(request, chat), "message": message, "form": form, "message_context_route": search_route + "_context", @@ -237,6 +251,7 @@ def message_context(self, request, object_id, message_id): "opts": self.model._meta, "title": "Сообщение в переписке", "chat": chat, + **self.extra_channel_context(request, chat), "context_form": form, "context": context, "anchor_id": ( @@ -267,6 +282,164 @@ class OwnMessageForm(forms.Form): ) +class ChannelPublicationForm(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": "Некорректный номер публикации."}, + ) + + def __init__(self, user, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["files"].queryset = UserFile.objects.filter(user=user).order_by( + "-datetime_uploaded" + ) + + +class ChannelUploadForm(forms.Form): + file = forms.FileField(label="Файл") + + +@admin.register(ProgramChannel) +class ProgramChannelAdmin(ChatSearchAdmin): + list_display = ("program", "message_search_link") + list_display_links = None + cdn = CDN(storage=SelectelSwiftStorage()) + + 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( + "/publish/", + self.admin_site.admin_view( + require_http_methods(["GET", "POST"])(self.publish_message) + ), + name=self.message_search_url_name + "_publish", + ), + 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): + prefix = "admin:" + self.message_search_url_name + return { + "channel_read_route": prefix + "_read", + "channel_publish_url": ( + reverse(prefix + "_publish", args=[chat.pk]) + if can_publish_in_program_channel(request.user, chat.program) + else None + ), + } + + def change_own_message(self, request, object_id, message_id): + chat = self.get_chat_for_member(request, object_id) + if not can_publish_in_program_channel(request.user, chat.program): + raise PermissionDenied("Публикации доступны менеджерам программы.") + return super().change_own_message(request, object_id, message_id) + + def publish_message(self, request, object_id): + chat = self.get_chat_for_member(request, object_id) + if not can_publish_in_program_channel(request.user, chat.program): + raise PermissionDenied("Публикации доступны менеджерам программы.") + posted = request.method == "POST" + uploading = posted and request.POST.get("action") == "upload" + form = ChannelPublicationForm( + request.user, + request.POST if posted and not uploading else None, + initial={"client_message_id": uuid4()}, + ) + 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: + publication, _ = publish_channel_message( + channel_id=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"], + ) + except MessageValidationException as error: + form.add_error(None, str(error)) + else: + self.message_user(request, "Публикация сохранена.") + 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": "Новая публикация в канале", + "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, + ) + + 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(ChatSearchAdmin): list_display = ( 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 index a20a1838..e4dde1c5 100644 --- a/chats/attachments.py +++ b/chats/attachments.py @@ -1,7 +1,7 @@ from django.http import Http404 from django.urls import reverse -from chats.models import FileToMessage, ProjectChat +from chats.models import DirectChat, FileToMessage, ProgramChannel, ProjectChat from chats.permissions import accessible_chats from files.downloads import download_stored_file @@ -14,31 +14,43 @@ def get_accessible_chat(user, model, chat_id): def chat_attachments(chat): - if isinstance(chat, ProjectChat): - return FileToMessage.objects.filter( - project_message__chat=chat, - project_message__is_deleted=False, - direct_message__isnull=True, - ) - return FileToMessage.objects.filter( - direct_message__chat=chat, - direct_message__is_deleted=False, - project_message__isnull=True, - ) + 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", +} + + +def chat_attachment_field(chat): + return ATTACHMENT_FIELDS[type(chat)] def attachment_download_url(attachment, message): - kind = "project" if attachment.project_message_id is not None else "direct" + 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 + "-chat-attachment-download", + "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 = ( - "project_message_id" if isinstance(chat, ProjectChat) else "direct_message_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: diff --git a/chats/channel_events.py b/chats/channel_events.py new file mode 100644 index 00000000..d584140e --- /dev/null +++ b/chats/channel_events.py @@ -0,0 +1,25 @@ +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): + 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}, + }, + ), + robust=True, + ) diff --git a/chats/channel_views.py b/chats/channel_views.py new file mode 100644 index 00000000..a75e0b7a --- /dev/null +++ b/chats/channel_views.py @@ -0,0 +1,213 @@ +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.exceptions import MessageValidationException +from chats.models import ProgramChannel, ProgramChannelMessage +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) + last_message = serializers.SerializerMethodField() + + 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", + ) + + +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 + ) + + +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 = accessible_chats(user, ProgramChannel).select_related("program") + 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 bd1b5bb9..1df01e78 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -9,6 +9,7 @@ from django.core.cache import cache from django.utils import timezone +from chats.channel_events import channel_group from chats.exceptions import ChatException from chats.permissions import accessible_chats, direct_chat_is_accessible from chats.project_reads import personalize_project_message_event @@ -25,7 +26,7 @@ from users.models import CustomUser from chats.consumers.event_types import DirectEvent, ProjectEvent from chats.utils import get_chat_and_user_ids_from_content -from chats.models import ProjectChat +from chats.models import ProgramChannel, ProjectChat @database_sync_to_async @@ -38,6 +39,16 @@ 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() + + class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -62,6 +73,8 @@ async def connect(self): 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) @@ -169,6 +182,8 @@ async def receive_json(self, content, **kwargs): except KeyError as 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) @@ -274,6 +289,31 @@ 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): + await self.send_json(event) + 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() 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/models.py b/chats/models.py index 22509ffe..0459dd6a 100644 --- a/chats/models.py +++ b/chats/models.py @@ -373,6 +373,60 @@ class Meta: ] +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" + ) + + 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 FileToMessage(models.Model): file = models.OneToOneField( UserFile, @@ -393,6 +447,13 @@ 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): @@ -400,7 +461,17 @@ def download_id(self): @property def is_linked_to_one_message(self): - return (self.direct_message_id is None) != (self.project_message_id is None) + 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/permissions.py b/chats/permissions.py index 1f621c28..4c5537bf 100644 --- a/chats/permissions.py +++ b/chats/permissions.py @@ -1,21 +1,39 @@ 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 + 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 diff --git a/chats/program_channels.py b/chats/program_channels.py new file mode 100644 index 00000000..8c2083ec --- /dev/null +++ b/chats/program_channels.py @@ -0,0 +1,86 @@ +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.downloads 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): + 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, + ) + if created: + notify_channel_change(channel.pk) + 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/search.py b/chats/search.py index 7b910a52..42d6ff9e 100644 --- a/chats/search.py +++ b/chats/search.py @@ -2,7 +2,7 @@ from django.db.models import Case, Exists, F, OuterRef, Q, TextField, Value, When from django.db.models.functions import Concat -from chats.models import ProjectChat +from chats.attachments import ATTACHMENT_FIELDS, chat_attachment_field from core.search import unicode_literal_pattern from files.models import UserFile @@ -72,6 +72,11 @@ def search_chat_messages(chat, filters): def _message_files(chat): - if isinstance(chat, ProjectChat): - return UserFile.objects.filter(file_to_message__project_message_id=OuterRef("pk")) - return UserFile.objects.filter(file_to_message__direct_message_id=OuterRef("pk")) + 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 b91e5d38..eda5aa64 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -7,6 +7,7 @@ ProjectChat, DirectChatMessage, ProjectChatMessage, + ProgramChannelMessage, ) from chats.project_reads import project_message_is_read, with_project_message_reads from files.serializers import UserFileSerializer @@ -27,10 +28,11 @@ class ChatAttachmentSerializer(serializers.ModelSerializer): link = serializers.SerializerMethodField() def get_link(self, attachment) -> str: - message = self.context.get("message") or ( - attachment.project_message - if attachment.project_message_id is not None - else attachment.direct_message + message = ( + self.context.get("message") + or attachment.channel_message + or attachment.project_message + or attachment.direct_message ) return attachment_download_url(attachment, message) @@ -85,6 +87,27 @@ class Meta: fields = ["id", "opponent", "last_message", "name", "image_address"] +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", + "files", + "is_edited", + "created_at", + ] + + class DirectChatDetailSerializer(serializers.ModelSerializer): opponent = serializers.SerializerMethodField() diff --git a/chats/services/messages.py b/chats/services/messages.py index 32b75661..4ebcd4a3 100644 --- a/chats/services/messages.py +++ b/chats/services/messages.py @@ -2,17 +2,25 @@ from django.db import transaction +from chats.channel_events import notify_channel_change from chats.exceptions import MessageValidationException from chats.models import ( DirectChat, DirectChatMessage, FileToMessage, + ProgramChannel, + ProgramChannelMessage, ProjectChat, ProjectChatMessage, ) -from chats.permissions import accessible_chats, is_project_member +from chats.permissions import ( + accessible_chats, + can_publish_in_program_channel, + is_project_member, +) from chats.serializers import ( DirectChatMessageListSerializer, + ProgramChannelMessageSerializer, ProjectChatMessageListSerializer, ) from files.models import UserFile @@ -86,11 +94,12 @@ def _get_files(file_urls, author): def _serialize_message(message_model, serializer_class, message_id): - message = ( - message_model.objects.select_related("author", "reply_to__author") - .prefetch_related("file_to_message__file") - .get(pk=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 @@ -126,14 +135,16 @@ def _create_message( ) file_urls = _normalize_message(text, file_urls) - reply_to = _get_reply(message_model, chat_id, reply_to_id) + 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) message = message_model.objects.create( chat_id=chat_id, author=author, text=text, - reply_to=reply_to, client_message_id=client_message_id, + **reply, ) for user_file in files: FileToMessage.objects.create(file=user_file, **{message_link_field: message}) @@ -236,6 +247,9 @@ def _get_message_for_change(chat_model, chat_id, message_id, author): ) 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): @@ -262,11 +276,13 @@ def edit_message(*, chat_model, chat_id, message_id, author, text): message.text = text message.is_edited = True message.save(update_fields=["text", "is_edited"]) - serializer_class = ( - DirectChatMessageListSerializer - if chat_model is DirectChat - else ProjectChatMessageListSerializer - ) + 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) @@ -276,4 +292,6 @@ def delete_message(*, 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..2dd1926e --- /dev/null +++ b/chats/signals.py @@ -0,0 +1,11 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from chats.models import ProgramChannel +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) diff --git a/chats/tests/test_program_channels.py b/chats/tests/test_program_channels.py new file mode 100644 index 00000000..27c822b2 --- /dev/null +++ b/chats/tests/test_program_channels.py @@ -0,0 +1,282 @@ +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.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) + + @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}, + }, + ) + 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/urls.py b/chats/urls.py index bf736b91..af7b0bf7 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,14 +1,23 @@ from django.urls import path -from chats.models import DirectChat, ProjectChat +from chats.channel_views import ( + ProgramChannelDetail, + ProgramChannelHistory, + ProgramChannelList, + ProgramChannelMessageChange, + ProgramChannelReadView, +) +from chats.models import DirectChat, ProgramChannel, ProjectChat from chats.serializers import ( DirectChatMessageListSerializer, + ProgramChannelMessageSerializer, ProjectChatMessageListSerializer, ) from chats.views import ( ChatAttachmentDownload, ChatMessageChanges, ChatMessageContext, + ChatFileList, DirectChatList, DirectChatMessageList, HasChatUnreadsView, @@ -23,6 +32,51 @@ app_name = "chats" urlpatterns = [ + 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("directs//", DirectChatDetail.as_view(), name="direct-chat-detail"), path( diff --git a/chats/views.py b/chats/views.py index 5c27a8c7..c6dbb390 100644 --- a/chats/views.py +++ b/chats/views.py @@ -29,9 +29,10 @@ MessageContextForm, get_message_context, ) -from chats.models import DirectChat, DirectChatMessage, ProjectChat +from chats.models import DirectChat, DirectChatMessage, ProgramChannel, ProjectChat from chats.pagination import MessageListPagination 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, @@ -292,7 +293,7 @@ def get(self, request, id, message_id): queryset = with_project_message_reads( queryset, request.user.pk, include_reply=True ) - else: + elif isinstance(chat, DirectChat): queryset = queryset.select_related("reply_to__author") context = get_message_context(chat, queryset, message_id, **form.cleaned_data) return Response( @@ -367,7 +368,7 @@ def get(self, request, id): changed = with_project_message_reads( changed, request.user.pk, include_reply=True ) - else: + elif isinstance(chat, DirectChat): changed = changed.select_related("reply_to__author") page = list(changed[: limit + 1]) has_more = len(page) > limit @@ -395,6 +396,7 @@ def get(self, request, id): class ChatFileList(ListAPIView): + chat_model = None serializer_class = ChatAttachmentSerializer permission_classes = [IsAuthenticated] @@ -404,7 +406,9 @@ def get_queryset(self): chat = get_accessible_chat(self.request.user, self.chat_model, self.kwargs["id"]) return ( chat_attachments(chat) - .select_related("file", "direct_message", "project_message") + .select_related( + "file", "direct_message", "project_message", "channel_message" + ) .order_by("file__datetime_uploaded", "pk") ) @@ -472,6 +476,17 @@ def get(self, request, *args, **kwargs): .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/docs/modules/chats.md b/docs/modules/chats.md index 4918c2b6..2dc306c9 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -90,6 +90,38 @@ - `limit`, по умолчанию `20`; - `offset`. +## Каналы программ + +`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` и дождитесь подтверждения +того же типа. + ## WebSocket WebSocket endpoint: diff --git a/files/service.py b/files/service.py index 7a045105..9a379874 100644 --- a/files/service.py +++ b/files/service.py @@ -11,6 +11,7 @@ from files.content import detect_content_type from files.exceptions import 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 @@ -86,14 +87,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 @@ -161,3 +166,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/views.py b/files/views.py index 776ab8a5..036609e7 100644 --- a/files/views.py +++ b/files/views.py @@ -1,10 +1,12 @@ +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 files.exceptions import SelectelUploadError +from files.service import CDN, SelectelSwiftStorage, create_user_upload from files.models import UserFile from files.serializers import UserFileSerializer @@ -43,21 +45,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): 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/templates/admin/chats/_message.html b/templates/admin/chats/_message.html index 4c294ce1..e86c2ef9 100644 --- a/templates/admin/chats/_message.html +++ b/templates/admin/chats/_message.html @@ -7,7 +7,15 @@

Сообщение №{{ message.pk }} · {{ message.author.get_full_name }}

Показать в переписке

{% 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 message.file_to_message.all %}

Вложения:

diff --git a/templates/admin/chats/channel_publish.html b/templates/admin/chats/channel_publish.html new file mode 100644 index 00000000..73713ef2 --- /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/message_search.html b/templates/admin/chats/message_search.html index 0b86b9db..63d1162c 100644 --- a/templates/admin/chats/message_search.html +++ b/templates/admin/chats/message_search.html @@ -1,6 +1,9 @@ {% extends "admin/base_site.html" %} {% block content %} + {% if channel_publish_url %} +

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

+ {% endif %}
{{ form.as_p }} 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 %}