fix: исправлена система прав доступа к чатам и сообщениям

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 при удалении/редактировании комментариев
- Комментарии в общем списке чатов
- Гости могли отправлять сообщения
This commit is contained in:
lizerk 2026-03-12 14:30:25 +03:00
parent d87e8ed796
commit 80cdc36d92
23 changed files with 4991 additions and 615 deletions

View File

@ -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

View File

@ -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(),

View File

@ -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(

View File

@ -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` - Обновление названий чатов отделов

View File

@ -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✅ Права успешно назначены'))

View File

@ -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):

View File

@ -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
)
# Изменение ролей участников

View File

@ -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()
)

View File

@ -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'

View File

@ -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()
```

View File

@ -0,0 +1,215 @@
# Диагностика прав доступа в чатах (Communications)
## Быстрая проверка
### 1. Проверить роль пользователя в чате
```bash
cd backend
.venv/bin/python manage.py manage_chat_permissions --chat-id <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 <ID> --user-id <USER_ID> --role admin
# ИЛИ дать персональное право
.venv/bin/python manage.py manage_chat_permissions \\
--chat-id <ID> --user-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=<ID>)
membership = ChatMembership.objects.get(chat=chat, user_id=<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=<USER_ID>).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 '❌'}")
```
**Все предикаты должны существовать!**

View File

@ -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):

View File

@ -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):

View File

@ -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',
}
)

View File

@ -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():

View File

@ -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):

View File

@ -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

View File

