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:
parent
d87e8ed796
commit
80cdc36d92
126
backend/communications/api/permissions.py
Normal file
126
backend/communications/api/permissions.py
Normal 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
|
||||
@ -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(),
|
||||
|
||||
@ -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(
|
||||
|
||||
93
backend/communications/management/commands/README.md
Normal file
93
backend/communications/management/commands/README.md
Normal 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` - Обновление названий чатов отделов
|
||||
@ -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✅ Права успешно назначены'))
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
# Изменение ролей участников
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
|
||||
445
backend/docs/completed/CHAT_PERMISSIONS_FIX.md
Normal file
445
backend/docs/completed/CHAT_PERMISSIONS_FIX.md
Normal 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'
|
||||
355
backend/docs/guides/CHAT_ROLES.md
Normal file
355
backend/docs/guides/CHAT_ROLES.md
Normal 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()
|
||||
```
|
||||
215
backend/docs/troubleshooting/CHAT_PERMISSIONS_DEBUG.md
Normal file
215
backend/docs/troubleshooting/CHAT_PERMISSIONS_DEBUG.md
Normal 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 '❌'}")
|
||||
```
|
||||
|
||||
**Все предикаты должны существовать!**
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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',
|
||||
}
|
||||
)
|
||||
@ -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():
|
||||
|
||||
@ -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):
|
||||
|
||||
923
backend/tests/api/v1/communications/test_chat_roles.py
Normal file
923
backend/tests/api/v1/communications/test_chat_roles.py
Normal 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
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
1108
frontend/src/app/messages/[chatId]/settings/page.tsx
Normal file
1108
frontend/src/app/messages/[chatId]/settings/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user