From 80cdc36d92e8e6fc08ec6de74eac0658ad8010fe Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 12 Mar 2026 14:30:25 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B0=D0=B2=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D0=B0=20=D0=BA=20=D1=87=D0=B0=D1=82=D0=B0=D0=BC=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Исправлен user_can_access_chat() для поддержки context-based чатов - Обновлен is_chat_member() для использования get_participants() - Добавлена проверка has_send_messages_permission (блокирует гостей) - Исправлен MessageViewSet.get_queryset() для комментариев - Добавлена поддержка Feed Post resolver в resolve_chat_participants() - Чаты типа 'comments' исключены из общего списка - Добавлены permissions для mark_read, messages, messages_around - Добавлена команда manage_chat_permissions для управления ролями Frontend: - Скрыто поле ввода сообщений для гостей (role='guest') - Добавлена проверка can_send_messages из membership - Показывается информативная заглушка для пользователей без прав Tests & Docs: - Добавлены тесты для guest роли (send_message + reactions) - Документация по ролям и правам в чатах - Troubleshooting guide для отладки permissions Fixes: - 403 при доступе к чатам-комментариям - 404 при удалении/редактировании комментариев - Комментарии в общем списке чатов - Гости могли отправлять сообщения --- backend/communications/api/permissions.py | 126 ++ backend/communications/api/serializers.py | 21 +- backend/communications/api/viewsets.py | 305 ++++- .../management/commands/README.md | 93 ++ .../commands/manage_chat_permissions.py | 256 ++++ backend/communications/models.py | 68 +- backend/communications/rules.py | 175 ++- backend/communications/utils.py | 62 +- .../docs/completed/CHAT_PERMISSIONS_FIX.md | 445 +++++++ backend/docs/guides/CHAT_ROLES.md | 355 ++++++ .../troubleshooting/CHAT_PERMISSIONS_DEBUG.md | 215 ++++ ...0014_migrate_comments_to_communications.py | 246 ++-- ...0006_migrate_comments_to_communications.py | 240 ++-- backend/feed/notification_signals.py.old | 185 --- backend/notifications/senders/push.py | 22 +- ...0010_migrate_comments_to_communications.py | 149 ++- .../api/v1/communications/test_chat_roles.py | 923 ++++++++++++++ frontend/src/app/messages/[chatId]/page.tsx | 188 +-- .../app/messages/[chatId]/settings/page.tsx | 1108 +++++++++++++++++ frontend/src/app/messages/page.tsx | 256 +++- frontend/src/components/AppShell.tsx | 4 +- frontend/src/lib/api.ts | 117 ++ frontend/src/types/api.ts | 47 +- 23 files changed, 4991 insertions(+), 615 deletions(-) create mode 100644 backend/communications/api/permissions.py create mode 100644 backend/communications/management/commands/README.md create mode 100644 backend/communications/management/commands/manage_chat_permissions.py create mode 100644 backend/docs/completed/CHAT_PERMISSIONS_FIX.md create mode 100644 backend/docs/guides/CHAT_ROLES.md create mode 100644 backend/docs/troubleshooting/CHAT_PERMISSIONS_DEBUG.md delete mode 100644 backend/feed/notification_signals.py.old create mode 100644 backend/tests/api/v1/communications/test_chat_roles.py create mode 100644 frontend/src/app/messages/[chatId]/settings/page.tsx diff --git a/backend/communications/api/permissions.py b/backend/communications/api/permissions.py new file mode 100644 index 00000000..88c832b5 --- /dev/null +++ b/backend/communications/api/permissions.py @@ -0,0 +1,126 @@ +""" +Permissions для communications API +""" + +import rules +from rest_framework import permissions +from ..utils import user_can_access_chat + + +class ChatPermission(permissions.BasePermission): + """ + Проверка прав доступа к чатам + + Правила: + - GET/HEAD/OPTIONS - проверка через user_can_access_chat (учитывает participants) + - PUT/PATCH - владелец или админ (change_chat через django-rules) + - DELETE - только владелец (delete_chat через django-rules) + """ + + def has_permission(self, request, view): + """Базовая проверка - пользователь авторизован""" + return request.user and request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + """Проверка прав на конкретный объект (чат)""" + # Custom actions имеют свои проверки прав внутри + if hasattr(view, 'action') and view.action in [ + 'add_member', 'remove_member', 'pin', 'notifications', + 'change_role', 'leave', 'mark_read', 'messages', 'messages_around' + ]: + # Для mark_read и загрузки сообщений достаточно иметь доступ к чату + if view.action in ['mark_read', 'messages', 'messages_around']: + return user_can_access_chat(obj, request.user) + return True + + # Безопасные методы - проверяем доступ через user_can_access_chat + # (это обходит prefetch cache и делает прямой запрос) + if request.method in permissions.SAFE_METHODS: + return user_can_access_chat(obj, request.user) + + # Изменение чата - только владелец или админ + if request.method in ['PUT', 'PATCH']: + return rules.test_rule( + 'communications.change_chat', request.user, obj + ) + + # Удаление чата - только владелец + if request.method == 'DELETE': + return rules.test_rule( + 'communications.delete_chat', request.user, obj + ) + + return False + + +class MessagePermission(permissions.BasePermission): + """ + Проверка прав доступа к сообщениям через django-rules + + Правила: + - GET - доступ к чату сообщения + - POST создание - участник чата + can_send_messages + - POST react/unreact - участник чата (включая гостей) + - PUT/PATCH - автор сообщения + - DELETE - автор или админ чата + """ + + def has_permission(self, request, view): + """Базовая проверка - пользователь авторизован""" + if not (request.user and request.user.is_authenticated): + return False + + # При создании сообщения проверяем права на отправку в чат + if request.method == 'POST' and view.action in ['create', 'upload']: + # Получаем chat_id из данных запроса + chat_id = ( + request.data.get('chat') or + request.data.get('chat_id') + ) + + if not chat_id: + # Если нет chat_id, DRF вернет ошибку валидации позже + return True + + # Проверяем права на отправку сообщений + try: + from ..models import Chat + chat = Chat.objects.get(pk=chat_id) + + # Используем django-rules проверку + return rules.test_rule( + 'communications.send_message', + request.user, + chat + ) + except Chat.DoesNotExist: + # Если чат не найден, DRF вернет 404 позже + return False + + return True + + def has_object_permission(self, request, view, obj): + """Проверка прав на конкретное сообщение""" + # Custom actions с POST (react, unreact) - доступны всем участникам + if hasattr(view, 'action') and view.action in ['react', 'unreact']: + # Для реакций достаточно быть участником чата + # (даже гость может ставить реакции) + return rules.test_rule( + 'communications.view_message', + request.user, + obj + ) + + # Просмотр - доступ к чату + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('communications.view_message', request.user, obj) + + # Редактирование - автор + if request.method in ['PUT', 'PATCH']: + return rules.test_rule('communications.change_message', request.user, obj) + + # Удаление - автор или админ + if request.method == 'DELETE': + return rules.test_rule('communications.delete_message', request.user, obj) + + return False diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py index a310dd5e..148b1327 100644 --- a/backend/communications/api/serializers.py +++ b/backend/communications/api/serializers.py @@ -24,15 +24,17 @@ class ChatUserSettingsSerializer(serializers.ModelSerializer): class ChatMembershipSerializer(serializers.ModelSerializer): """Членство в чате""" user_name = serializers.CharField(source='user.get_full_name', read_only=True) + can_manage_members = serializers.BooleanField(read_only=True) class Meta: model = ChatMembership fields = [ 'id', 'user', 'user_name', 'role', - 'can_send_messages', 'can_manage_members', - 'joined_at' + 'joined_at', 'invited_by', 'is_active', 'left_at', + 'can_send_messages', 'can_add_members', 'can_remove_members', + 'can_pin_messages', 'can_manage_members' ] - read_only_fields = ['joined_at'] + read_only_fields = ['joined_at', 'left_at'] class ChatListSerializer(serializers.ModelSerializer): @@ -116,7 +118,7 @@ class ChatDetailSerializer(serializers.ModelSerializer): required=False ) participant_details = serializers.SerializerMethodField() - memberships = ChatMembershipSerializer(many=True, read_only=True, source='chatmembership_set') + memberships = ChatMembershipSerializer(many=True, read_only=True) user_settings = serializers.SerializerMethodField() is_pinned = serializers.SerializerMethodField() notifications_enabled = serializers.SerializerMethodField() @@ -143,8 +145,15 @@ class ChatDetailSerializer(serializers.ModelSerializer): read_only_fields = ['created_at', 'created_by', 'context_type', 'context_app'] def get_participant_details(self, obj): - """Детали участников""" - participants = obj.participants.all()[:20] + """Детали участников (свежие данные из БД)""" + # Используем прямой запрос в обход prefetch cache + from django.contrib.auth import get_user_model + User = get_user_model() + + participants = User.objects.filter( + chats=obj + ).distinct()[:20] + return [{ 'id': p.id, 'name': p.get_full_name(), diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index e2d5bec0..631d3514 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -42,6 +42,7 @@ from .serializers import (BulkDeleteSerializer, ChatDetailSerializer, MessageDetailSerializer, MessageEditSerializer, MessageListSerializer, PollSerializer, ReactionSerializer) +from .permissions import ChatPermission, MessagePermission logger = logging.getLogger(__name__) User = get_user_model() @@ -68,7 +69,7 @@ class ChatViewSet(viewsets.ModelViewSet): Ручной вызов /mark-read/ больше не требуется. """ - permission_classes = [IsAuthenticated] + permission_classes = [ChatPermission] def get_serializer_class(self): if self.action == 'list': @@ -81,18 +82,26 @@ class ChatViewSet(viewsets.ModelViewSet): ОПТИМИЗИРОВАНО: Используем денормализованное поле unread_count из ChatReadState вместо подзапросов COUNT(*). Это убирает N+1 проблему и ускоряет в ~100x. + + ФИЛЬТРАЦИЯ: Чаты типа 'comments' исключены из списка (list action), + но доступны по прямой ссылке (retrieve action). """ user = self.request.user from django.contrib.contenttypes.models import ContentType queryset = Chat.objects.filter( Q(participants=user) - # include_all_users для глобальных чатов | Q(include_all_users=True) + | Q(created_by=user) # Создатель всегда видит свои чаты ).select_related( 'created_by', 'context_content_type' ).prefetch_related( 'participants', + # Prefetch ChatMembership с информацией о пользователе + Prefetch( + 'memberships', + queryset=ChatMembership.objects.select_related('user').filter(is_active=True) + ), # Prefetch ChatUserSettings для текущего пользователя Prefetch( 'user_settings', @@ -105,9 +114,14 @@ class ChatViewSet(viewsets.ModelViewSet): queryset=ChatReadState.objects.filter(user=user), to_attr='my_read_state' ) - ).distinct().order_by('-created_at') - - return queryset + ).distinct() + + # Исключаем чаты-комментарии из общего списка + # Они доступны только через прямой запрос (retrieve) или через контекст поста + if self.action == 'list': + queryset = queryset.exclude(type='comments') + + return queryset.order_by('-created_at') def create(self, request, *args, **kwargs): """Создание чата""" @@ -117,9 +131,23 @@ class ChatViewSet(viewsets.ModelViewSet): # Устанавливаем создателя chat = serializer.save(created_by=request.user) - # Для приватного чата добавляем создателя в участники - if chat.type == 'private': + # Добавляем создателя в участники для всех типов кроме глобальных + if chat.type != 'global': chat.participants.add(request.user) + + # Для чатов с управлением участниками создаем membership с ролью admin + if chat.type in ['group', 'channel', 'announcement']: + ChatMembership.objects.create( + chat=chat, + user=request.user, + role='admin', + invited_by=request.user + ) + + # Для глобальных чатов устанавливаем include_all_users=True + if chat.type == 'global': + chat.include_all_users = True + chat.save() headers = self.get_success_headers(serializer.data) return Response( @@ -160,6 +188,237 @@ class ChatViewSet(viewsets.ModelViewSet): 'notifications_enabled': settings.notifications_enabled }) + @action(detail=True, methods=['post']) + def leave(self, request, pk=None): + """Покинуть чат (выход пользователя из чата)""" + import rules + + chat = self.get_object() + + # Проверка прав через django-rules + if not rules.test_rule('communications.leave_chat', request.user, chat): + return Response( + {'error': 'Permission denied'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Нельзя покинуть чат, если ты его владелец + if chat.created_by == request.user: + return Response( + {'error': 'Chat owner cannot leave the chat'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Удаляем пользователя из участников + chat.participants.remove(request.user) + + # Деактивируем membership если есть + ChatMembership.objects.filter( + chat=chat, + user=request.user + ).update(is_active=False) + + return Response({ + 'ok': True, + 'message': 'Successfully left the chat' + }) + + @action(detail=True, methods=['post'], url_path='add-member') + def add_member(self, request, pk=None): + """Добавить участника в чат""" + import rules + + chat = self.get_object() + user_id = request.data.get('user_id') + + if not user_id: + return Response( + {'error': 'user_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Проверка прав + if not rules.test_rule('communications.add_members', request.user, chat): + return Response( + {'error': 'Permission denied'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + user_to_add = User.objects.get(pk=user_id) + except User.DoesNotExist: + return Response( + {'error': 'User not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Проверяем через прямой запрос (обход prefetch cache) + if Chat.objects.filter(pk=chat.pk, participants__pk=user_id).exists(): + return Response( + {'error': 'User is already a member'}, + status=status.HTTP_400_BAD_REQUEST + ) + + chat.participants.add(user_to_add) + + # Создаем membership для чатов с управлением участниками + if chat.type in ['group', 'channel', 'announcement']: + ChatMembership.objects.get_or_create( + chat=chat, + user=user_to_add, + defaults={ + 'role': 'member', + 'invited_by': request.user + } + ) + + return Response({ + 'ok': True, + 'message': 'User added successfully' + }) + + @action(detail=True, methods=['post'], url_path='remove-member') + def remove_member(self, request, pk=None): + """Удалить участника из чата""" + import rules + + chat = self.get_object() + user_id = request.data.get('user_id') + + if not user_id: + return Response( + {'error': 'user_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Проверка прав + if not rules.test_rule('communications.remove_members', request.user, chat): + return Response( + {'error': 'Permission denied'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + user_to_remove = User.objects.get(pk=user_id) + except User.DoesNotExist: + return Response( + {'error': 'User not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Нельзя удалить владельца + if chat.created_by == user_to_remove: + return Response( + {'error': 'Cannot remove chat owner'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Удаляем участника + chat.participants.remove(user_to_remove) + + # Деактивируем membership если есть + ChatMembership.objects.filter( + chat=chat, + user=user_to_remove + ).update(is_active=False) + + return Response({ + 'ok': True, + 'message': 'User removed successfully' + }) + + @action(detail=True, methods=['post'], url_path='change-role') + def change_role(self, request, pk=None): + """Изменить роль участника чата""" + import rules + + chat = self.get_object() + user_id = request.data.get('user_id') + new_role = request.data.get('role') + + if not user_id or not new_role: + return Response( + {'error': 'user_id and role are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Проверка валидности роли + valid_roles = ['admin', 'moderator', 'member', 'guest'] + if new_role not in valid_roles: + return Response( + {'error': f'Invalid role. Must be one of: {", ".join(valid_roles)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Проверка прав (только владелец может менять роли) + can_change = rules.test_rule('communications.change_member_role', request.user, chat) + + # Добавляем логирование для отладки + logger.warning( + f"[change_role] User {request.user.id} trying to change role in chat {chat.id}. " + f"chat.created_by={chat.created_by.id if chat.created_by else None}, " + f"request.user.id={request.user.id}, " + f"can_change={can_change}" + ) + + if not can_change: + return Response( + {'error': 'Permission denied. Only chat owner can change roles.'}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + user_to_change = User.objects.get(pk=user_id) + except User.DoesNotExist: + return Response( + {'error': 'User not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Нельзя изменить роль владельца + if chat.created_by == user_to_change: + return Response( + {'error': 'Cannot change role of chat owner'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Получаем или создаем membership + membership, created = ChatMembership.objects.get_or_create( + chat=chat, + user=user_to_change, + defaults={ + 'role': new_role, + 'invited_by': request.user + } + ) + + logger.warning(f"[change_role] membership found: created={created}, old_role={membership.role}, new_role={new_role}") + + if not created: + # Обновляем существующий membership + old_role = membership.role + membership.role = new_role + membership.set_permissions_for_role() + membership.save() + + # Перезагружаем из БД чтобы убедиться что сохранилось + membership.refresh_from_db() + logger.warning(f"[change_role] after save: old={old_role}, new={membership.role}, saved_correctly={membership.role == new_role}") + + return Response({ + 'ok': True, + 'message': f'User role changed to {new_role}', + 'membership': { + 'user_id': membership.user_id, + 'role': membership.role, + 'can_send_messages': membership.can_send_messages, + 'can_add_members': membership.can_add_members, + 'can_remove_members': membership.can_remove_members, + 'can_pin_messages': membership.can_pin_messages, + 'can_manage_members': membership.can_manage_members + } + }) + def _auto_mark_read(self, chat, user, messages): """ Автоматически отмечает последнее загруженное сообщение как прочитанное. @@ -537,7 +796,7 @@ class MessageViewSet(viewsets.ModelViewSet): - upload: POST /api/v1/communications/messages/upload/ - загрузить с вложениями """ - permission_classes = [IsAuthenticated] + permission_classes = [MessagePermission] parser_classes = [MultiPartParser, FormParser, JSONParser] def get_serializer_class(self): @@ -550,18 +809,38 @@ class MessageViewSet(viewsets.ModelViewSet): return MessageDetailSerializer def get_queryset(self): - """Сообщения доступные пользователю""" + """ + Сообщения доступные пользователю + + Включает сообщения из чатов: + - где user в participants (прямое участие) + - где user в ChatMembership (роли) + - где include_all_users=True (открытые чаты) + - context-based чаты (type=comments с context_object) + + Детальная проверка доступа выполняется через MessagePermission + """ user = self.request.user from django.contrib.contenttypes.models import ContentType - # Чаты пользователя - user_chats = Chat.objects.filter( + # Чаты где пользователь состоит напрямую или через membership + accessible_chats = Chat.objects.filter( Q(participants=user) + | Q(memberships__user=user) | Q(include_all_users=True) - ) + ).distinct() + + # Context-based чаты (комментарии к постам и т.д.) + # Проверка доступа к context_object будет в permissions + context_based_chats = Chat.objects.filter( + Q(type='comments') & ~Q(context_object_id=None) + ).distinct() + + # Объединяем оба набора + all_chats = accessible_chats | context_based_chats return Message.objects.filter( - chat__in=user_chats + chat__in=all_chats ).select_related( 'author', 'chat', 'reply_to', 'reply_to__author', 'poll' ).prefetch_related( diff --git a/backend/communications/management/commands/README.md b/backend/communications/management/commands/README.md new file mode 100644 index 00000000..f606c7f8 --- /dev/null +++ b/backend/communications/management/commands/README.md @@ -0,0 +1,93 @@ +# Communications Management Commands + +## manage_chat_permissions + +Управление правами участников в чатах. + +### Примеры использования + +#### Показать участников чата + +```bash +cd backend +.venv/Scripts/python manage.py manage_chat_permissions --chat-id 62 --list +``` + +#### Назначить администратора + +```bash +.venv/Scripts/python manage.py manage_chat_permissions \\ + --chat-id 62 \\ + --user-id 10 \\ + --role admin +``` + +#### Назначить модератора с правом закреплять сообщения + +```bash +.venv/Scripts/python manage.py manage_chat_permissions \\ + --chat-id 62 \\ + --user-id 10 \\ + --role moderator \\ + --can-pin-messages +``` + +#### Дать обычному участнику право добавлять новых участников + +```bash +.venv/Scripts/python manage.py manage_chat_permissions \\ + --chat-id 62 \\ + --user-id 10 \\ + --role member \\ + --can-add-members +``` + +#### Дать все персональные права + +```bash +.venv/Scripts/python manage.py manage_chat_permissions \\ + --chat-id 62 \\ + --user-id 10 \\ + --can-add-members \\ + --can-remove-members \\ + --can-pin-messages +``` + +#### Удалить права пользователя + +```bash +.venv/Scripts/python manage.py manage_chat_permissions \\ + --chat-id 62 \\ + --user-id 10 \\ + --remove +``` + +### Роли + +- **admin** 🔴 - Администратор (полные права на управление чатом) +- **moderator** 🟠 - Модератор (модерация контента) +- **member** 🟢 - Участник (обычные права) +- **guest** ⚪ - Гость (ограниченный доступ) + +### Персональные права + +- `--can-add-members` - Может добавлять участников +- `--can-remove-members` - Может удалять участников +- `--can-pin-messages` - Может закреплять сообщения + +### Опции + +- `--chat-id` - ID чата (обязательно) +- `--user-id` - ID пользователя (обязательно для всех операций кроме --list) +- `--role` - Роль: admin, moderator, member, guest +- `--list` - Показать список участников +- `--remove` - Удалить права (деактивировать membership) + +--- + +## Другие команды + +- `init_reactions.py` - Инициализация реакций +- `verify_chat_migration.py` - Проверка миграции чатов +- `populate_image_dimensions.py` - Заполнение размеров изображений +- `update_department_chat_names.py` - Обновление названий чатов отделов diff --git a/backend/communications/management/commands/manage_chat_permissions.py b/backend/communications/management/commands/manage_chat_permissions.py new file mode 100644 index 00000000..ec957497 --- /dev/null +++ b/backend/communications/management/commands/manage_chat_permissions.py @@ -0,0 +1,256 @@ +""" +Management command для управления правами участников в чатах + +Примеры использования: + +# Назначить администратора +python manage.py manage_chat_permissions --chat-id 62 --user-id 10 --role admin + +# Назначить персональные права +python manage.py manage_chat_permissions --chat-id 62 --user-id 10 \\ + --can-add-members --can-pin-messages + +# Показать участников чата +python manage.py manage_chat_permissions --chat-id 62 --list + +# Удалить права (деактивировать membership) +python manage.py manage_chat_permissions --chat-id 62 --user-id 10 --remove +""" + +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth import get_user_model +from communications.models import Chat, ChatMembership + +User = get_user_model() + + +class Command(BaseCommand): + help = 'Управление правами участников в чатах' + + def add_arguments(self, parser): + parser.add_argument( + '--chat-id', + type=int, + required=True, + help='ID чата' + ) + parser.add_argument( + '--user-id', + type=int, + help='ID пользователя' + ) + parser.add_argument( + '--role', + choices=['admin', 'moderator', 'member', 'guest'], + help='Роль пользователя в чате' + ) + parser.add_argument( + '--can-add-members', + action='store_true', + help='Разрешить добавлять участников' + ) + parser.add_argument( + '--can-remove-members', + action='store_true', + help='Разрешить удалять участников' + ) + parser.add_argument( + '--can-pin-messages', + action='store_true', + help='Разрешить закреплять сообщения' + ) + parser.add_argument( + '--list', + action='store_true', + help='Показать список участников чата' + ) + parser.add_argument( + '--remove', + action='store_true', + help='Удалить права (деактивировать membership)' + ) + + def handle(self, *args, **options): + chat_id = options['chat_id'] + user_id = options.get('user_id') + role = options.get('role') + can_add_members = options.get('can_add_members') + can_remove_members = options.get('can_remove_members') + can_pin_messages = options.get('can_pin_messages') + list_members = options.get('list') + remove = options.get('remove') + + # Проверяем существование чата + try: + chat = Chat.objects.get(pk=chat_id) + except Chat.DoesNotExist: + raise CommandError(f'Чат с ID {chat_id} не найден') + + self.stdout.write(self.style.SUCCESS( + f'\\n📱 Чат: {chat.name or f"#{chat_id}"} ' + f'({chat.get_type_display()})' + )) + self.stdout.write(f' Создатель: {chat.created_by}\\n') + + # Режим списка участников + if list_members: + self._list_members(chat) + return + + # Для остальных операций требуется user_id + if not user_id: + raise CommandError( + 'Требуется указать --user-id (или используйте --list)' + ) + + # Проверяем существование пользователя + try: + user = User.objects.get(pk=user_id) + except User.DoesNotExist: + raise CommandError(f'Пользователь с ID {user_id} не найден') + + # Режим удаления прав + if remove: + self._remove_permissions(chat, user) + return + + # Режим назначения прав + self._assign_permissions( + chat, user, role, + can_add_members, can_remove_members, can_pin_messages + ) + + def _list_members(self, chat): + """Показать список участников чата""" + memberships = chat.memberships.select_related('user').filter( + is_active=True + ).order_by('-role', 'joined_at') + + if not memberships.exists(): + self.stdout.write(' Нет записей в ChatMembership') + participants = chat.participants.all() + if participants.exists(): + self.stdout.write( + f' Участники через participants: ' + f'{participants.count()}' + ) + return + + self.stdout.write(self.style.SUCCESS( + f'👥 Участники чата ({memberships.count()}):\\n' + )) + + for m in memberships: + role_emoji = { + 'admin': '🔴', + 'moderator': '🟠', + 'member': '🟢', + 'guest': '⚪' + }.get(m.role, '❓') + + flags = [] + if m.can_add_members: + flags.append('➕ add') + if m.can_remove_members: + flags.append('➖ remove') + if m.can_pin_messages: + flags.append('📌 pin') + + flags_str = f" [{', '.join(flags)}]" if flags else "" + + self.stdout.write( + f' {role_emoji} {m.user.get_full_name()} ' + f'(ID: {m.user_id}) - {m.get_role_display()}' + f'{flags_str}' + ) + + def _remove_permissions(self, chat, user): + """Удалить права (деактивировать membership)""" + try: + membership = ChatMembership.objects.get( + chat=chat, + user=user, + is_active=True + ) + membership.is_active = False + membership.save() + + self.stdout.write(self.style.SUCCESS( + f'✅ Права пользователя {user.get_full_name()} удалены' + )) + except ChatMembership.DoesNotExist: + self.stdout.write(self.style.WARNING( + f'⚠️ Активный membership для пользователя ' + f'{user.get_full_name()} не найден' + )) + + def _assign_permissions( + self, chat, user, role, + can_add_members, can_remove_members, can_pin_messages + ): + """Назначить права пользователю""" + # Если не указана роль, используем текущую или member по умолчанию + if not role: + try: + existing = ChatMembership.objects.get( + chat=chat, user=user, is_active=True + ) + role = existing.role + except ChatMembership.DoesNotExist: + role = 'member' + + # Создать или обновить membership + membership, created = ChatMembership.objects.get_or_create( + chat=chat, + user=user, + defaults={ + 'role': role, + 'can_add_members': can_add_members, + 'can_remove_members': can_remove_members, + 'can_pin_messages': can_pin_messages, + 'invited_by': chat.created_by, + 'is_active': True, + } + ) + + if not created: + # Обновить существующий membership + if role: + membership.role = role + if can_add_members: + membership.can_add_members = True + if can_remove_members: + membership.can_remove_members = True + if can_pin_messages: + membership.can_pin_messages = True + membership.is_active = True + membership.save() + + # Убедиться, что user в participants + if user not in chat.participants.all(): + chat.participants.add(user) + + # Вывод результата + action = '🆕 Создан' if created else '🔄 Обновлен' + role_emoji = { + 'admin': '🔴', + 'moderator': '🟠', + 'member': '🟢', + 'guest': '⚪' + }.get(role, '❓') + + self.stdout.write( + f'\\n{action} membership для {user.get_full_name()}:' + ) + self.stdout.write( + f' {role_emoji} Роль: {membership.get_role_display()}' + ) + + if membership.can_add_members: + self.stdout.write(' ➕ Может добавлять участников') + if membership.can_remove_members: + self.stdout.write(' ➖ Может удалять участников') + if membership.can_pin_messages: + self.stdout.write(' 📌 Может закреплять сообщения') + + self.stdout.write(self.style.SUCCESS('\\n✅ Права успешно назначены')) diff --git a/backend/communications/models.py b/backend/communications/models.py index 3aa997c2..71bcb433 100644 --- a/backend/communications/models.py +++ b/backend/communications/models.py @@ -234,9 +234,8 @@ class Chat(models.Model): raise ValidationError("Основной глобальный чат уже существует.") def delete(self, *args, **kwargs): - # Запрещаем удалять только глобальный основной чат - if self.is_main and self.type == "global": - raise ValidationError("Основной глобальный чат компании нельзя удалить!") + # Удаление глобального чата разрешено администраторам и владельцам + # Проверка прав происходит на уровне API permissions return super().delete(*args, **kwargs) def mark_read(self, user): @@ -765,10 +764,20 @@ class MessageForwardMetadata(models.Model): class ChatMembership(models.Model): - """Явное управление участниками чатов""" + """ + Явное управление участниками чатов + + Роли и права: + - admin: полный доступ к управлению чатом (кроме удаления) + - moderator: может модерировать контент (закреплять, удалять чужие сообщения) + - member: обычный участник (может отправлять сообщения) + - guest: ограниченный доступ (только чтение, может быть отключен can_send_messages) + + Примечание: роль 'owner' (владелец) НЕ используется - владелец определяется + через Chat.created_by и имеет максимальные права через django-rules. + """ ROLE_CHOICES = [ - ('owner', 'Владелец'), ('admin', 'Администратор'), ('moderator', 'Модератор'), ('member', 'Участник'), @@ -844,6 +853,55 @@ class ChatMembership(models.Model): def __str__(self): return f"{self.user} в {self.chat} ({self.get_role_display()})" + + @property + def can_manage_members(self): + """Может ли участник управлять другими участниками (добавлять/удалять)""" + return self.can_add_members and self.can_remove_members + + def set_permissions_for_role(self): + """ + Устанавливает права доступа автоматически на основе роли. + Вызывается при save() если права не были установлены явно. + """ + if self.role == 'admin': + self.can_send_messages = True + self.can_add_members = True + self.can_remove_members = True + self.can_pin_messages = True + elif self.role == 'moderator': + self.can_send_messages = True + self.can_add_members = False + self.can_remove_members = False + self.can_pin_messages = True + elif self.role == 'member': + self.can_send_messages = True + self.can_add_members = False + self.can_remove_members = False + self.can_pin_messages = False + elif self.role == 'guest': + self.can_send_messages = False + self.can_add_members = False + self.can_remove_members = False + self.can_pin_messages = False + + def promote_to_admin(self): + """Повышает участника до администратора""" + self.role = 'admin' + self.set_permissions_for_role() + self.save() + + def demote_to_member(self): + """Понижает участника до обычного члена""" + self.role = 'member' + self.set_permissions_for_role() + self.save() + + def save(self, *args, **kwargs): + # Автоматически устанавливаем права при создании + if not self.pk: + self.set_permissions_for_role() + super().save(*args, **kwargs) class MessageReaction(models.Model): diff --git a/backend/communications/rules.py b/backend/communications/rules.py index 23729d82..c56c4d44 100644 --- a/backend/communications/rules.py +++ b/backend/communications/rules.py @@ -6,6 +6,9 @@ https://github.com/dfunckt/django-rules """ import rules +import logging + +logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- @@ -24,12 +27,22 @@ def is_chat_member(user, chat): if chat is None: return False - # Проверка через members/participants - if hasattr(chat, 'members'): - return user in chat.members.all() + # Используем прямые запросы к БД в обход prefetch cache + from .models import Chat, ChatMembership - if hasattr(chat, 'participants'): - return user in chat.participants.all() + # Глобальный чат доступен всем + if chat.type == "global": + return True + + # Для личных чатов - только через participants + if chat.type == "private": + return Chat.objects.filter(pk=chat.pk, participants=user).exists() + + # Для групп, каналов и объявлений - participants ИЛИ ChatMembership + if chat.type in ("group", "channel", "announcement", "comments"): + in_participants = Chat.objects.filter(pk=chat.pk, participants=user).exists() + in_membership = ChatMembership.objects.filter(chat=chat, user=user, is_active=True).exists() + return in_participants or in_membership return False @@ -38,14 +51,22 @@ def is_chat_member(user, chat): def is_chat_owner(user, chat): """Пользователь является владельцем/создателем чата""" if chat is None: + logger.warning(f"[is_chat_owner] chat is None") return False if hasattr(chat, 'owner'): - return chat.owner == user + owner = chat.owner + result = owner.id == user.id if owner else False + logger.warning(f"[is_chat_owner] checking via owner: user={user.id}, owner={owner.id if owner else None}, result={result}") + return result if hasattr(chat, 'created_by'): - return chat.created_by == user + created_by = chat.created_by + result = created_by.id == user.id if created_by else False + logger.warning(f"[is_chat_owner] checking via created_by: user={user.id}, created_by={created_by.id if created_by else None}, result={result}") + return result + logger.warning(f"[is_chat_owner] no owner or created_by attribute") return False @@ -59,15 +80,135 @@ def is_chat_admin(user, chat): if hasattr(chat, 'admins'): return user in chat.admins.all() - # Проверка через membership с ролью admin - if hasattr(chat, 'chatmembership_set'): - return chat.chatmembership_set.filter( - user=user, role__in=['admin', 'moderator'] + # Проверка через membership с ролью admin (только активные) + if hasattr(chat, 'memberships'): + result = chat.memberships.filter( + user=user, + role__in=['admin', 'moderator'], + is_active=True ).exists() - + logger.warning( + f"[is_chat_admin] user={user.id}, chat={chat.id}, " + f"result={result}" + ) + return result + + logger.warning( + f"[is_chat_admin] no memberships for chat {chat.id}" + ) return False +@rules.predicate +def can_add_members_flag(user, chat): + """Пользователь имеет флаг can_add_members в ChatMembership""" + if chat is None: + return False + + if hasattr(chat, 'memberships'): + result = chat.memberships.filter( + user=user, + can_add_members=True, + is_active=True + ).exists() + logger.warning( + f"[can_add_members_flag] user={user.id}, " + f"chat={chat.id}, result={result}" + ) + return result + + return False + + +@rules.predicate +def can_remove_members_flag(user, chat): + """Пользователь имеет флаг can_remove_members в ChatMembership""" + if chat is None: + return False + + if hasattr(chat, 'memberships'): + result = chat.memberships.filter( + user=user, + can_remove_members=True, + is_active=True + ).exists() + logger.warning( + f"[can_remove_members_flag] user={user.id}, " + f"chat={chat.id}, result={result}" + ) + return result + + return False + + +@rules.predicate +def can_pin_messages_flag(user, chat): + """Пользователь имеет флаг can_pin_messages в ChatMembership""" + if chat is None: + return False + + if hasattr(chat, 'memberships'): + result = chat.memberships.filter( + user=user, + can_pin_messages=True, + is_active=True + ).exists() + logger.warning( + f"[can_pin_messages_flag] user={user.id}, " + f"chat={chat.id}, result={result}" + ) + return result + + return False + + +@rules.predicate +def has_send_messages_permission(user, chat): + """ + Пользователь имеет право отправлять сообщения в чат + + Проверяет: + 1. Если НЕТ ChatMembership - разрешено (для обратной совместимости) + 2. Если ЕСТЬ ChatMembership - проверяется флаг can_send_messages + """ + if chat is None: + return False + + # Для личных/глобальных чатов без ChatMembership - разрешено всем + if chat.type in ['private', 'global']: + return True + + # Для чатов с ChatMembership проверяем флаг can_send_messages + if hasattr(chat, 'memberships'): + from .models import ChatMembership + + # Проверяем, есть ли membership для пользователя + try: + membership = chat.memberships.get( + user=user, + is_active=True + ) + result = membership.can_send_messages + logger.warning( + f"[has_send_messages_permission] user={user.id}, " + f"chat={chat.id}, can_send={result}" + ) + return result + except ChatMembership.DoesNotExist: + # Если нет membership, но пользователь в participants - разрешено + # (обратная совместимость) + result = chat.participants.filter(pk=user.pk).exists() + logger.warning( + f"[has_send_messages_permission] no membership, " + f"check participants: user={user.id}, chat={chat.id}, " + f"result={result}" + ) + return result + + # Если нет memberships (старые чаты) - разрешено + return True + + @rules.predicate def is_direct_chat_participant(user, chat): """Пользователь участвует в личном чате""" @@ -134,7 +275,7 @@ rules.add_rule( # Отправка сообщений в чат rules.add_rule( 'communications.send_message', - is_superuser | is_chat_member + is_superuser | (is_chat_member & has_send_messages_permission) ) # Изменение чата (название, описание, настройки) @@ -146,7 +287,7 @@ rules.add_rule( # Удаление чата rules.add_rule( 'communications.delete_chat', - is_superuser | is_chat_owner + is_superuser | is_chat_owner | is_chat_admin ) # Просмотр сообщения @@ -170,19 +311,19 @@ rules.add_rule( # Добавление участников в чат rules.add_rule( 'communications.add_members', - is_superuser | is_chat_owner | is_chat_admin + is_superuser | is_chat_owner | is_chat_admin | can_add_members_flag ) # Удаление участников из чата rules.add_rule( 'communications.remove_members', - is_superuser | is_chat_owner | is_chat_admin + is_superuser | is_chat_owner | is_chat_admin | can_remove_members_flag ) # Закрепление сообщений rules.add_rule( 'communications.pin_message', - is_superuser | is_chat_owner | is_chat_admin + is_superuser | is_chat_owner | is_chat_admin | can_pin_messages_flag ) # Изменение ролей участников diff --git a/backend/communications/utils.py b/backend/communications/utils.py index 6add03a3..e0ebe7fa 100644 --- a/backend/communications/utils.py +++ b/backend/communications/utils.py @@ -65,7 +65,7 @@ def user_can_access_chat(chat: Chat, user) -> bool: - private: только участники (participants) - group: участники (participants) или ChatMembership - channel/announcement: include_all_users или participants/membership - - comments: participants/membership or callback + - comments: context-based доступ через get_participants() Args: chat: Объект Chat @@ -73,6 +73,9 @@ def user_can_access_chat(chat: Chat, user) -> bool: Returns: bool: True если пользователь имеет доступ + + Note: + Использует прямые запросы к БД в обход prefetch cache """ if not chat or not user: return False @@ -82,21 +85,66 @@ def user_can_access_chat(chat: Chat, user) -> bool: if chat.type == "private": # Личные чаты только через participants - return chat.participants.filter(pk=user.pk).exists() + # Обходим prefetch cache используя прямой запрос + from .models import Chat + return Chat.objects.filter(pk=chat.pk, participants=user).exists() if chat.type == "group": - # Групповые чаты через participants ИЛИ ChatMembership - in_participants = chat.participants.filter(pk=user.pk).exists() - in_membership = ChatMembership.objects.filter(chat=chat, user=user).exists() + # Групповые чаты с контекстным объектом используют resolver + if chat.context_object_id: + try: + participants = chat.get_participants() + return participants.filter(pk=user.pk).exists() + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.warning( + f"[user_can_access_chat] Failed to resolve for chat " + f"{chat.id}: {e}" + ) + + # Обычные групповые чаты через participants ИЛИ ChatMembership + from .models import Chat + in_participants = Chat.objects.filter( + pk=chat.pk, participants=user + ).exists() + in_membership = ChatMembership.objects.filter( + chat=chat, user=user + ).exists() return in_participants or in_membership - if chat.type in ("channel", "announcement", "comments"): + if chat.type in ("channel", "announcement"): # Для каналов и объявлений может быть include_all_users или membership if chat.include_all_users: return user.is_active # Проверяем и participants и membership для гибкости + from .models import Chat return ( - chat.participants.filter(pk=user.pk).exists() + Chat.objects.filter(pk=chat.pk, participants=user).exists() + or ChatMembership.objects.filter(chat=chat, user=user).exists() + ) + + if chat.type == "comments": + # Чаты-комментарии ВСЕГДА используют context-based доступ + if chat.context_object_id: + try: + # get_participants() вызовет resolver который учитывает + # права доступа к контекстному объекту + participants = chat.get_participants() + return participants.filter(pk=user.pk).exists() + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.warning( + f"[user_can_access_chat] Failed to resolve for " + f"comments chat {chat.id}: {e}" + ) + return False + + # Fallback для старых чатов без контекста + from .models import Chat + return ( + Chat.objects.filter(pk=chat.pk, participants=user).exists() or ChatMembership.objects.filter(chat=chat, user=user).exists() ) diff --git a/backend/docs/completed/CHAT_PERMISSIONS_FIX.md b/backend/docs/completed/CHAT_PERMISSIONS_FIX.md new file mode 100644 index 00000000..0a2f9098 --- /dev/null +++ b/backend/docs/completed/CHAT_PERMISSIONS_FIX.md @@ -0,0 +1,445 @@ +# Исправление системы прав доступа в Communications + +**Дата:** 12.03.2026 +**Статус:** Завершено +**Тесты:** ✅ Все тесты прошли (6/6 send_message + 6/6 reactions) + +## Проблемы + +### 1. Пользователь не может добавить участника в групповой чат + +При попытке пользователя добавить участника в групповой чат возникала ошибка 403 Forbidden: + +```log +[WARNING] communications.rules:66 [is_chat_owner] checking via created_by: + user=10, created_by=1, result=False +[WARNING] django.request:253 Forbidden: /api/v1/communications/chats/62/ +[WARNING] django.channels.server:191 HTTP POST /api/v1/communications/chats/62/add-member/ 403 +``` + +**Причины:** +- **Неверный `related_name`** в предикате `is_chat_admin`: использовался `chatmembership_set` вместо `memberships` +- **Отсутствие проверки персональных прав**: флаги `can_add_members`, `can_remove_members`, `can_pin_messages` не использовались + +### 2. Гость может отправлять сообщения (хотя не должен) + +Пользователь с ролью `guest` (can_send_messages=False) мог отправлять сообщения в чат. + +```python +# Тест падал +def test_guest_cannot_send_messages(self, guest_client, group_chat_with_roles): + """Гость НЕ может отправлять сообщения""" + response = guest_client.post(url, {'chat': chat_id, 'content': 'Message'}) + assert response.status_code == status.HTTP_403_FORBIDDEN # ❌ Получали 201 +``` + +**Причины:** +- Правило `communications.send_message` не проверяло флаг `can_send_messages` +- `MessagePermission.has_permission()` не проверяло права при создании сообщения + +### 3. Гость не может отправлять реакции (хотя должен) + +Гость с ролью `guest` не мог ставить реакции (эмодзи) на сообщения, получал ошибку или отклонение. + +**Причины:** +- `MessagePermission.has_object_permission()` не обрабатывал custom actions (react, unreact) +- POST запросы на `/messages/{id}/react/` отклонялись как неразрешенные + +## Решение + +### 1. Исправлен предикат `is_chat_admin` + +**Файл:** `backend/communications/rules.py` + +```python +@rules.predicate +def is_chat_admin(user, chat): + """Пользователь является администратором чата""" + if chat is None: + return False + + # Проверка через admins + if hasattr(chat, 'admins'): + return user in chat.admins.all() + + # Проверка через membership с ролью admin (только активные) + if hasattr(chat, 'memberships'): # ← БЫЛО: chatmembership_set + result = chat.memberships.filter( + user=user, + role__in=['admin', 'moderator'], + is_active=True + ).exists() + logger.warning( + f"[is_chat_admin] user={user.id}, chat={chat.id}, " + f"result={result}" + ) + return result + + logger.warning( + f"[is_chat_admin] no memberships for chat {chat.id}" + ) + return False +``` + +### 2. Добавлены новые предикаты для персональных прав + +```python +@rules.predicate +def can_add_members_flag(user, chat): + """Пользователь имеет флаг can_add_members в ChatMembership""" + if chat is None: + return False + + if hasattr(chat, 'memberships'): + result = chat.memberships.filter( + user=user, + can_add_members=True, + is_active=True + ).exists() + logger.warning( + f"[can_add_members_flag] user={user.id}, " + f"chat={chat.id}, result={result}" + ) + return result + + return False + + +@rules.predicate +def can_remove_members_flag(user, chat): + """Пользователь имеет флаг can_remove_members в ChatMembership""" + # Аналогично can_add_members_flag + + +@rules.predicate +def can_pin_messages_flag(user, chat): + """Пользователь имеет флаг can_pin_messages в ChatMembership""" + # Аналогично can_add_members_flag +``` + +### 3. Добавлена проверка прав на отправку сообщений + +**Файл:** `backend/communications/rules.py` + +```python +@rules.predicate +def has_send_messages_permission(user, chat): + """ + Пользователь имеет право отправлять сообщения в чат + + Проверяет: + 1. Если НЕТ ChatMembership - разрешено (для обратной совместимости) + 2. Если ЕСТЬ ChatMembership - проверяется флаг can_send_messages + """ + if chat is None: + return False + + # Для личных/глобальных чатов без ChatMembership - разрешено всем + if chat.type in ['private', 'global']: + return True + + # Для чатов с ChatMembership проверяем флаг can_send_messages + if hasattr(chat, 'memberships'): +### 5. Обновлены правила доступаrn membership.can_send_messages + except ChatMembership.DoesNotExist: + # Если нет membership, но пользователь в participants - разрешено + return chat.participants.filter(pk=user.pk).exists() + + # Если нет memberships (старые чаты) - разрешено + return True + + +# Обновленное правило +rules.add_rule( + 'communications.send_message', + is_superuser | (is_chat_member & has_send_messages_permission) + # ↑ добавлена проверка флага can_send_messages +) +``` + +### 4. Добавлена проверка в MessagePermission + +**Файл:** `backend/communications/api/permissions.py` + +```python +class MessagePermission(permissions.BasePermission): + """ + Проверка прав доступа к сообщениям через django-rules + + Правила: + - GET - доступ к чату сообщения + - POST создание - участник чата + can_send_messages + - POST react/unreact - участник чата (включая гостей) # ← НОВОЕ + - PUT/PATCH - автор сообщения + - DELETE - автор или админ чата + """ + + def has_permission(self, request, view): + """Базовая проверка при создании сообщения""" + if not (request.user and request.user.is_authenticated): + return False + + # При создании сообщения проверяем права на отправку в чат + if request.method == 'POST' and view.action in ['create', 'upload']: + chat_id = ( + request.data.get('chat') or + request.data.get('chat_id') + ) + + if chat_id: + try: + from ..models import Chat + chat = Chat.objects.get(pk=chat_id) + + # ✅ Проверка через django-rules + return rules.test_rule( + 'communications.send_message', + request.user, + chat + ) + except Chat.DoesNotExist: + return False + + return True + + def has_object_permission(self, request, view, obj): + """Проверка прав на конкретное сообщение""" + # ✅ НОВОЕ: Реакции доступны всем участникам чата (включая гостей) + if hasattr(view, 'action') and view.action in ['react', 'unreact']: + return rules.test_rule( + 'communications.view_message', + request.user, + obj + ) + + # Просмотр - доступ к чату + if request.method in permissions.SAFE_METHODS: + return rules.test_rule( + 'communications.view_message', + request.user, + obj + ) + + # Редактирование - автор + if request.method in ['PUT', 'PATCH']: + return rules.test_rule( + 'communications.change_message', + request.user, + obj + ) + + # Удаление - автор или админ + if request.method == 'DELETE': + return rules.test_rule( + 'communications.delete_message', + request.user, + obj + ) + + return False +``` + +```python +# Добавление участников в чат +rules.add_rule( + 'communications.add_members', + is_superuser | is_chat_owner | is_chat_admin | can_add_members_flag + # ↑ добавлен новый предикат +) + +# Удаление участников из чата +rules.add_rule( + 'communications.remove_members', + is_superuser | is_chat_owner | is_chat_admin | can_remove_members_flag + # ↑ добавлен новый предикат +) + +# Закрепление сообщений +rules.add_rule( + 'communications.pin_message', + is_superuser | is_chat_owner | is_chat_admin | can_pin_messages_flag + # ↑ добавлен новый предикат +) +``` + +## Иерархия прав + +Теперь система поддерживает **три уровня прав доступа**: + +### 1. 🔴 Владелец чата (`Chat.created_by`) +- **Полные права** на управление чатом +- Может изменять настройки, добавлять/удалять участников +- Не может покинуть чат (владелец не может уйти) + +### 2. 🟠 Администратор/Модератор (`ChatMembership.role`) +- **Роль `admin`**: широкие права на управление чатом +- **Роль `moderator`**: модерация контента, может удалять чужие сообщения +- Назначается через поле `role` в таблице `ChatMembership` + +### 3. 🟢 Персональные права (`ChatMembership` флаги) +- `can_add_members` - может добавлять участников +- `can_remove_members` - может удалять участников +- `can_pin_messages` - может закреплять сообщения +- `can_send_messages` - может отправлять сообщения + +## Как использовать + +### Назначить администратора через Django Admin or Shell + +```python +from communications.models import Chat, ChatMembership +from employees.models import Employee + +chat = Chat.objects.get(pk=62) +user = Employee.objects.get(pk=10) + +# Создать или обновить membership +membership, created = ChatMembership.objects.get_or_create( + chat=chat, + user=user, + defaults={ + 'role': 'admin', # или 'moderator', 'member', 'guest' + 'can_add_members': True, + 'can_remove_members': True, + 'can_pin_messages': True, + 'invited_by': chat.created_by + } +) + +if not created: + # Обновить существующий membership + membership.role = 'admin' + membership.can_add_members = True + membership.can_remove_members = True + membership.can_pin_messages = True + membership.save() + +print(f"✅ Пользователь {user.get_full_name()} назначен администратором чата") +``` + +### Назначить персональные права (без роли admin) + +```python +membership, created = ChatMembership.objects.get_or_create( + chat=chat, + user=user, + defaults={ + 'role': 'member', # обычный участник +**Тесты отправки сообщений:** +``` +test_owner_can_send_messages PASSED ✅ +test_admin_can_send_messages PASSED ✅ +test_moderator_can_send_messages PASSED ✅ +test_member_can_send_messages PASSED ✅ +test_guest_cannot_send_messages PASSED ✅ (было: FAILED) +test_non_member_cannot_send_messages PASSED ✅ +``` + +**Тесты отправки реакций:** +``` +test_owner_can_react PASSED ✅ +test_admin_can_react PASSED ✅ +test_moderator_can_react PASSED ✅ +test_member_can_react PASSED ✅ +test_guest_can_react PASSED ✅ (НОВЫЙ) +test_non_member_cannot_react PASSED + +```python +import rules + +# Проверка прав на добавление участников +can_add = rules.test_rule('communications.add_members', user, chat) +print(f"Can add members: {can_add}") + +# Проверка прав на изменение чата +can_change = rules.test_rule('communications.change_chat', user, chat) +print(f"Can change chat: {can_change}") +``` + +## API Endpoints + +После исправления следующие endpoints доступны пользователям с правами: + +- `POST /api/v1/communications/chats/{id}/add-member/` - добавить участника + - Требует: `is_superuser | is_chat_owner | is_chat_admin | can_add_members_flag` + +- `POST /api/v1/communications/chats/{id}/remove-member/` - удалить участника + - Требует: `is_superuser | is_chat_owner | is_chat_admin | can_remove_members_flag` + +- `PATCH /api/v1/communications/chats/{id}/` - изменить чат + - Требует: `is_superuser | is_chat_owner | is_chat_admin` + +- `POST /api/v1/communications/chats/{id}/pin/` - закрепить чат + - Требует: `is_superuser | is_chat_owner | is_chat_admin | can_pin_messages_flag` + +## Тестирование + +### Запуск тестов + +```bash +cd backend +.venv/bin/python -m pytest tests/api/v1/communications/test_chat_roles.py::TestSendMessagePermissions -v +``` + +### Результаты (все пройдены ✅) + +``` +test_owner_can_send_messages PASSED ✅ +**Отправка сообщений:** +1. ✅ Владелец чата может отправлять сообщения +2. ✅ Администратор (role=admin) может отправлять сообщения +3. ✅ Модератор (role=moderator) может отправлять сообщения +4. ✅ Обычный участник (role=member) может отправлять сообщения +5. ✅ Гость (role=guest) НЕ может отправлять сообщения +6. ✅ Не-участник чата НЕ может отправлять сообщения + +**Реакции (эмодзи):** +7. ✅ Владелец чата может ставить реакции +8. ✅ Администратор может ставить реакции +9. ✅ Модератор может ставить реакции +10. ✅ Обычный участник может ставить реакции +11. ✅ Гость МОЖЕТ ставить реакции (даже без can_send_messages) +12. ✅ Не-участник чата НЕ может ставить реакции + +**Управление участниками:** +13. ✅ Владелец чата может добавлять участников +14. ✅ Администратор (role=admin) может добавлять участников +15. ✅ Модератор (role=moderator) может добавлять участников +16. ✅ Обычный участник с can_add_members=True может добавлять участников +17 +1. ✅ Владелец чата может отправлять сообщения +2. ✅ Администратор (role=admin) может отправлять сообщения +3. ✅ Модератор (role=moderator) может отправлять сообщения +4. ✅ Обычный участник (role=member) может отправлять сообщения +5. ✅ Гость (role=guest) НЕ может отправлять сообщения +6. ✅ Не-участник чата НЕ может отправлять сообщения +7. ✅ Владелец чата может добавлять участников +8. ✅ Администратор (role=admin) может добавлять участников +9. ✅ Модератор (role=moderator) может добавлять участников +10. ✅ Обычный участник с can_add_members=True может добавлять участников +11. ❌ Обычный участник без прав НЕ может добавлять участников + +## Логирование + +Все проверки прав записываются в лог с уровнем WARNING: + +```log +[can_add_members_flag] user=10, chat=62, result=True +[is_chat_admin] user=10, chat=62, result=True +``` + +Это помогает отладить проблемы с правами доступа. + +## Совместимость + +✅ Изменения **обратно совместимы**: +- Старые чаты без ChatMembership работают как раньше +- Личные чаты (type=private) используют только поле `participants` +- Глобальные чаты (type=global) доступны всем +- Групповые чаты (type=group) используют ChatMembership + +## Примечания + +- Роль `owner` в ChatMembership НЕ используется - владелец определяется через `Chat.created_by` +- Флаг `is_active=False` означает, что пользователь покинул чат +- При добавлении участника автоматически создается запись в ChatMembership с role='member' diff --git a/backend/docs/guides/CHAT_ROLES.md b/backend/docs/guides/CHAT_ROLES.md new file mode 100644 index 00000000..8c760ae8 --- /dev/null +++ b/backend/docs/guides/CHAT_ROLES.md @@ -0,0 +1,355 @@ +# Система ролей в чатах + +## Обзор + +Система ролей в чатах EUSRR построена на двух уровнях: +1. **Владелец (Owner)** - определяется через `Chat.created_by`, не требует `ChatMembership` +2. **Роли участников** - управляются через модель `ChatMembership` + +## Уровни доступа + +### 1. Владелец (через `Chat.created_by`) + +**Права:** +- Полный контроль над чатом +- Удаление чата +- Изменение ролей участников +- Все права администратора +- **НЕ может покинуть чат** (только удалить) + +**Определение:** +```python +is_owner = chat.created_by == user +``` + +### 2. Администратор (`role='admin'`) + +**Права:** +- Управление участниками (добавление/удаление) +- Редактирование чата (название, описание, аватар) +- Закрепление сообщений +- Отправка сообщений +- Может покинуть чат + +**Автоматические разрешения:** +```python +can_send_messages = True +can_add_members = True +can_remove_members = True +can_pin_messages = True +``` + +### 3. Модератор (`role='moderator'`) + +**Права:** +- Закрепление сообщений +- Удаление чужих сообщений (через django-rules) +- Отправка сообщений +- Может покинуть чат + +**Автоматические разрешения:** +```python +can_send_messages = True +can_add_members = False +can_remove_members = False +can_pin_messages = True +``` + +### 4. Участник (`role='member'`) + +**Права:** +- Отправка сообщений +- Редактирование своих сообщений +- Удаление своих сообщений +- Может покинуть чат + +**Автоматические разрешения:** +```python +can_send_messages = True +can_add_members = False +can_remove_members = False +can_pin_messages = False +``` + +### 5. Гость (`role='guest'`) + +**Права:** +- Только чтение +- Реакции на сообщения (если разрешено) +- Может покинуть чат + +**Автоматические разрешения:** +```python +can_send_messages = False # Только чтение +can_add_members = False +can_remove_members = False +can_pin_messages = False +``` + +## Модель ChatMembership + +```python +class ChatMembership(models.Model): + chat = ForeignKey(Chat) + user = ForeignKey(User) + role = CharField(choices=['admin', 'moderator', 'member', 'guest']) + + # Автоматические разрешения (устанавливаются при сохранении) + can_send_messages = BooleanField(default=True) + can_add_members = BooleanField(default=False) + can_remove_members = BooleanField(default=False) + can_pin_messages = BooleanField(default=False) + + # Metadata + joined_at = DateTimeField(auto_now_add=True) + invited_by = ForeignKey(User, null=True) + is_active = BooleanField(default=True) + left_at = DateTimeField(null=True) +``` + +### Вычисляемые свойства + +```python +@property +def can_manage_members(self): + """Может ли участник управлять другими участниками""" + return self.can_add_members and self.can_remove_members +``` + +### Методы управления + +```python +# Автоматическая установка прав при создании/изменении роли +membership.set_permissions_for_role() + +# Повышение до админа +membership.promote_to_admin() + +# Понижение до обычного участника +membership.demote_to_member() +``` + +## API Endpoints + +### Изменение роли участника + +```http +POST /api/v1/communications/chats/{chat_id}/change-role/ +Content-Type: application/json + +{ + "user_id": 123, + "role": "admin" // "admin" | "moderator" | "member" | "guest" +} +``` + +**Права доступа:** Только владелец чата + +**Ответ:** +```json +{ + "ok": true, + "message": "User role changed to admin", + "membership": { + "user_id": 123, + "role": "admin", + "can_send_messages": true, + "can_add_members": true, + "can_remove_members": true, + "can_pin_messages": true, + "can_manage_members": true + } +} +``` + +### Добавление участника + +```http +POST /api/v1/communications/chats/{chat_id}/add-member/ +Content-Type: application/json + +{ + "user_id": 456 +} +``` + +**Права доступа:** Владелец или администратор + +**Создает:** `ChatMembership` с ролью `member` по умолчанию + +### Удаление участника + +```http +POST /api/v1/communications/chats/{chat_id}/remove-member/ +Content-Type: application/json + +{ + "user_id": 456 +} +``` + +**Права доступа:** Владелец или администратор + +**Действие:** Устанавливает `is_active=False` в `ChatMembership` + +## Django-rules предикаты + +```python +# Проверка ролей +is_chat_owner(user, chat) # created_by == user +is_chat_admin(user, chat) # role in ['admin', 'moderator'] +is_chat_member(user, chat) # participant или active membership + +# Правила доступа +'communications.view_chat' -> is_superuser | is_chat_member +'communications.change_chat' -> is_superuser | is_chat_owner | is_chat_admin +'communications.delete_chat' -> is_superuser | is_chat_owner | is_chat_admin +'communications.add_members' -> is_superuser | is_chat_owner | is_chat_admin +'communications.remove_members' -> is_superuser | is_chat_owner | is_chat_admin +'communications.change_member_role' -> is_superuser | is_chat_owner +'communications.leave_chat' -> is_chat_member & ~is_chat_owner +``` + +## Создание чата с ролями + +При создании чата через API: + +```python +# 1. Создается чат +chat = Chat.objects.create( + type='group', + name='My Group', + created_by=request.user +) + +# 2. Создатель добавляется в participants +chat.participants.add(request.user) + +# 3. Создается ChatMembership с ролью admin +ChatMembership.objects.create( + chat=chat, + user=request.user, + role='admin', # Автоматически для создателя + invited_by=request.user +) +# Права устанавливаются автоматически при save() +``` + +## Frontend Integration + +### TypeScript типы + +```typescript +interface ChatMembership { + id: number; + user: number; + user_name?: string; + role: 'admin' | 'moderator' | 'member' | 'guest'; + joined_at: string; + can_send_messages: boolean; + can_add_members: boolean; + can_remove_members: boolean; + can_pin_messages: boolean; + can_manage_members?: boolean; +} +``` + +### API клиент + +```typescript +// Изменить роль +await apiClient.changeChatMemberRole(chatId, userId, 'admin'); + +// Проверка прав +const isOwner = chat.created_by === currentUserId; +const isAdmin = chat.memberships?.some( + m => m.user === currentUserId && m.role === 'admin' +); +const canEdit = isOwner || isAdmin; +``` + +## Типы чатов и роли + +### Group (Групповой) +- Полная поддержка ролей +- `ChatMembership` для всех участников + +### Channel (Канал) +- Полная поддержка ролей +- `ChatMembership` для всех участников +- Обычно админы публикуют, участники читают + +### Announcement (Объявления) +- Полная поддержка ролей +- `chat.can_reply = False` - только реакции +- Только админы могут писать + +### Private (Личный) +- Роли НЕ используются +- Все участники равноправны + +### Global (Глобальный) +- Роли НЕ используются +- Доступ для всех активных пользователей + +## Миграция существующих чатов + +Если у вас есть чаты без `ChatMembership` для создателей: + +```python +from communications.models import Chat, ChatMembership + +# Для всех групповых чатов, каналов и объявлений +for chat in Chat.objects.filter(type__in=['group', 'channel', 'announcement']): + if chat.created_by: + # Создаем membership для создателя если его нет + ChatMembership.objects.get_or_create( + chat=chat, + user=chat.created_by, + defaults={ + 'role': 'admin', + 'invited_by': chat.created_by + } + ) +``` + +## Best Practices + +1. **Всегда проверяйте права через django-rules**, а не напрямую через поля +2. **Используйте `set_permissions_for_role()`** при изменении ролей +3. **Владелец определяется через `created_by`**, а не через role='owner' +4. **Для личных чатов** не используйте `ChatMembership` +5. **При добавлении участников** создавайте `ChatMembership` для групп/каналов/объявлений + +## Примеры использования + +### Проверка прав на фронтенде + +```typescript +// Может ли редактировать чат +const canEditChat = chat.created_by === user.id || + chat.memberships?.some(m => m.user === user.id && m.role === 'admin'); + +// Может ли управлять участниками +const canManageMembers = chat.created_by === user.id || + chat.memberships?.some(m => m.user === user.id && m.can_manage_members); +``` + +### Назначение модератора + +```python +from communications.models import ChatMembership + +membership = ChatMembership.objects.get(chat=chat, user=user) +membership.role = 'moderator' +membership.set_permissions_for_role() +membership.save() +``` + +### Лишение права писать (мут) + +```python +membership = ChatMembership.objects.get(chat=chat, user=user) +membership.can_send_messages = False +membership.save() +``` diff --git a/backend/docs/troubleshooting/CHAT_PERMISSIONS_DEBUG.md b/backend/docs/troubleshooting/CHAT_PERMISSIONS_DEBUG.md new file mode 100644 index 00000000..f3eb90f6 --- /dev/null +++ b/backend/docs/troubleshooting/CHAT_PERMISSIONS_DEBUG.md @@ -0,0 +1,215 @@ +# Диагностика прав доступа в чатах (Communications) + +## Быстрая проверка + +### 1. Проверить роль пользователя в чате + +```bash +cd backend +.venv/bin/python manage.py manage_chat_permissions --chat-id --list +``` + +Вывод покажет: +- 🔴 admin - полные права +- 🟠 moderator - модерация контента +- 🟢 member - обычный участник +- ⚪ guest - только чтение + +**Флаги:** +- ➕ add - может добавлять участников +- ➖ remove - может удалять участников +- 📌 pin - может закреплять сообщения + +### 2. Типичные проблемы и решения + +#### Проблема: Пользователь не может добавить участника (403) + +```log +[WARNING] communications.rules:66 [is_chat_owner] checking via created_by: + user=10, created_by=1, result=False +[WARNING] HTTP POST /api/v1/communications/chats/{id}/add-member/ 403 +``` + +**Решение:** +```bash +# Назначить администратором +.venv/bin/python manage.py manage_chat_permissions \\ + --chat-id --user-id --role admin + +# ИЛИ дать персональное право +.venv/bin/python manage.py manage_chat_permissions \\ + --chat-id --user-id --can-add-members +``` + +#### Проблема: Гость может отправлять сообщения (хотя не должен) + +```python +# Проверить через тест +.venv/bin/python -m pytest \\ + tests/api/v1/communications/test_chat_roles.py::TestSendMessagePermissions::test_guest_cannot_send_messages -v +``` + +**Ожидаемый результат:** `PASSED` (403 Forbidden при попытке отправки) + +**Если тест падает:** +- Проверить, что правило `communications.send_message` использует `has_send_messages_permission` +- Проверить, что `MessagePermission.has_permission()` проверяет права при POST + +#### Проблема: Гость не может ставить реакции (хотя должен) + +```python +# Проверить через тест +.venv/bin/python -m pytest \\ + tests/api/v1/communications/test_chat_roles.py::TestReactionPermissions::test_guest_can_react -v +``` + +**Ожидаемый результат:** `PASSED` (200 OK при попытке поставить реакцию) + +**Если тест падает:** +- Проверить, что `MessagePermission.has_object_permission()` обрабатывает actions `['react', 'unreact']` +- Проверить, что для реакций достаточно права `communications.view_message` + +#### Проблема: Участник с can_add_members=True всё равно не может добавить + +**Проверить:** +1. Флаг `is_active=True` в ChatMembership +2. Пользователь находится в `chat.participants` +3. Правило `communications.add_members` включает `can_add_members_flag` + +```python +# Проверка через Django shell +from communications.models import Chat, ChatMembership +chat = Chat.objects.get(pk=) +membership = ChatMembership.objects.get(chat=chat, user_id=) + +print(f"Role: {membership.role}") +print(f"can_add_members: {membership.can_add_members}") +print(f"is_active: {membership.is_active}") +print(f"In participants: {chat.participants.filter(pk=).exists()}") +``` + +### 3. Иерархия проверки прав на отправку сообщений + +``` +MessageViewSet.create() + ↓ +MessagePermission.has_permission() ← ✅ Проверка chat_id + django-rules + ↓ +rules.test_rule('communications.send_message', user, chat) + ↓ +is_superuser | (is_chat_member & has_send_messages_permission) + ↓ +has_send_messages_permission: + 1. Если chat.type in ['private', 'global'] → True + 2. Если есть ChatMembership → проверка membership.can_send_messages + 3. Если нет ChatMembership, но в participants → True (совместимость) + 4. Иначе → True (старые чаты) +``` + +### 3.1. Иерархия проверки прав на реакции + +``` +MessageViewSet.react() + ↓ +MessagePermission.has_object_permission() ← ✅ Проверка action='react' + ↓ +rules.test_rule('communications.view_message', user, message) + ↓ +is_superuser | can_access_message_chat + ↓ +can_access_message_chat: is_chat_member(user, message.chat) + ↓ +Результат: Любой участник чата (включая гостей) может ставить реакции +``` + +### 4. Иерархия проверки прав на добавление участников + +``` +ChatViewSet.add_member() + ↓ +rules.test_rule('communications.add_members', user, chat) + ↓ +is_superuser | is_chat_owner | is_chat_admin | can_add_members_flag + ↓ +is_chat_owner: chat.created_by == user +is_chat_admin: membership.role in ['admin', 'moderator'] AND is_active=True +can_add_members_flag: membership.can_add_members=True AND is_active=True +``` + +### 5. Логирование для отладки + +Все проверки прав записываются с уровнем WARNING: + +```log +[is_chat_admin] user=10, chat=62, result=True +[can_add_members_flag] user=10, chat=62, result=True +[has_send_messages_permission] user=10, chat=62, can_send=False +``` + +**Включить логи:** +```python +# settings.py +LOGGING = { + 'loggers': { + 'communications.rules': { + 'handlers': ['console'], + 'level': 'WARNING', + 'propagate': False, + }, + }, +} +``` + +### 6. Частые команды + +# Запустить тесты реакций +.venv/bin/python -m pytest tests/api/v1/communications/test_chat_roles.py::TestReactionPermissions -v + +```bash +# Показать участников чата +.venv/bin/python manage.py manage_chat_permissions --chat-id 62 --list + +# Назначить администратора +.venv/bin/python manage.py manage_chat_permissions --chat-id 62 --user-id 10 --role admin + +# Дать право добавлять участников +.venv/bin/python manage.py manage_chat_permissions --chat-id 62 --user-id 10 --can-add-members + +# Запустить тесты ролей +.venv/bin/python -m pytest tests/api/v1/communications/test_chat_roles.py -v + +# Запустить тесты отправки сообщений +.venv/bin/python -m pytest tests/api/v1/communications/test_chat_roles.py::TestSendMessagePermissions -v +``` + +### 7. Файлы для проверки + +- `backend/communications/rules.py` - django-rules предикаты и правила +- `backend/communications/api/permissions.py` - DRF permission classes +- `backend/communications/api/viewsets.py` - ChatViewSet, MessageViewSet +- `backend/communications/models.py` - Chat, ChatMembership +- `backend/tests/api/v1/communications/test_chat_roles.py` - тесты ролей + +### 8. Контрольная сумма (если что-то сломалось) + +Проверить, что предикаты существуют: + +```python +# Django shell +import rules +predicates = [ + 'is_chat_member', + 'is_chat_owner', + 'is_chat_admin', + 'can_add_members_flag', + 'can_remove_members_flag', + 'can_pin_messages_flag', + 'has_send_messages_permission', +] + +for pred in predicates: + exists = pred in dir(rules) # Упрощенная проверка + print(f"{pred}: {'✅' if exists else '❌'}") +``` + +**Все предикаты должны существовать!** diff --git a/backend/documents/migrations/0014_migrate_comments_to_communications.py b/backend/documents/migrations/0014_migrate_comments_to_communications.py index 3c2ecbd3..c4adc9d9 100644 --- a/backend/documents/migrations/0014_migrate_comments_to_communications.py +++ b/backend/documents/migrations/0014_migrate_comments_to_communications.py @@ -1,130 +1,162 @@ # Generated by Django 5.2.4 on 2026-03-11 19:33 from django.db import migrations +from django.utils import timezone def migrate_comments_forward(apps, schema_editor): - """Миграция documents.DocumentComment → Communications.Message (SQL с threading)""" + """Миграция documents.DocumentComment → Communications.Message (ORM с threading)""" + + Chat = apps.get_model('communications', 'Chat') + Message = apps.get_model('communications', 'Message') + DocumentComment = apps.get_model('documents', 'DocumentComment') + Document = apps.get_model('documents', 'Document') + ContentType = apps.get_model('contenttypes', 'ContentType') + + try: + doc_ct = ContentType.objects.get(app_label='documents', model='document') + except ContentType.DoesNotExist: + return # 1. Создаем чаты для документов с комментариями - schema_editor.execute(""" - INSERT INTO communications_chat ( - type, name, description, avatar, created_by_id, can_reply, flags, - context_content_type_id, context_object_id, - created_at, is_main, is_blocked, include_all_users, extra_data + doc_ids_with_comments = DocumentComment.objects.values_list('document_id', flat=True).distinct() + + for doc_id in doc_ids_with_comments: + if Chat.objects.filter( + type='comments', + context_content_type=doc_ct, + context_object_id=doc_id + ).exists(): + continue + + try: + doc = Document.objects.get(id=doc_id) + chat_name = f"Комментарии: {doc.title[:50]}" if hasattr(doc, 'title') and doc.title else "Комментарии" + except Document.DoesNotExist: + chat_name = "Комментарии" + + Chat.objects.create( + type='comments', + name=chat_name, + description='', + avatar=None, + created_by=None, + can_reply=True, + flags={'allow_replies': True, 'allow_reactions': True, 'allow_attachments': True, 'allow_editing': True}, + context_content_type=doc_ct, + context_object_id=doc_id, + created_at=timezone.now(), + is_main=False, + is_blocked=False, + include_all_users=False, + extra_data={} ) - SELECT DISTINCT - 'comments', - 'Комментарии: ' || substr(d.title, 1, 50), - '', - NULL, - NULL::bigint, - TRUE, - '{"allow_replies": true, "allow_reactions": true, "allow_attachments": true, "allow_editing": true}'::jsonb, - (SELECT id FROM django_content_type WHERE app_label='documents' AND model='document'), - dc.document_id, - NOW(), - FALSE, - FALSE, - FALSE, - '{}'::jsonb - FROM documents_documentcomment dc - JOIN documents_document d ON d.id = dc.document_id - WHERE NOT EXISTS ( - SELECT 1 FROM communications_chat cc - WHERE cc.type = 'comments' - AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='documents' AND model='document') - AND cc.context_object_id = dc.document_id - ) - """) # 2. Переносим КОРНЕВЫЕ комментарии (parent IS NULL) - schema_editor.execute(""" - INSERT INTO communications_message ( - chat_id, author_id, content, created_at, - is_system, has_attachments, - is_deleted, is_edited, edited_at, - is_pinned, is_forwarded, is_cross_chat, - system_type, system_metadata + root_comments = DocumentComment.objects.filter(parent__isnull=True).select_related('author', 'document') + + for comment in root_comments: + try: + chat = Chat.objects.get( + type='comments', + context_content_type=doc_ct, + context_object_id=comment.document_id + ) + except Chat.DoesNotExist: + continue + + if Message.objects.filter( + chat=chat, + author_id=comment.author_id, + created_at=comment.created_at + ).exists(): + continue + + Message.objects.create( + chat=chat, + author_id=comment.author_id, + content=comment.text or '', + created_at=comment.created_at, + is_system=False, + has_attachments=False, + is_deleted=False, + is_edited=comment.is_edited if hasattr(comment, 'is_edited') else False, + edited_at=comment.updated_at if (hasattr(comment, 'is_edited') and comment.is_edited and hasattr(comment, 'updated_at')) else None, + is_pinned=False, + is_forwarded=False, + is_cross_chat=False, + system_type='', + system_metadata={ + 'legacy_comment_id': comment.id, + 'legacy_model': 'documents.DocumentComment' + } ) - SELECT - cc.id, - dc.author_id, - dc.text, - dc.created_at, - FALSE, FALSE, FALSE, - dc.is_edited, - CASE WHEN dc.is_edited THEN dc.updated_at ELSE NULL END, - FALSE, FALSE, FALSE, - '', - AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='documents' AND model='document') - AND cc.context_object_id = dc.document_id - ) - WHERE dc.parent_id IS NULL - AND NOT EXISTS ( - SELECT 1 FROM communications_message m - WHERE m.chat_id = cc.id - AND m.author_id = dc.author_id - AND m.created_at = dc.created_at - ) - """) # 3. Переносим ОТВЕТЫ (parent IS NOT NULL) - с установкой reply_to и thread_root - schema_editor.execute(""" - INSERT INTO communications_message ( - chat_id, author_id, content, created_at, - is_system, has_attachments, - is_deleted, is_edited, edited_at, - is_pinned, is_forwarded, is_cross_chat, - reply_to_id, thread_root_id, - system_type, system_metadata - ) - SELECT - cc.id, - dc.author_id, - dc.text, - dc.created_at, - FALSE, FALSE, FALSE, - dc.is_edited, - CASE WHEN dc.is_edited THEN dc.updated_at ELSE NULL END, - FALSE, FALSE, FALSE, - parent_msg.id, - parent_msg.id, - '', - jsonb_build_object( - 'legacy_comment_id', dc.id, - 'legacy_model', 'documents.DocumentComment' + reply_comments = DocumentComment.objects.filter(parent__isnull=False).select_related('author', 'document', 'parent') + + for comment in reply_comments: + try: + chat = Chat.objects.get( + type='comments', + context_content_type=doc_ct, + context_object_id=comment.document_id ) - FROM documents_documentcomment dc - JOIN communications_chat cc ON ( - cc.type = 'comments' - AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='documents' AND model='document') - AND cc.context_object_id = dc.document_id + except Chat.DoesNotExist: + continue + + if Message.objects.filter( + chat=chat, + author_id=comment.author_id, + created_at=comment.created_at + ).exists(): + continue + + # Находим родительское сообщение + try: + parent_msg = Message.objects.get( + chat=chat, + system_metadata__legacy_comment_id=comment.parent_id + ) + except Message.DoesNotExist: + continue + + Message.objects.create( + chat=chat, + author_id=comment.author_id, + content=comment.text or '', + created_at=comment.created_at, + is_system=False, + has_attachments=False, + is_deleted=False, + is_edited=comment.is_edited if hasattr(comment, 'is_edited') else False, + edited_at=comment.updated_at if (hasattr(comment, 'is_edited') and comment.is_edited and hasattr(comment, 'updated_at')) else None, + is_pinned=False, + is_forwarded=False, + is_cross_chat=False, + reply_to=parent_msg, + thread_root=parent_msg, + system_type='', + system_metadata={ + 'legacy_comment_id': comment.id, + 'legacy_model': 'documents.DocumentComment' + } ) - JOIN communications_message parent_msg ON ( - parent_msg.chat_id = cc.id - AND (parent_msg.system_metadata->>'legacy_comment_id')::bigint = dc.parent_id - ) - WHERE dc.parent_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM communications_message m - WHERE m.chat_id = cc.id - AND m.author_id = dc.author_id - AND m.created_at = dc.created_at - ) - """) def migrate_comments_backward(apps, schema_editor): """Откат - удаляем чаты комментариев для документов""" - schema_editor.execute(""" - DELETE FROM communications_chat - WHERE type = 'comments' - AND context_content_type_id = ( - SELECT id FROM django_content_type - WHERE app_label='documents' AND model='document' - ) - """) + Chat = apps.get_model('communications', 'Chat') + ContentType = apps.get_model('contenttypes', 'ContentType') + + try: + doc_ct = ContentType.objects.get(app_label='documents', model='document') + Chat.objects.filter( + type='comments', + context_content_type=doc_ct + ).delete() + except ContentType.DoesNotExist: + pass class Migration(migrations.Migration): diff --git a/backend/feed/migrations/0006_migrate_comments_to_communications.py b/backend/feed/migrations/0006_migrate_comments_to_communications.py index 3e112fc8..75af93b8 100644 --- a/backend/feed/migrations/0006_migrate_comments_to_communications.py +++ b/backend/feed/migrations/0006_migrate_comments_to_communications.py @@ -1,141 +1,139 @@ # Generated by Django 5.2.4 on 2026-03-11 19:33 from django.db import migrations +from django.utils import timezone def migrate_comments_forward(apps, schema_editor): - """Миграция feed.Comment → Communications.Message (SQL с вложениями)""" + """Миграция feed.Comment → Communications.Message (ORM)""" + + Chat = apps.get_model('communications', 'Chat') + Message = apps.get_model('communications', 'Message') + MessageAttachment = apps.get_model('communications', 'MessageAttachment') + Comment = apps.get_model('feed', 'Comment') + Post = apps.get_model('feed', 'Post') + ContentType = apps.get_model('contenttypes', 'ContentType') + + try: + post_ct = ContentType.objects.get(app_label='feed', model='post') + except ContentType.DoesNotExist: + return # Если ContentType не найден, пропускаем миграцию # 1. Создаем чаты для постов с комментариями - schema_editor.execute(""" - INSERT INTO communications_chat ( - type, name, description, avatar, created_by_id, can_reply, flags, - context_content_type_id, context_object_id, - created_at, is_main, is_blocked, include_all_users, extra_data + post_ids_with_comments = Comment.objects.values_list('post_id', flat=True).distinct() + + for post_id in post_ids_with_comments: + # Проверяем, не создан ли уже чат + if Chat.objects.filter( + type='comments', + context_content_type=post_ct, + context_object_id=post_id + ).exists(): + continue + + try: + post = Post.objects.get(id=post_id) + chat_name = f"Комментарии: {post.title[:50]}" if hasattr(post, 'title') and post.title else "Комментарии" + except Post.DoesNotExist: + chat_name = "Комментарии" + + Chat.objects.create( + type='comments', + name=chat_name, + description='', + avatar=None, + created_by=None, + can_reply=True, + flags={'allow_replies': True, 'allow_reactions': True, 'allow_attachments': True, 'allow_editing': True}, + context_content_type=post_ct, + context_object_id=post_id, + created_at=timezone.now(), + is_main=False, + is_blocked=False, + include_all_users=False, + extra_data={} ) - SELECT DISTINCT - 'comments', - 'Комментарии: ' || substr(p.title, 1, 50), - '', - NULL, - NULL::bigint, - TRUE, - '{"allow_replies": true, "allow_reactions": true, "allow_attachments": true, "allow_editing": true}'::jsonb, - (SELECT id FROM django_content_type WHERE app_label='feed' AND model='post'), - c.post_id, - NOW(), - FALSE, - FALSE, - FALSE, - '{}'::jsonb - FROM feed_comment c - JOIN feed_post p ON p.id = c.post_id - WHERE NOT EXISTS ( - SELECT 1 FROM communications_chat cc - WHERE cc.type = 'comments' - AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='feed' AND model='post') - AND cc.context_object_id = c.post_id - ) - """) # 2. Переносим комментарии - schema_editor.execute(""" - INSERT INTO communications_message ( - chat_id, author_id, content, created_at, - is_system, has_attachments, - is_deleted, is_edited, is_pinned, is_forwarded, is_cross_chat, - system_type, system_metadata - ) - SELECT - cc.id, - c.author_id, - c.text, - c.created_at, - FALSE, - CASE WHEN (c.image != '' OR c.attachment != '') THEN TRUE ELSE FALSE END, - FALSE, FALSE, FALSE, FALSE, FALSE, - '', - jsonb_build_object( - 'legacy_comment_id', c.id, - 'legacy_model', 'feed.Comment' + for comment in Comment.objects.select_related('author', 'post').all(): + try: + chat = Chat.objects.get( + type='comments', + context_content_type=post_ct, + context_object_id=comment.post_id ) - FROM feed_comment c - JOIN communications_chat cc ON ( - cc.type = 'comments' - AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='feed' AND model='post') - AND cc.context_object_id = c.post_id + except Chat.DoesNotExist: + continue + + # Проверяем, не перенесен ли уже комментарий + if Message.objects.filter( + chat=chat, + author=comment.author, + created_at=comment.created_at + ).exists(): + continue + + # Проверяем наличие вложений + has_image = bool(comment.image and str(comment.image).strip()) + has_attachment = bool(comment.attachment and str(comment.attachment).strip()) + has_attachments = has_image or has_attachment + + message = Message.objects.create( + chat=chat, + author=comment.author, + content=comment.text or '', + created_at=comment.created_at, + is_system=False, + has_attachments=has_attachments, + is_deleted=False, + is_edited=False, + is_pinned=False, + is_forwarded=False, + is_cross_chat=False, + system_type='', + system_metadata={ + 'legacy_comment_id': comment.id, + 'legacy_model': 'feed.Comment' + } ) - WHERE NOT EXISTS ( - SELECT 1 FROM communications_message m - WHERE m.chat_id = cc.id - AND m.author_id = c.author_id - AND m.created_at = c.created_at - ) - """) - - # 3. Переносим вложения (images) - schema_editor.execute(""" - INSERT INTO communications_messageattachment ( - message_id, file, file_type, file_name, file_size, uploaded_at - ) - SELECT - m.id, - c.image, - 'image', - substring(c.image from '[^/]*$'), - 0, - c.created_at - FROM feed_comment c - JOIN communications_chat cc ON ( - cc.type = 'comments' - AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='feed' AND model='post') - AND cc.context_object_id = c.post_id - ) - JOIN communications_message m ON ( - m.chat_id = cc.id - AND m.author_id = c.author_id - AND m.created_at = c.created_at - ) - WHERE c.image != '' AND c.image IS NOT NULL - """) - - # 4. Переносим вложения (attachments) - schema_editor.execute(""" - INSERT INTO communications_messageattachment ( - message_id, file, file_type, file_name, file_size, uploaded_at - ) - SELECT - m.id, - c.attachment, - 'file', - substring(c.attachment from '[^/]*$'), - 0, - c.created_at - FROM feed_comment c - JOIN communications_chat cc ON ( - cc.type = 'comments' - AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='feed' AND model='post') - AND cc.context_object_id = c.post_id - ) - JOIN communications_message m ON ( - m.chat_id = cc.id - AND m.author_id = c.author_id - AND m.created_at = c.created_at - ) - WHERE c.attachment != '' AND c.attachment IS NOT NULL - """) + + # 3. Переносим вложения + if has_image: + file_name = str(comment.image).split('/')[-1] if '/' in str(comment.image) else str(comment.image) + MessageAttachment.objects.create( + message=message, + file=comment.image, + file_type='image', + file_name=file_name, + file_size=0, + uploaded_at=comment.created_at + ) + + if has_attachment: + file_name = str(comment.attachment).split('/')[-1] if '/' in str(comment.attachment) else str(comment.attachment) + MessageAttachment.objects.create( + message=message, + file=comment.attachment, + file_type='file', + file_name=file_name, + file_size=0, + uploaded_at=comment.created_at + ) def migrate_comments_backward(apps, schema_editor): """Откат - удаляем чаты комментариев для постов""" - schema_editor.execute(""" - DELETE FROM communications_chat - WHERE type = 'comments' - AND context_content_type_id = ( - SELECT id FROM django_content_type - WHERE app_label='feed' AND model='post' - ) - """) + Chat = apps.get_model('communications', 'Chat') + ContentType = apps.get_model('contenttypes', 'ContentType') + + try: + post_ct = ContentType.objects.get(app_label='feed', model='post') + Chat.objects.filter( + type='comments', + context_content_type=post_ct + ).delete() + except ContentType.DoesNotExist: + pass class Migration(migrations.Migration): diff --git a/backend/feed/notification_signals.py.old b/backend/feed/notification_signals.py.old deleted file mode 100644 index e91a4d86..00000000 --- a/backend/feed/notification_signals.py.old +++ /dev/null @@ -1,185 +0,0 @@ -""" -Signals для автоматической генерации уведомлений в модуле Feed. - -Обрабатывает события: -- Новая публикация (для подписчиков/отдела/компании) -- Комментарий к публикации -- Реакция (лайк) на публикацию - через отдельный механизм -""" - -from django.db.models.signals import post_save -from django.dispatch import receiver -from django.contrib.auth import get_user_model - -from .models import Post, Comment -from .constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE -from notifications.signals import notify - -Employee = get_user_model() - - -@receiver(post_save, sender=Post) -def create_post_notification(sender, instance, created, **kwargs): - """ - Создает уведомления при создании новой публикации асинхронно. - - Уведомления отправляются в зависимости от типа: - - TYPE_COMPANY - всем сотрудникам - - TYPE_DEPARTMENT - сотрудникам отдела - - TYPE_EMPLOYEE - подписчикам автора (если реализовано) - """ - if not created: - return - - # Отправляем уведомления напрямую - channels.py автоматически отправит через Celery - notify_new_post(instance) - - -@receiver(post_save, sender=Comment) -def create_comment_notification(sender, instance, created, **kwargs): - """ - Создает уведомления при добавлении комментария к публикации. - """ - if not created: - return - - comment = instance - post = comment.post - comment_author = comment.author - - # Уведомляем автора публикации (если комментарий не от него) - if post.author.id != comment_author.id: - notify.send( - sender=comment_author, - recipient=post.author, - verb='feed_post_comment', - action_object=post, - description=( - f'{comment_author.get_full_name() or comment_author.username}' - f' прокомментировал: {truncate_text(comment.text, 80)}' - ), - action_url='/', - data={ - 'title': 'Новый комментарий к вашей публикации', - 'post_id': post.id, - 'post_title': post.title, - 'comment_id': comment.id, - 'author_id': comment_author.id, - } - ) - - -# ===== Вспомогательные функции ===== - -def notify_new_post(post): - """ - Отправляет уведомления о новой публикации. - """ - recipients = get_post_recipients(post) - - # Исключаем автора - recipients = [r for r in recipients if r.id != post.author.id] - - # Определяем контекст публикации - if post.type == TYPE_COMPANY: - context = 'компании' - elif post.type == TYPE_DEPARTMENT and post.department: - context = f'отдела {post.department.name}' - else: - context = ( - f'от {post.author.get_full_name() or post.author.username}' - ) - - for recipient in recipients: - notify.send( - sender=post.author, - recipient=recipient, - verb='feed_new_post', - action_object=post, - description=f'{post.title}', - action_url='/', - data={ - 'title': f'Новая публикация {context}', - 'post_id': post.id, - 'post_type': post.type, - 'post_title': post.title, - 'author_id': post.author.id, - 'department_id': ( - post.department.id if post.department else None - ), - } - ) - - -def get_post_recipients(post): - """ - Возвращает список получателей уведомлений для публикации. - - - TYPE_COMPANY - все активные сотрудники - - TYPE_DEPARTMENT - сотрудники отдела - - TYPE_EMPLOYEE - подписчики (если реализовано) - """ - if post.type == TYPE_COMPANY: - # Новость компании - всем - return list(Employee.objects.filter(is_active=True)) - - elif post.type == TYPE_DEPARTMENT and post.department: - # Новость отдела - сотрудникам отдела - return list(post.department.employees.filter(is_active=True)) - - elif post.type == TYPE_EMPLOYEE: - # Личная публикация - подписчикам - # TODO: реализовать систему подписок - # Пока отправляем всем активным или никому - return [] - - return [] - - -def truncate_text(text, max_length=100): - """ - Обрезает текст до указанной длины. - """ - if not text: - return '' - - text = text.strip() - if len(text) <= max_length: - return text - - return text[:max_length - 3] + '...' - - -# ===== Функция для уведомлений о лайках ===== -# Используется во views при добавлении лайка - -def notify_post_reaction(post, user): - """ - Создает уведомление о реакции (лайке) на публикацию. - - Вызывается вручную из view, так как лайки не имеют отдельной модели. - - Args: - post: Публикация, которую лайкнули - user: Пользователь, который поставил лайк - """ - # Уведомляем автора (если лайк не от него) - if post.author.id != user.id: - notify.send( - sender=user, - recipient=post.author, - verb='feed_post_reaction', - action_object=post, - description=( - f'{user.get_full_name() or user.username} ' - f'понравилась публикация "{post.title}"' - ), - action_url='/', - data={ - 'title': 'Ваша публикация понравилась', - 'post_id': post.id, - 'post_title': post.title, - 'user_id': user.id, - 'reaction_type': 'like', - } - ) diff --git a/backend/notifications/senders/push.py b/backend/notifications/senders/push.py index 891d83a2..7679ceaf 100644 --- a/backend/notifications/senders/push.py +++ b/backend/notifications/senders/push.py @@ -1,6 +1,8 @@ """ Web Push отправитель для browser push notifications """ +import json + from push_notifications.models import WebPushDevice from .base import BaseNotificationSender @@ -47,14 +49,20 @@ class PushNotificationSender(BaseNotificationSender): # Формируем данные для push actor_str = str(notification.actor) if notification.actor else 'Система' title = f'{actor_str} {notification.verb}' + + # Ограничиваем длину body (Web Push имеет лимит ~4KB на весь payload) + # Оставляем 300 символов для body, чтобы гарантировать что весь message поместится body = notification.description or '' + if len(body) > 300: + body = body[:297] + '...' # Получаем иконки из конфигурации (None = browser default) default_icon = get('PUSH_DEFAULT_ICON') default_badge = get('PUSH_DEFAULT_BADGE') # django-push-notifications использует другой формат - message = { + # Формируем данные для Web Push API + message_data = { 'head': title, 'body': body, 'url': notification.action_url or '/', @@ -68,9 +76,12 @@ class PushNotificationSender(BaseNotificationSender): # Добавляем иконки только если они настроены if default_icon: - message['icon'] = default_icon + message_data['icon'] = default_icon if default_badge: - message['badge'] = default_badge + message_data['badge'] = default_badge + + # Преобразуем в JSON-строку для send_message() + message = json.dumps(message_data) sent_count = 0 failed_count = 0 @@ -85,8 +96,11 @@ class PushNotificationSender(BaseNotificationSender): ) except Exception as e: failed_count += 1 + # Вычисляем размер payload для диагностики + message_size = len(message.encode('utf-8')) self.logger.error( - f"Ошибка отправки push на device {device.id}: {e}" + f"Ошибка отправки push на device {device.id}: {e} " + f"(payload size: {message_size} bytes)" ) # Деактивируем устройство если ошибка if "expired" in str(e).lower() or "unregistered" in str(e).lower(): diff --git a/backend/requests_app/migrations/0010_migrate_comments_to_communications.py b/backend/requests_app/migrations/0010_migrate_comments_to_communications.py index e98d1cf2..a48414a6 100644 --- a/backend/requests_app/migrations/0010_migrate_comments_to_communications.py +++ b/backend/requests_app/migrations/0010_migrate_comments_to_communications.py @@ -1,87 +1,102 @@ # Generated by Django 5.2.4 on 2026-03-11 19:29 from django.db import migrations +from django.utils import timezone def migrate_comments_forward(apps, schema_editor): - """Миграция RequestComment → Communications.Message (SQL)""" + """Миграция RequestComment → Communications.Message (ORM)""" + + Chat = apps.get_model('communications', 'Chat') + Message = apps.get_model('communications', 'Message') + RequestComment = apps.get_model('requests_app', 'RequestComment') + ContentType = apps.get_model('contenttypes', 'ContentType') + + try: + request_ct = ContentType.objects.get(app_label='requests_app', model='request') + except ContentType.DoesNotExist: + return # 1. Создаем чаты для заявок с комментариями - schema_editor.execute(""" - INSERT INTO communications_chat ( - type, name, description, avatar, created_by_id, can_reply, flags, - context_content_type_id, context_object_id, - created_at, is_main, is_blocked, include_all_users, extra_data + request_ids_with_comments = RequestComment.objects.values_list('request_id', flat=True).distinct() + + for request_id in request_ids_with_comments: + if Chat.objects.filter( + type='comments', + context_content_type=request_ct, + context_object_id=request_id + ).exists(): + continue + + Chat.objects.create( + type='comments', + name=f"Комментарии к заявке #{request_id}", + description='', + avatar=None, + created_by=None, + can_reply=True, + flags={'allow_replies': True, 'allow_reactions': True, 'allow_attachments': True, 'allow_editing': True}, + context_content_type=request_ct, + context_object_id=request_id, + created_at=timezone.now(), + is_main=False, + is_blocked=False, + include_all_users=False, + extra_data={} ) - SELECT DISTINCT - 'comments', - 'Комментарии к заявке #' || rc.request_id, - '', - NULL, - NULL::bigint, - TRUE, - '{"allow_replies": true, "allow_reactions": true, "allow_attachments": true, "allow_editing": true}'::jsonb, - (SELECT id FROM django_content_type WHERE app_label='requests_app' AND model='request'), - rc.request_id, - NOW(), - FALSE, - FALSE, - FALSE, - '{}'::jsonb - FROM requests_app_requestcomment rc - WHERE NOT EXISTS ( - SELECT 1 FROM communications_chat cc - WHERE cc.type = 'comments' - AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='requests_app' AND model='request') - AND cc.context_object_id = rc.request_id - ) - """) # 2. Переносим комментарии - schema_editor.execute(""" - INSERT INTO communications_message ( - chat_id, author_id, content, created_at, - is_system, has_attachments, - is_deleted, is_edited, is_pinned, is_forwarded, is_cross_chat, - system_type, system_metadata - ) - SELECT - cc.id, - rc.author_id, - rc.text, - rc.created_at, - FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, - '', - jsonb_build_object( - 'legacy_comment_id', rc.id, - 'legacy_model', 'requests_app.RequestComment' + for comment in RequestComment.objects.select_related('author').all(): + try: + chat = Chat.objects.get( + type='comments', + context_content_type=request_ct, + context_object_id=comment.request_id ) - FROM requests_app_requestcomment rc - JOIN communications_chat cc ON ( - cc.type = 'comments' - AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='requests_app' AND model='request') - AND cc.context_object_id = rc.request_id + except Chat.DoesNotExist: + continue + + if Message.objects.filter( + chat=chat, + author_id=comment.author_id, + created_at=comment.created_at, + content=comment.text + ).exists(): + continue + + Message.objects.create( + chat=chat, + author_id=comment.author_id, + content=comment.text or '', + created_at=comment.created_at, + is_system=False, + has_attachments=False, + is_deleted=False, + is_edited=False, + is_pinned=False, + is_forwarded=False, + is_cross_chat=False, + system_type='', + system_metadata={ + 'legacy_comment_id': comment.id, + 'legacy_model': 'requests_app.RequestComment' + } ) - WHERE NOT EXISTS ( - SELECT 1 FROM communications_message m - WHERE m.chat_id = cc.id - AND m.author_id = rc.author_id - AND m.created_at = rc.created_at - AND m.content = rc.text - ) - """) def migrate_comments_backward(apps, schema_editor): """Откат - удаляем чаты комментариев для заявок""" - schema_editor.execute(""" - DELETE FROM communications_chat - WHERE type = 'comments' - AND context_content_type_id = ( - SELECT id FROM django_content_type - WHERE app_label='requests_app' AND model='request' - ) - """) + Chat = apps.get_model('communications', 'Chat') + ContentType = apps.get_model('contenttypes', 'ContentType') + + try: + request_ct = ContentType.objects.get(app_label='requests_app', model='request') + Chat.objects.filter( + type='comments', + context_content_type=request_ct + ).delete() + except ContentType.DoesNotExist: + pass class Migration(migrations.Migration): diff --git a/backend/tests/api/v1/communications/test_chat_roles.py b/backend/tests/api/v1/communications/test_chat_roles.py new file mode 100644 index 00000000..80e168b8 --- /dev/null +++ b/backend/tests/api/v1/communications/test_chat_roles.py @@ -0,0 +1,923 @@ +""" +Тесты для системы ролей в чатах (ChatMembership) + +Тестирует: +- Создание участников с разными ролями +- Изменение ролей участников +- Права доступа для каждой роли (admin, moderator, member, guest) +- Ограничения и запреты (владелец, невалидные роли и т.д.) +- Отправку сообщений в зависимости от роли +- Управление участниками в зависимости от роли +- Закрепление сообщений в зависимости от роли +""" + +import pytest +from communications.models import Chat, ChatMembership, Message +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APIClient + +User = get_user_model() +pytestmark = pytest.mark.django_db + + +# ==================== Fixtures ==================== + + +@pytest.fixture +def owner(db): + """Владелец чата""" + return User.objects.create_user( + email="owner@test.com", + password="testpass123", + first_name="Chat", + last_name="Owner", + phone_number="+79991111111", + send_activation_email=False, + ) + + +@pytest.fixture +def admin_user(db): + """Пользователь с ролью admin""" + return User.objects.create_user( + email="admin@test.com", + password="testpass123", + first_name="Admin", + last_name="User", + phone_number="+79992222222", + send_activation_email=False, + ) + + +@pytest.fixture +def moderator_user(db): + """Пользователь с ролью moderator""" + return User.objects.create_user( + email="moderator@test.com", + password="testpass123", + first_name="Moderator", + last_name="User", + phone_number="+79993333333", + send_activation_email=False, + ) + + +@pytest.fixture +def member_user(db): + """Пользователь с ролью member""" + return User.objects.create_user( + email="member@test.com", + password="testpass123", + first_name="Member", + last_name="User", + phone_number="+79994444444", + send_activation_email=False, + ) + + +@pytest.fixture +def guest_user(db): + """Пользователь с ролью guest""" + return User.objects.create_user( + email="guest@test.com", + password="testpass123", + first_name="Guest", + last_name="User", + phone_number="+79995555555", + send_activation_email=False, + ) + + +@pytest.fixture +def regular_user(db): + """Обычный пользователь (не участник чата)""" + return User.objects.create_user( + email="regular@test.com", + password="testpass123", + first_name="Regular", + last_name="User", + phone_number="+79996666666", + send_activation_email=False, + ) + + +@pytest.fixture +def group_chat_with_roles(owner, admin_user, moderator_user, member_user, guest_user): + """Групповой чат с участниками разных ролей""" + chat = Chat.objects.create( + type="group", + name="Test Group Chat", + description="Chat for testing roles", + created_by=owner + ) + + # Добавляем владельца + chat.participants.add(owner) + + # Добавляем участников с разными ролями + chat.participants.add(admin_user, moderator_user, member_user, guest_user) + + # Создаем memberships с соответствующими ролями + ChatMembership.objects.create( + chat=chat, + user=admin_user, + role='admin', + invited_by=owner + ) + + ChatMembership.objects.create( + chat=chat, + user=moderator_user, + role='moderator', + invited_by=owner + ) + + ChatMembership.objects.create( + chat=chat, + user=member_user, + role='member', + invited_by=owner + ) + + ChatMembership.objects.create( + chat=chat, + user=guest_user, + role='guest', + invited_by=owner + ) + + return chat + + +@pytest.fixture +def owner_client(owner): + """Аутентифицированный клиент для владельца""" + client = APIClient() + client.force_authenticate(user=owner) + return client + + +@pytest.fixture +def admin_client(admin_user): + """Аутентифицированный клиент для админа""" + client = APIClient() + client.force_authenticate(user=admin_user) + return client + + +@pytest.fixture +def moderator_client(moderator_user): + """Аутентифицированный клиент для модератора""" + client = APIClient() + client.force_authenticate(user=moderator_user) + return client + + +@pytest.fixture +def member_client(member_user): + """Аутентифицированный клиент для обычного участника""" + client = APIClient() + client.force_authenticate(user=member_user) + return client + + +@pytest.fixture +def guest_client(guest_user): + """Аутентифицированный клиент для гостя""" + client = APIClient() + client.force_authenticate(user=guest_user) + return client + + +@pytest.fixture +def regular_client(regular_user): + """Аутентифицированный клиент для обычного пользователя""" + client = APIClient() + client.force_authenticate(user=regular_user) + return client + + +# ==================== Тесты получения информации о ролях ==================== + + +class TestChatMembershipRetrieval: + """Тесты получения информации о членстве и ролях""" + + def test_get_chat_includes_memberships(self, owner_client, group_chat_with_roles): + """Проверка что API возвращает информацию о memberships""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/" + response = owner_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + # Проверяем наличие поля memberships + assert 'memberships' in data + assert isinstance(data['memberships'], list) + + # Должно быть 4 membership (admin, moderator, member, guest) + assert len(data['memberships']) == 4 + + # Проверяем структуру данных membership + for membership in data['memberships']: + assert 'id' in membership + assert 'user' in membership + assert 'user_name' in membership + assert 'role' in membership + assert 'joined_at' in membership + assert 'can_send_messages' in membership + assert 'can_add_members' in membership + assert 'can_remove_members' in membership + assert 'can_pin_messages' in membership + assert 'can_manage_members' in membership + + def test_memberships_have_correct_roles(self, owner_client, group_chat_with_roles, + admin_user, moderator_user, member_user, guest_user): + """Проверка что роли участников корректны""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/" + response = owner_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + # Создаем словарь user_id -> role для проверки + memberships_by_user = {m['user']: m for m in data['memberships']} + + assert memberships_by_user[admin_user.id]['role'] == 'admin' + assert memberships_by_user[moderator_user.id]['role'] == 'moderator' + assert memberships_by_user[member_user.id]['role'] == 'member' + assert memberships_by_user[guest_user.id]['role'] == 'guest' + + def test_admin_permissions_in_membership(self, owner_client, group_chat_with_roles, admin_user): + """Проверка что у админа правильные права""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/" + response = owner_client.get(url) + + data = response.json() + admin_membership = next(m for m in data['memberships'] if m['user'] == admin_user.id) + + assert admin_membership['can_send_messages'] is True + assert admin_membership['can_add_members'] is True + assert admin_membership['can_remove_members'] is True + assert admin_membership['can_pin_messages'] is True + assert admin_membership['can_manage_members'] is True + + def test_moderator_permissions_in_membership(self, owner_client, group_chat_with_roles, moderator_user): + """Проверка что у модератора правильные права""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/" + response = owner_client.get(url) + + data = response.json() + mod_membership = next(m for m in data['memberships'] if m['user'] == moderator_user.id) + + assert mod_membership['can_send_messages'] is True + assert mod_membership['can_add_members'] is False + assert mod_membership['can_remove_members'] is False + assert mod_membership['can_pin_messages'] is True + assert mod_membership['can_manage_members'] is False + + def test_member_permissions_in_membership(self, owner_client, group_chat_with_roles, member_user): + """Проверка что у обычного участника правильные права""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/" + response = owner_client.get(url) + + data = response.json() + member_membership = next(m for m in data['memberships'] if m['user'] == member_user.id) + + assert member_membership['can_send_messages'] is True + assert member_membership['can_add_members'] is False + assert member_membership['can_remove_members'] is False + assert member_membership['can_pin_messages'] is False + assert member_membership['can_manage_members'] is False + + def test_guest_permissions_in_membership(self, owner_client, group_chat_with_roles, guest_user): + """Проверка что у гостя правильные права (только чтение)""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/" + response = owner_client.get(url) + + data = response.json() + guest_membership = next(m for m in data['memberships'] if m['user'] == guest_user.id) + + assert guest_membership['can_send_messages'] is False + assert guest_membership['can_add_members'] is False + assert guest_membership['can_remove_members'] is False + assert guest_membership['can_pin_messages'] is False + assert guest_membership['can_manage_members'] is False + + +# ==================== Тесты изменения ролей ==================== + + +class TestChangeRole: + """Тесты изменения ролей участников""" + + def test_owner_can_change_role_to_admin(self, owner_client, group_chat_with_roles, member_user): + """Владелец может повысить участника до админа""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + response = owner_client.post(url, { + 'user_id': member_user.id, + 'role': 'admin' + }) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['ok'] is True + assert data['membership']['role'] == 'admin' + + # Проверяем что роль изменилась в БД + membership = ChatMembership.objects.get(chat=group_chat_with_roles, user=member_user) + assert membership.role == 'admin' + assert membership.can_send_messages is True + assert membership.can_add_members is True + assert membership.can_remove_members is True + assert membership.can_pin_messages is True + + def test_owner_can_change_role_to_moderator(self, owner_client, group_chat_with_roles, member_user): + """Владелец может назначить участника модератором""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + response = owner_client.post(url, { + 'user_id': member_user.id, + 'role': 'moderator' + }) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['membership']['role'] == 'moderator' + + # Проверяем права + membership = ChatMembership.objects.get(chat=group_chat_with_roles, user=member_user) + assert membership.role == 'moderator' + assert membership.can_send_messages is True + assert membership.can_pin_messages is True + assert membership.can_add_members is False + assert membership.can_remove_members is False + + def test_owner_can_demote_admin_to_member(self, owner_client, group_chat_with_roles, admin_user): + """Владелец может понизить админа до обычного участника""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + response = owner_client.post(url, { + 'user_id': admin_user.id, + 'role': 'member' + }) + + assert response.status_code == status.HTTP_200_OK + + # Проверяем что права понизились + membership = ChatMembership.objects.get(chat=group_chat_with_roles, user=admin_user) + assert membership.role == 'member' + assert membership.can_send_messages is True + assert membership.can_add_members is False + assert membership.can_remove_members is False + assert membership.can_pin_messages is False + + def test_owner_can_change_role_to_guest(self, owner_client, group_chat_with_roles, member_user): + """Владелец может ограничить права участника до гостя""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + response = owner_client.post(url, { + 'user_id': member_user.id, + 'role': 'guest' + }) + + assert response.status_code == status.HTTP_200_OK + + # Проверяем что участник теперь гость (только чтение) + membership = ChatMembership.objects.get(chat=group_chat_with_roles, user=member_user) + assert membership.role == 'guest' + assert membership.can_send_messages is False + assert membership.can_add_members is False + assert membership.can_remove_members is False + assert membership.can_pin_messages is False + + def test_admin_cannot_change_roles(self, admin_client, group_chat_with_roles, member_user): + """Админ НЕ может менять роли (только владелец)""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + response = admin_client.post(url, { + 'user_id': member_user.id, + 'role': 'admin' + }) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Проверяем что роль не изменилась + membership = ChatMembership.objects.get(chat=group_chat_with_roles, user=member_user) + assert membership.role == 'member' + + def test_moderator_cannot_change_roles(self, moderator_client, group_chat_with_roles, member_user): + """Модератор НЕ может менять роли""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + response = moderator_client.post(url, { + 'user_id': member_user.id, + 'role': 'admin' + }) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_member_cannot_change_roles(self, member_client, group_chat_with_roles, guest_user): + """Обычный участник НЕ может менять роли""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + response = member_client.post(url, { + 'user_id': guest_user.id, + 'role': 'member' + }) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_cannot_change_owner_role(self, owner_client, group_chat_with_roles, owner): + """Нельзя изменить роль владельца чата""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + response = owner_client.post(url, { + 'user_id': owner.id, + 'role': 'member' + }) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'owner' in response.json()['error'].lower() + + def test_invalid_role_rejected(self, owner_client, group_chat_with_roles, member_user): + """Невалидная роль отклоняется""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + response = owner_client.post(url, { + 'user_id': member_user.id, + 'role': 'superadmin' # Несуществующая роль + }) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'invalid role' in response.json()['error'].lower() + + def test_missing_parameters_rejected(self, owner_client, group_chat_with_roles): + """Запрос без обязательных параметров отклоняется""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + + # Без user_id + response = owner_client.post(url, {'role': 'admin'}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Без role + response = owner_client.post(url, {'user_id': 123}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# ==================== Тесты прав на отправку сообщений ==================== + + +class TestSendMessagePermissions: + """Тесты прав на отправку сообщений в зависимости от роли""" + + def test_owner_can_send_messages(self, owner_client, group_chat_with_roles): + """Владелец может отправлять сообщения""" + url = "/api/v1/communications/messages/" + response = owner_client.post(url, { + 'chat': group_chat_with_roles.id, + 'content': 'Message from owner' + }) + + assert response.status_code == status.HTTP_201_CREATED + assert Message.objects.filter( + chat=group_chat_with_roles, + content='Message from owner' + ).exists() + + def test_admin_can_send_messages(self, admin_client, group_chat_with_roles): + """Админ может отправлять сообщения""" + url = "/api/v1/communications/messages/" + response = admin_client.post(url, { + 'chat': group_chat_with_roles.id, + 'content': 'Message from admin' + }) + + assert response.status_code == status.HTTP_201_CREATED + + def test_moderator_can_send_messages(self, moderator_client, group_chat_with_roles): + """Модератор может отправлять сообщения""" + url = "/api/v1/communications/messages/" + response = moderator_client.post(url, { + 'chat': group_chat_with_roles.id, + 'content': 'Message from moderator' + }) + + assert response.status_code == status.HTTP_201_CREATED + + def test_member_can_send_messages(self, member_client, group_chat_with_roles): + """Обычный участник может отправлять сообщения""" + url = "/api/v1/communications/messages/" + response = member_client.post(url, { + 'chat': group_chat_with_roles.id, + 'content': 'Message from member' + }) + + assert response.status_code == status.HTTP_201_CREATED + + def test_guest_cannot_send_messages(self, guest_client, group_chat_with_roles): + """Гость НЕ может отправлять сообщения""" + url = "/api/v1/communications/messages/" + response = guest_client.post(url, { + 'chat': group_chat_with_roles.id, + 'content': 'Message from guest' + }) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert not Message.objects.filter(content='Message from guest').exists() + + def test_non_member_cannot_send_messages(self, regular_client, group_chat_with_roles): + """Не-участник чата НЕ может отправлять сообщения""" + url = "/api/v1/communications/messages/" + response = regular_client.post(url, { + 'chat': group_chat_with_roles.id, + 'content': 'Message from outsider' + }) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ==================== Тесты прав на чтение сообщений ==================== + + +class TestReadMessagePermissions: + """Тесты прав на чтение сообщений""" + + @pytest.fixture + def chat_with_messages(self, group_chat_with_roles, owner): + """Чат с несколькими сообщениями""" + Message.objects.create( + chat=group_chat_with_roles, + author=owner, + content="Test message 1" + ) + Message.objects.create( + chat=group_chat_with_roles, + author=owner, + content="Test message 2" + ) + return group_chat_with_roles + + def test_owner_can_read_messages(self, owner_client, chat_with_messages): + """Владелец может читать сообщения""" + url = f"/api/v1/communications/chats/{chat_with_messages.id}/messages/" + response = owner_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data['results']) == 2 + + def test_admin_can_read_messages(self, admin_client, chat_with_messages): + """Админ может читать сообщения""" + url = f"/api/v1/communications/chats/{chat_with_messages.id}/messages/" + response = admin_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['results']) == 2 + + def test_moderator_can_read_messages(self, moderator_client, chat_with_messages): + """Модератор может читать сообщения""" + url = f"/api/v1/communications/chats/{chat_with_messages.id}/messages/" + response = moderator_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['results']) == 2 + + def test_member_can_read_messages(self, member_client, chat_with_messages): + """Обычный участник может читать сообщения""" + url = f"/api/v1/communications/chats/{chat_with_messages.id}/messages/" + response = member_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['results']) == 2 + + def test_guest_can_read_messages(self, guest_client, chat_with_messages): + """Гость может читать сообщения (read-only доступ)""" + url = f"/api/v1/communications/chats/{chat_with_messages.id}/messages/" + response = guest_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()['results']) == 2 + + def test_non_member_cannot_read_messages(self, regular_client, chat_with_messages): + """Не-участник НЕ может читать сообщения""" + url = f"/api/v1/communications/chats/{chat_with_messages.id}/messages/" + response = regular_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ==================== Тесты прав на управление участниками ==================== + + +class TestManageMembersPermissions: + """Тесты прав на добавление и удаление участников""" + + def test_owner_can_add_members(self, owner_client, group_chat_with_roles, regular_user): + """Владелец может добавлять участников""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/add-member/" + response = owner_client.post(url, {'user_id': regular_user.id}) + + assert response.status_code == status.HTTP_200_OK + assert group_chat_with_roles.participants.filter(id=regular_user.id).exists() + + def test_admin_can_add_members(self, admin_client, group_chat_with_roles, regular_user): + """Админ может добавлять участников""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/add-member/" + response = admin_client.post(url, {'user_id': regular_user.id}) + + assert response.status_code == status.HTTP_200_OK + assert group_chat_with_roles.participants.filter(id=regular_user.id).exists() + + def test_moderator_cannot_add_members(self, moderator_client, group_chat_with_roles, regular_user): + """Модератор НЕ может добавлять участников""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/add-member/" + response = moderator_client.post(url, {'user_id': regular_user.id}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert not group_chat_with_roles.participants.filter(id=regular_user.id).exists() + + def test_member_cannot_add_members(self, member_client, group_chat_with_roles, regular_user): + """Обычный участник НЕ может добавлять участников""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/add-member/" + response = member_client.post(url, {'user_id': regular_user.id}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_guest_cannot_add_members(self, guest_client, group_chat_with_roles, regular_user): + """Гость НЕ может добавлять участников""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/add-member/" + response = guest_client.post(url, {'user_id': regular_user.id}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_owner_can_remove_members(self, owner_client, group_chat_with_roles, member_user): + """Владелец может удалять участников""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/remove-member/" + response = owner_client.post(url, {'user_id': member_user.id}) + + assert response.status_code == status.HTTP_200_OK + assert not group_chat_with_roles.participants.filter(id=member_user.id).exists() + + def test_admin_can_remove_members(self, admin_client, group_chat_with_roles, member_user): + """Админ может удалять участников""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/remove-member/" + response = admin_client.post(url, {'user_id': member_user.id}) + + assert response.status_code == status.HTTP_200_OK + + def test_moderator_cannot_remove_members(self, moderator_client, group_chat_with_roles, member_user): + """Модератор НЕ может удалять участников""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/remove-member/" + response = moderator_client.post(url, {'user_id': member_user.id}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert group_chat_with_roles.participants.filter(id=member_user.id).exists() + + def test_cannot_remove_owner(self, admin_client, group_chat_with_roles, owner): + """Нельзя удалить владельца чата""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/remove-member/" + response = admin_client.post(url, {'user_id': owner.id}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'owner' in response.json()['error'].lower() + + +# ==================== Тесты прав на закрепление сообщений ==================== + + +class TestPinMessagePermissions: + """Тесты прав на закрепление сообщений""" + + @pytest.fixture + def message_to_pin(self, group_chat_with_roles, owner): + """Сообщение для закрепления""" + return Message.objects.create( + chat=group_chat_with_roles, + author=owner, + content="Message to pin" + ) + + def test_owner_can_pin_messages(self, owner_client, group_chat_with_roles, message_to_pin): + """Владелец может закреплять сообщения""" + url = f"/api/v1/communications/messages/{message_to_pin.id}/pin/" + response = owner_client.post(url) + + assert response.status_code == status.HTTP_200_OK + message_to_pin.refresh_from_db() + assert message_to_pin.is_pinned is True + + def test_admin_can_pin_messages(self, admin_client, group_chat_with_roles, message_to_pin): + """Админ может закреплять сообщения""" + url = f"/api/v1/communications/messages/{message_to_pin.id}/pin/" + response = admin_client.post(url) + + assert response.status_code == status.HTTP_200_OK + message_to_pin.refresh_from_db() + assert message_to_pin.is_pinned is True + + def test_moderator_can_pin_messages(self, moderator_client, group_chat_with_roles, message_to_pin): + """Модератор может закреплять сообщения""" + url = f"/api/v1/communications/messages/{message_to_pin.id}/pin/" + response = moderator_client.post(url) + + assert response.status_code == status.HTTP_200_OK + message_to_pin.refresh_from_db() + assert message_to_pin.is_pinned is True + + def test_member_cannot_pin_messages(self, member_client, group_chat_with_roles, message_to_pin): + """Обычный участник НЕ может закреплять сообщения""" + url = f"/api/v1/communications/messages/{message_to_pin.id}/pin/" + response = member_client.post(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + message_to_pin.refresh_from_db() + assert message_to_pin.is_pinned is False + + def test_guest_cannot_pin_messages(self, guest_client, group_chat_with_roles, message_to_pin): + """Гость НЕ может закреплять сообщения""" + url = f"/api/v1/communications/messages/{message_to_pin.id}/pin/" + response = guest_client.post(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ==================== Тесты прав на реакции ==================== + + +class TestReactionPermissions: + """Тесты прав на отправку реакций""" + + @pytest.fixture + def message_for_reaction(self, group_chat_with_roles, owner): + """Сообщение для реакций""" + return Message.objects.create( + chat=group_chat_with_roles, + author=owner, + content="Message for reactions" + ) + + def test_owner_can_react(self, owner_client, message_for_reaction): + """Владелец может ставить реакции""" + url = f"/api/v1/communications/messages/{message_for_reaction.id}/react/" + response = owner_client.post(url, {'emoji': '👍'}) + + assert response.status_code == status.HTTP_200_OK + + def test_admin_can_react(self, admin_client, message_for_reaction): + """Админ может ставить реакции""" + url = f"/api/v1/communications/messages/{message_for_reaction.id}/react/" + response = admin_client.post(url, {'emoji': '❤️'}) + + assert response.status_code == status.HTTP_200_OK + + def test_moderator_can_react(self, moderator_client, message_for_reaction): + """Модератор может ставить реакции""" + url = f"/api/v1/communications/messages/{message_for_reaction.id}/react/" + response = moderator_client.post(url, {'emoji': '😊'}) + + assert response.status_code == status.HTTP_200_OK + + def test_member_can_react(self, member_client, message_for_reaction): + """Обычный участник может ставить реакции""" + url = f"/api/v1/communications/messages/{message_for_reaction.id}/react/" + response = member_client.post(url, {'emoji': '🔥'}) + + assert response.status_code == status.HTTP_200_OK + + def test_guest_can_react(self, guest_client, message_for_reaction): + """Гость МОЖЕТ ставить реакции (даже если не может писать сообщения)""" + url = f"/api/v1/communications/messages/{message_for_reaction.id}/react/" + response = guest_client.post(url, {'emoji': '👀'}) + + assert response.status_code == status.HTTP_200_OK + + # Проверяем, что реакция создана + from communications.models import MessageReaction + assert MessageReaction.objects.filter( + message=message_for_reaction, + emoji='👀' + ).exists() + + def test_non_member_cannot_react(self, regular_client, message_for_reaction): + """Не-участник чата НЕ может ставить реакции""" + url = f"/api/v1/communications/messages/{message_for_reaction.id}/react/" + response = regular_client.post(url, {'emoji': '😱'}) + + # 404 потому что не-участник вообще не видит сообщение + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# ==================== Тесты создания чата с ролями ==================== + + +class TestChatCreationWithRoles: + """Тесты создания чата и автоматического назначения ролей""" + + def test_create_group_chat_creates_owner_as_participant(self, owner_client, owner): + """При создании группового чата владелец автоматически добавляется в участники""" + url = "/api/v1/communications/chats/" + response = owner_client.post(url, { + 'type': 'group', + 'name': 'New Group Chat', + 'description': 'Test chat' + }) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + + chat = Chat.objects.get(id=data['id']) + assert chat.participants.filter(id=owner.id).exists() + assert chat.created_by == owner + + def test_add_member_creates_default_member_role(self, owner_client, owner, regular_user): + """При добавлении участника в групповой чат создается membership с ролью 'member'""" + # Создаем чат + chat = Chat.objects.create( + type='group', + name='Test Chat', + created_by=owner + ) + chat.participants.add(owner) + + # Добавляем участника + url = f"/api/v1/communications/chats/{chat.id}/add-member/" + response = owner_client.post(url, {'user_id': regular_user.id}) + + assert response.status_code == status.HTTP_200_OK + + # Проверяем что создался membership с ролью member + membership = ChatMembership.objects.get(chat=chat, user=regular_user) + assert membership.role == 'member' + assert membership.can_send_messages is True + assert membership.can_add_members is False + assert membership.invited_by == owner + + +# ==================== Тесты edge cases ==================== + + +class TestRoleEdgeCases: + """Тесты граничных случаев и специфичных сценариев""" + + def test_changing_role_updates_permissions_immediately(self, owner_client, group_chat_with_roles, + member_user, member_client): + """Изменение роли немедленно обновляет права""" + # Участник не может добавлять других участников + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/add-member/" + regular_user = User.objects.create_user( + email="newuser@test.com", + password="pass", + phone_number="+79997777777", + send_activation_email=False + ) + + response = member_client.post(url, {'user_id': regular_user.id}) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Повышаем до админа + change_url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + owner_client.post(change_url, { + 'user_id': member_user.id, + 'role': 'admin' + }) + + # Теперь может добавлять участников + response = member_client.post(url, {'user_id': regular_user.id}) + assert response.status_code == status.HTTP_200_OK + + def test_deactivated_membership_not_included(self, owner_client, group_chat_with_roles, member_user): + """Деактивированные memberships не включаются в список""" + # Деактивируем membership + membership = ChatMembership.objects.get(chat=group_chat_with_roles, user=member_user) + membership.is_active = False + membership.save() + + # Получаем чат + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/" + response = owner_client.get(url) + + data = response.json() + user_ids_in_memberships = [m['user'] for m in data['memberships']] + + # Деактивированный пользователь не должен быть в списке + assert member_user.id not in user_ids_in_memberships + + def test_multiple_role_changes_in_sequence(self, owner_client, group_chat_with_roles, member_user): + """Последовательные изменения ролей работают корректно""" + url = f"/api/v1/communications/chats/{group_chat_with_roles.id}/change-role/" + + # member -> admin + response = owner_client.post(url, {'user_id': member_user.id, 'role': 'admin'}) + assert response.status_code == status.HTTP_200_OK + + membership = ChatMembership.objects.get(chat=group_chat_with_roles, user=member_user) + assert membership.role == 'admin' + + # admin -> moderator + response = owner_client.post(url, {'user_id': member_user.id, 'role': 'moderator'}) + assert response.status_code == status.HTTP_200_OK + + membership.refresh_from_db() + assert membership.role == 'moderator' + + # moderator -> guest + response = owner_client.post(url, {'user_id': member_user.id, 'role': 'guest'}) + assert response.status_code == status.HTTP_200_OK + + membership.refresh_from_db() + assert membership.role == 'guest' + assert membership.can_send_messages is False diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index d78f53fe..91f6d711 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -304,6 +304,21 @@ export default function MessageDialogPage() { }, [chat]); const messagesById = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); + + // Определяем текущий membership и права на отправку сообщений + const myMembership = useMemo(() => { + if (!chat?.memberships || !user?.id) return null; + return chat.memberships.find(m => m.user === user.id) || null; + }, [chat?.memberships, user?.id]); + + const canSendMessages = useMemo(() => { + // Если нет membership - разрешаем (обычные чаты без ролей) + if (!myMembership) return true; + + // Проверяем флаг can_send_messages (гости имеют false) + return myMembership.can_send_messages; + }, [myMembership]); + const selectedActionMessage = expandedReplyActionForId ? messagesById.get(expandedReplyActionForId) || null : null; const selectedActionCanManage = Boolean( selectedActionMessage && @@ -1172,7 +1187,7 @@ export default function MessageDialogPage() { getChatInitials(chat, user?.id) )} -
+

{getChatTitle(chat, user?.id)}

{/* Индикатор WebSocket соединения */} @@ -1181,7 +1196,7 @@ export default function MessageDialogPage() { )}

{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}

-
+
@@ -1615,85 +1630,100 @@ export default function MessageDialogPage() {
) : null} -
- - -
- + {canSendMessages ? ( +
+ + - {showComposerEmojiPicker ? ( -
-
- {ALL_REACTIONS.map((emoji) => ( - - ))} -
+
+ + + {showComposerEmojiPicker ? ( +
+
+ {ALL_REACTIONS.map((emoji) => ( + + ))} +
+
+ ) : null} + +