@ -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)
)}
</div>
<div>
<Link href={`/messages/${chatId}/settings`} className="hover:opacity-80 transition">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-900">{getChatTitle(chat, user?.id)}</p>
{/* Индикатор WebSocket соединения */}
@ -1181,7 +1196,7 @@ export default function MessageDialogPage() {
)}
</div>
<p className="text-xs text-gray-500">{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}</p>
</div>
</Link>
</div>
<div className="flex items-center gap-2">
@ -1615,85 +1630,100 @@ export default function MessageDialogPage() {
</div>
) : null}
<div className="flex items-start gap-2">
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFilesChange}
className="hidden"
/>
<button
type="button"
onClick={handlePickFiles}
disabled={Boolean(editingMessageId)}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 leading-none transition hover:bg-gray-50 hover:text-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
title={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
>
<Paperclip size={15} />
</button>
<div className="relative w-full" data-composer-emoji="true">
<button
type="button"
onClick={() => {
setShowComposerEmojiPicker((prev) => !prev);
setExpandedReplyActionForId(null);
setActionsMenuAnchor(null);
setReactionPickerForMessageId(null);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-sky-600"
title="Смайлы"
>
<Smile size={14} />
</button>
{canSendMessages ? (
<div className="flex items-start gap-2">
<input
ref={fileInputRef}
multiple
type="file"
className="hidden"
onChange={handleFilesChange}
/>
<button
type="button"
onClick={handlePickFiles}
disabled={Boolean(editingMessageId)}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 leading-none transition hover:bg-gray-50 hover:text-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
title={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
>
<Paperclip size={15} />
</button>
{showComposerEmojiPicker ? (
<div className="absolute bottom-full right-0 z-20 mb-2 w-[260px] rounded-lg border border-gray-200 bg-white p-2 shadow-xl">
<div className="grid max-h-48 grid-cols-8 gap-1 overflow-y-auto">
{ALL_REACTIONS.map((emoji) => (
<button
key={`composer-${emoji}`}
type="button"
onClick={() => appendEmojiToComposer(emoji)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-base hover:bg-sky-50"
>
{emoji}
</button>
))}
</div>
<div className="relative w-full" data-composer-emoji="true">
<button
type="button"
onClick={() => {
setShowComposerEmojiPicker((prev) => !prev);
setExpandedReplyActionForId(null);
setActionsMenuAnchor(null);
setReactionPickerForMessageId(null);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:bg-gray-100 hover:text-sky-600"
title="Смайлы"
>
<Smile size={14} />
</button>
{showComposerEmojiPicker ? (
<div className="absolute bottom-full right-0 z-20 mb-2 w-[260px] rounded-lg border border-gray-200 bg-white p-2 shadow-xl">
<div className="grid max-h-48 grid-cols-8 gap-1 overflow-y-auto">
{ALL_REACTIONS.map((emoji) => (
<button
key={`composer-${emoji}`}
type="button"
onClick={() => {
appendEmojiToComposer(emoji);
setShowComposerEmojiPicker(false);
}}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-base hover:bg-sky-50"
>
{emoji}
</button>
))}
</div>
</div>
) : null}
<textarea
ref={messageInputRef}
value={messageText}
onChange={(e) => {
setMessageText(e.target.value);
// Отправляем индикатор "печатает..."
sendTyping();
}}
onClick={() => setShowComposerEmojiPicker(false)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
rows={1}
placeholder={editingMessageId ? "Редактируйте сообщение..." : "Введите сообщение..."}
className="w-full resize-none rounded-lg border border-gray-200 bg-white h-9 px-3 py-2 pr-10 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
) : null}
<textarea
ref={messageInputRef}
value={messageText}
onChange={(e) => {
setMessageText(e.target.value);
// Отправляем индикатор "печатает..."
sendTyping();
}}
onClick={() => setShowComposerEmojiPicker(false)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
rows={1}
placeholder={editingMessageId ? "Редактируйте сообщение..." : "Введите сообщение..."}
className="w-full resize-none rounded-lg border border-gray-200 bg-white h-9 px-3 py-2 pr-10 text-sm text-gray-900 outline-none ring-0 transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<button
type="button"
onClick={handleSend}
disabled={sending || (editingMessageId ? !messageText.trim() : (!messageText.trim() && attachedFiles.length === 0))}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
title={editingMessageId ? "Сохранить" : "Отправить"}
>
<Send size={15} />
</button>
</div>
<button
type="button"
onClick={handleSend}
disabled={sending || (editingMessageId ? !messageText.trim() : (!messageText.trim() && attachedFiles.length === 0))}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500 text-white leading-none transition hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
title={editingMessageId ? "Сохранить" : "Отправить"}
>
<Send size={15} />
</button>
</div>
) : (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-center text-sm text-amber-800">
<p className="font-medium">У вас нет прав на отправку сообщений</p>
<p className="mt-1 text-xs text-amber-700">
{myMembership?.role === 'guest'
? 'Гости могут только просматривать сообщения и отправлять реакции'
: 'Обратитесь к администратору чата для получения прав'}
</p>
</div>
)}
</div>
</div>
</>

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,11 @@ import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { resolveMediaUrl } from "@/lib/url";
import type { Chat } from "@/types/api";
import { Search, MessageCircle, Pin, BellOff, Filter } from "lucide-react";
import { Search, MessageCircle, Pin, BellOff, Filter, Plus, X, Users, Globe, Radio, Upload, Image as ImageIcon } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useUser } from "@/contexts/UserContext";
import { useRouter } from "next/navigation";
function getUserFullName(lastName?: string, firstName?: string): string {
return `${lastName || ""} ${firstName || ""}`.trim();
@ -150,6 +151,7 @@ function isChatOnline(chat: Chat, currentUserId?: number): boolean {
export default function MessagesPage() {
const { user } = useUser();
const router = useRouter();
const currentUserId = user?.id;
const currentUserFirstName = user?.first_name;
const currentUserLastName = user?.last_name;
@ -173,6 +175,15 @@ export default function MessagesPage() {
const [filtersOpen, setFiltersOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Состояния для создания чата
const [showCreateModal, setShowCreateModal] = useState(false);
const [newChatType, setNewChatType] = useState<'group' | 'channel' | 'global'>('group');
const [newChatName, setNewChatName] = useState("");
const [newChatDescription, setNewChatDescription] = useState("");
const [newChatAvatar, setNewChatAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
useEffect(() => {
async function loadChats() {
@ -309,6 +320,78 @@ export default function MessagesPage() {
return counts;
}, [chats]);
const handleCreateChat = async () => {
if (creating) return;
// Валидация: все типы кроме comments требуют название
if (!newChatName.trim()) {
alert('Введите название чата');
return;
}
setCreating(true);
try {
const chatData: any = {
type: newChatType,
name: newChatName.trim(),
};
if (newChatDescription.trim()) {
chatData.description = newChatDescription.trim();
}
if (newChatAvatar) {
chatData.avatar = newChatAvatar;
}
const newChat = await apiClient.createChat(chatData);
// Закрываем модалку и сбрасываем форму
setShowCreateModal(false);
setNewChatType('group');
setNewChatName('');
setNewChatDescription('');
setNewChatAvatar(null);
setAvatarPreview(null);
// Перенаправляем на страницу настроек созданного чата
router.push(`/messages/${newChat.id}/settings`);
} catch (e: unknown) {
console.error('Ошибка создания чата:', e);
const errorMessage = e instanceof Error ? e.message : String(e);
alert(`Не удалось создать чат: ${errorMessage}`);
} finally {
setCreating(false);
}
};
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 5 * 1024 * 1024) { // 5MB
alert('Файл слишком большой. Максимум 5MB');
return;
}
if (!file.type.startsWith('image/')) {
alert('Можно загружать только изображения');
return;
}
setNewChatAvatar(file);
// Создаем превью
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = () => {
setNewChatAvatar(null);
setAvatarPreview(null);
};
return (
<AppShell>
{loading ? (
@ -333,6 +416,14 @@ export default function MessagesPage() {
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center justify-center rounded-lg border border-sky-200 bg-sky-500 p-2.5 text-white transition hover:bg-sky-600"
title="Создать чат"
>
<Plus size={16} />
</button>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
@ -642,6 +733,169 @@ export default function MessagesPage() {
) : null}
</section>
)}
{/* Модальное окно создания чата */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-900">Создать чат</h2>
<button
onClick={() => setShowCreateModal(false)}
className="rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
{/* Тип чата */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Тип чата
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setNewChatType('group')}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 transition ${
newChatType === 'group'
? 'border-sky-500 bg-sky-50 text-sky-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<Users size={18} />
<span className="text-sm font-medium">Групповой</span>
</button>
<button
type="button"
onClick={() => setNewChatType('channel')}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 transition ${
newChatType === 'channel'
? 'border-sky-500 bg-sky-50 text-sky-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<Radio size={18} />
<span className="text-sm font-medium">Канал</span>
</button>
<button
type="button"
onClick={() => setNewChatType('global')}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 transition ${
newChatType === 'global'
? 'border-sky-500 bg-sky-50 text-sky-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<Globe size={18} />
<span className="text-sm font-medium">Глобальный</span>
</button>
</div>
</div>
{/* Название и описание */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Название *
</label>
<input
type="text"
value={newChatName}
onChange={(e) => setNewChatName(e.target.value)}
placeholder="Введите название чата..."
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Описание (необязательно)
</label>
<textarea
value={newChatDescription}
onChange={(e) => setNewChatDescription(e.target.value)}
placeholder="Краткое описание чата..."
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
{/* Аватар */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Аватар (необязательно)
</label>
{avatarPreview ? (
<div className="flex items-center gap-3">
<div className="relative h-20 w-20">
<Image
src={avatarPreview}
alt="Preview"
width={80}
height={80}
className="h-20 w-20 rounded-full object-cover"
unoptimized
/>
</div>
<div className="flex-1">
<p className="text-sm text-gray-600 mb-2">{newChatAvatar?.name}</p>
<button
type="button"
onClick={handleRemoveAvatar}
className="inline-flex items-center gap-1 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-700 transition hover:bg-red-100"
>
<X size={14} />
Удалить
</button>
</div>
</div>
) : (
<label className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 transition hover:border-sky-400 hover:bg-sky-50">
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
<div className="flex flex-col items-center gap-2">
<div className="rounded-full bg-sky-100 p-3">
<ImageIcon size={24} className="text-sky-600" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-gray-700">
Загрузить изображение
</p>
<p className="text-xs text-gray-500 mt-1">
PNG, JPG до 5MB
</p>
</div>
</div>
</label>
)}
</div>
{/* Кнопки */}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
Отмена
</button>
<button
type="button"
onClick={handleCreateChat}
disabled={creating}
className="flex-1 rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-600 disabled:opacity-50"
>
{creating ? 'Создание...' : 'Создать'}
</button>
</div>
</div>
</div>
</div>
)}
</AppShell>
);
}

View File

@ -355,7 +355,9 @@ export function AppShell({ children }: AppShellProps) {
const { user, loading, logout } = useUser();
const router = useRouter();
const pathname = usePathname();
const isMessageDialogPage = pathname.startsWith('/messages/') && pathname !== '/messages';
const isMessageDialogPage = pathname.startsWith('/messages/') &&
pathname !== '/messages' &&
!pathname.includes('/settings');
// Вычисляем данные пользователя один раз на уровне AppShell
const userInitials = user

View File

@ -1425,7 +1425,30 @@ class ApiClient {
description?: string;
department?: number;
include_all_employees?: boolean;
avatar?: File;
}): Promise<any> {
// Если есть файл, используем FormData
if (data.avatar) {
const formData = new FormData();
formData.append('type', data.type);
if (data.name) formData.append('name', data.name);
if (data.description) formData.append('description', data.description);
if (data.avatar) formData.append('avatar', data.avatar);
if (data.participants) {
data.participants.forEach(id => formData.append('participants', id.toString()));
}
if (data.department) formData.append('department', data.department.toString());
if (data.include_all_employees !== undefined) {
formData.append('include_all_employees', data.include_all_employees.toString());
}
return this.request('/api/v1/communications/chats/', {
method: 'POST',
body: formData,
});
}
// Без файла - обычный JSON
return this.request('/api/v1/communications/chats/', {
method: 'POST',
body: JSON.stringify(data),
@ -1486,6 +1509,100 @@ class ApiClient {
});
}
/**
* Покинуть чат (для групповых чатов)
*/
async leaveChat(chatId: number): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/leave/`, {
method: 'POST',
});
}
/**
* Удалить чат (требует прав администратора чата)
*/
async deleteChat(chatId: number): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/`, {
method: 'DELETE',
});
}
/**
* Обновить информацию о чате (частичное обновление)
*/
async updateChat(chatId: number, data: Partial<{
name: string;
description: string;
type: string;
can_reply: boolean;
include_all_users: boolean;
flags: Record<string, any>;
extra_data: Record<string, any>;
}>): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
/**
* Загрузить аватар чата
*/
async uploadChatAvatar(chatId: number, file: File): Promise<any> {
const formData = new FormData();
formData.append('avatar', file);
return this.request(`/api/v1/communications/chats/${chatId}/`, {
method: 'PATCH',
body: formData,
});
}
/**
* Добавить участника в чат
*/
async addChatMember(chatId: number, userId: number): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/add-member/`, {
method: 'POST',
body: JSON.stringify({ user_id: userId }),
});
}
/**
* Удалить участника из чата
*/
async removeChatMember(chatId: number, userId: number): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/remove-member/`, {
method: 'POST',
body: JSON.stringify({ user_id: userId }),
});
}
/**
* Изменить роль участника в чате
*/
async changeChatMemberRole(chatId: number, userId: number, role: 'admin' | 'moderator' | 'member' | 'guest'): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/change-role/`, {
method: 'POST',
body: JSON.stringify({ user_id: userId, role }),
});
}
/**
* Обновить пользовательские настройки чата
*/
async updateChatUserSettings(chatId: number, data: Partial<{
custom_name: string;
is_hidden: boolean;
}>): Promise<any> {
// Примечание: может потребоваться отдельный endpoint на backend
// Сейчас используем PATCH чата, но лучше создать /api/v1/communications/chats/{id}/user-settings/
return this.request(`/api/v1/communications/chats/${chatId}/user-settings/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
// ==================== Оборудование ====================
async getEquipmentCategories(params?: Record<string, string | number>): Promise<any> {

View File

@ -358,17 +358,60 @@ export interface Chat {
id: number;
name?: string;
avatar?: string | null;
description?: string | null;
interlocutor?: { id: number; name?: string; avatar?: string | null } | null;
chat_type?: 'direct' | 'group' | 'department';
type?: 'private' | 'group' | 'department' | 'announcement';
chat_type?: 'direct' | 'group' | 'department' | 'global' | 'channel' | 'private' | 'announcement' | 'comments';
type?: 'private' | 'group' | 'department' | 'announcement' | 'global' | 'channel' | 'direct' | 'comments';
participants?: Array<User | number>;
participant_names?: string[];
participant_details?: Array<{ id: number; name?: string; avatar?: string | null }>;
last_message?: Message;
unread_count?: number;
created_at: string;
created_by?: number;
is_pinned?: boolean;
notifications_enabled?: boolean;
can_manage?: boolean;
can_reply?: boolean;
include_all_users?: boolean;
is_main?: boolean;
is_blocked?: boolean;
blocked_at?: string | null;
blocked_by?: number | null;
flags?: Record<string, any>;
extra_data?: Record<string, any>;
context_object_id?: number | null;
context_type?: string | null;
context_app?: string | null;
memberships?: ChatMembership[];
user_settings?: ChatUserSettings;
last_read_message_id?: number | null;
}
export interface ChatMembership {
id: number;
user: number;
user_name?: string;
role: 'admin' | 'moderator' | 'member' | 'guest';
joined_at: string;
invited_by?: number | null;
is_active: boolean;
left_at?: string | null;
can_send_messages: boolean;
can_add_members: boolean;
can_remove_members: boolean;
can_pin_messages: boolean;
can_manage_members?: boolean;
}
export interface ChatUserSettings {
id?: number;
is_pinned: boolean;
pinned_at?: string | null;
pin_order: number;
notifications_enabled: boolean;
custom_name?: string;
is_hidden: boolean;
}
export interface Message {