diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 00000000..5b11d76b --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,14 @@ +[flake8] +max-line-length = 88 +exclude = + .git, + .venv, + __pycache__, + node_modules, + staticfiles, + media, + migrations +extend-ignore = + W503 +per-file-ignores = + communications/api/viewsets.py:E501 diff --git a/backend/api/v1/schedule/views.py b/backend/api/v1/schedule/views.py index f8a560d3..319aa81b 100644 --- a/backend/api/v1/schedule/views.py +++ b/backend/api/v1/schedule/views.py @@ -568,9 +568,9 @@ class ScheduleEventRelationViewSet(viewsets.ModelViewSet): def get_queryset(self): """Фильтрация по событию.""" qs = super().get_queryset() - + event_id = self.request.query_params.get('event') if event_id: qs = qs.filter(event_id=event_id) - - return qs + + return qs.order_by('id') diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 35795ace..49a53d50 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -19,16 +19,9 @@ from datetime import timezone as dt_tz from asgiref.sync import async_to_sync from channels.layers import get_channel_layer -from ..models import (Chat, ChatMembership, ChatReadState, - ChatUserSettings, Message, - MessageAttachment, MessageReaction, Poll, - PollOption, PollVote) -from communications.serialization import serialize_message -from ..utils import _coerce_ts, user_can_access_chat from django.contrib.auth import get_user_model from django.db import transaction -from django.db.models import Count, Exists, OuterRef, Prefetch, Q, Subquery -from django.db.models.functions import Coalesce +from django.db.models import Prefetch, Q from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action @@ -36,13 +29,32 @@ from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from .serializers import (BulkDeleteSerializer, ChatDetailSerializer, - ChatListSerializer, ForwardMessageSerializer, - MessageAttachmentSerializer, MessageCreateSerializer, - MessageDetailSerializer, MessageEditSerializer, - MessageListSerializer, PollSerializer, - ReactionSerializer) from .permissions import ChatPermission, MessagePermission +from .serializers import ( + BulkDeleteSerializer, + ChatDetailSerializer, + ChatListSerializer, + ForwardMessageSerializer, + MessageCreateSerializer, + MessageDetailSerializer, + MessageEditSerializer, + MessageListSerializer, + PollSerializer, + ReactionSerializer, +) +from ..models import ( + Chat, + ChatMembership, + ChatReadState, + ChatUserSettings, + Message, + MessageAttachment, + MessageReaction, + Poll, + PollVote, +) +from ..utils import _coerce_ts, user_can_access_chat +from communications.serialization import serialize_message logger = logging.getLogger(__name__) User = get_user_model() @@ -80,15 +92,14 @@ class ChatViewSet(viewsets.ModelViewSet): def get_queryset(self): """ Чаты доступные пользователю с аннотацией непрочитанных. - + ОПТИМИЗИРОВАНО: Используем денормализованное поле 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(memberships__user=user, memberships__is_active=True) @@ -117,12 +128,12 @@ class ChatViewSet(viewsets.ModelViewSet): to_attr='my_read_state' ) ).distinct() - + # Исключаем чаты-комментарии из общего списка # Они доступны только через прямой запрос (retrieve) или через контекст поста if self.action == 'list': queryset = queryset.exclude(type='comments') - + return queryset.order_by('-created_at') def create(self, request, *args, **kwargs): @@ -211,29 +222,29 @@ class ChatViewSet(viewsets.ModelViewSet): 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 ) - + # MIGRATION: Убрали participants.remove, только деактивируем membership membership = ChatMembership.objects.filter( chat=chat, user=request.user ).first() - + if membership: membership.is_active = False membership.left_at = timezone.now() @@ -244,7 +255,7 @@ class ChatViewSet(viewsets.ModelViewSet): {'error': 'You are not a member of this chat'}, status=status.HTTP_400_BAD_REQUEST ) - + return Response({ 'ok': True, 'message': 'Successfully left the chat' @@ -254,23 +265,23 @@ class ChatViewSet(viewsets.ModelViewSet): 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: @@ -278,7 +289,7 @@ class ChatViewSet(viewsets.ModelViewSet): {'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND ) - + # MIGRATION: Проверяем через memberships вместо participants if ChatMembership.objects.filter( chat=chat, @@ -289,7 +300,7 @@ class ChatViewSet(viewsets.ModelViewSet): {'error': 'User is already a member'}, status=status.HTTP_400_BAD_REQUEST ) - + # Создаем или восстанавливаем membership для чатов с управлением участниками if chat.type in ['group', 'channel', 'announcement']: membership, created = ChatMembership.objects.get_or_create( @@ -301,7 +312,7 @@ class ChatViewSet(viewsets.ModelViewSet): 'is_active': True } ) - + # Если membership уже существовал (например, пользователь раньше покинул чат), # восстанавливаем его активность if not created and not membership.is_active: @@ -310,7 +321,7 @@ class ChatViewSet(viewsets.ModelViewSet): membership.invited_by = request.user # Обновляем кто пригласил повторно membership.save() chat.participants.add(user_to_add) - + return Response({ 'ok': True, 'message': 'User added successfully' @@ -320,23 +331,23 @@ class ChatViewSet(viewsets.ModelViewSet): 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: @@ -344,31 +355,31 @@ class ChatViewSet(viewsets.ModelViewSet): {'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 ) - + # MIGRATION: Убрали participants.remove, только деактивируем membership membership = ChatMembership.objects.filter( chat=chat, user=user_to_remove ).first() - + if not membership: return Response( {'error': 'User is not a member'}, status=status.HTTP_400_BAD_REQUEST ) - + membership.is_active = False membership.left_at = timezone.now() membership.save(update_fields=['is_active', 'left_at']) chat.participants.remove(user_to_remove) - + return Response({ 'ok': True, 'message': 'User removed successfully' @@ -378,17 +389,17 @@ class ChatViewSet(viewsets.ModelViewSet): 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: @@ -396,10 +407,10 @@ class ChatViewSet(viewsets.ModelViewSet): {'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}. " @@ -407,13 +418,13 @@ class ChatViewSet(viewsets.ModelViewSet): 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: @@ -421,14 +432,14 @@ class ChatViewSet(viewsets.ModelViewSet): {'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, @@ -439,13 +450,13 @@ class ChatViewSet(viewsets.ModelViewSet): 'is_active': True # Явно устанавливаем is_active при создании } ) - + logger.warning( f"[change_role] membership found: created={created}, " f"user_id={user_to_change.id}, old_role={membership.role}, new_role={new_role}, " f"is_active={membership.is_active}" ) - + if not created: # Обновляем существующий membership old_role = membership.role @@ -455,7 +466,7 @@ class ChatViewSet(viewsets.ModelViewSet): membership.left_at = None # Сбрасываем left_at если был membership.set_permissions_for_role() membership.save() - + # Перезагружаем из БД чтобы убедиться что сохранилось membership.refresh_from_db() logger.warning( @@ -464,7 +475,7 @@ class ChatViewSet(viewsets.ModelViewSet): f"old_is_active={old_is_active}, new_is_active={membership.is_active}, " f"saved_correctly={membership.role == new_role and membership.is_active}" ) - + return Response({ 'ok': True, 'message': f'User role changed to {new_role}', @@ -897,13 +908,13 @@ class MessageViewSet(viewsets.ModelViewSet): def get_queryset(self): """ Сообщения доступные пользователю - + Включает сообщения из чатов: - где user в participants (прямое участие) - где user в ChatMembership (роли) - где include_all_users=True (открытые чаты) - context-based чаты (type=comments с context_object) - + Детальная проверка доступа выполняется через MessagePermission """ user = self.request.user @@ -915,13 +926,13 @@ class MessageViewSet(viewsets.ModelViewSet): | Q(participants=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 diff --git a/backend/employees/ldap/tests/test_ldap_connection.py b/backend/employees/ldap/tests/test_ldap_connection.py index 714d03e5..2a25a805 100644 --- a/backend/employees/ldap/tests/test_ldap_connection.py +++ b/backend/employees/ldap/tests/test_ldap_connection.py @@ -22,7 +22,6 @@ def test_ldap_connection(): print(f"\n✓ LDAP подключение успешно") print(f"✓ Сервер: {conn.server}") print(f"✓ Пользователь: {conn.user}") - return True except Exception as e: pytest.fail(f"Не удалось подключиться к LDAP: {e}") diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index 956ecdc9..5d0f5906 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -6,11 +6,14 @@ API endpoints для системы уведомлений v2.0 - verb вместо category/type - UserChannelPreferences для настроек каналов """ +from datetime import datetime + from drf_spectacular.utils import OpenApiParameter, extend_schema +from django.db.models import Count, Q +from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework import status from ..models import Notification, UserChannelPreferences from .serializers import ( @@ -33,11 +36,23 @@ def _get_notification_title(notification): # Приоритет: title из data (задается при создании уведомления) if notification.data and 'title' in notification.data: return notification.data['title'] - + # Fallback: генерируем простой заголовок из verb return notification.verb.replace('_', ' ').title() +def _format_time_or_none(value): + if value is None: + return None + return value.strftime('%H:%M') + + +def _parse_time_or_none(value): + if not value: + return None + return datetime.strptime(value, '%H:%M').time() + + @extend_schema( tags=["Notifications"], summary="Получить список уведомлений", @@ -54,8 +69,8 @@ def _get_notification_title(notification): @permission_classes([IsAuthenticated]) def get_notifications(request): """ - Получить список уведомлений с пагинацией и фильтрами - + Получить список уведомлений с пагинацией и фильтрами. + Query params: - page: номер страницы (default: 1) - page_size: размер страницы (default: 20) @@ -84,10 +99,10 @@ def get_notifications(request): # Фильтры if verb: queryset = queryset.filter(verb=verb) - + if unread_only: queryset = queryset.unread() - + if search: queryset = queryset.filter(description__icontains=search) @@ -110,8 +125,8 @@ def get_notifications(request): 'title': _get_notification_title(n), 'message': n.description, 'short_message': ( - n.description[:100] + '...' - if len(n.description) > 100 + n.description[:100] + '...' + if len(n.description) > 100 else n.description ), 'is_read': not n.unread, @@ -174,12 +189,12 @@ def mark_as_read(request, notification_id): recipient=request.user ) notification.mark_as_read() - + return Response({ 'status': 'success', 'notification_id': notification_id }) - + except Notification.DoesNotExist: return Response( {'status': 'error', 'message': 'Notification not found'}, @@ -203,12 +218,12 @@ def mark_as_unread(request, notification_id): recipient=request.user ) notification.mark_as_unread() - + return Response({ 'status': 'success', 'notification_id': notification_id }) - + except Notification.DoesNotExist: return Response( {'status': 'error', 'message': 'Notification not found'}, @@ -227,16 +242,16 @@ def mark_as_unread(request, notification_id): def mark_all_as_read(request): """Отметить все уведомления как прочитанные""" verb = request.data.get('verb') # Опционально - только определенный тип - + queryset = Notification.objects.filter( recipient=request.user, unread=True, deleted=False, ) - + if verb: queryset = queryset.filter(verb=verb) - + count = queryset.mark_all_as_read() return Response({ @@ -309,9 +324,9 @@ def delete_notification(request, notification_id): ) notification.deleted = True notification.save(update_fields=['deleted']) - + return Response({'status': 'success'}) - + except Notification.DoesNotExist: return Response( {'status': 'error', 'message': 'Notification not found'}, @@ -349,12 +364,10 @@ def delete_all_read(request): @permission_classes([IsAuthenticated]) def get_verb_types(request): """ - Получить список типов (verb) уведомлений с количеством - + Получить список типов (verb) уведомлений с количеством. + Возвращает статистику по типам уведомлений пользователя. """ - from django.db.models import Count, Q - verb_stats = Notification.objects.filter( recipient=request.user, deleted=False, @@ -394,11 +407,11 @@ def get_verb_types(request): @permission_classes([IsAuthenticated]) def channel_preferences(request): """ - Получить или обновить настройки каналов уведомлений - + Получить или обновить настройки каналов уведомлений. + GET: Возвращает текущие настройки PUT: Обновляет настройки - + Настройки: - web_enabled: WebSocket уведомления (bool) - email_enabled: Email уведомления (bool) @@ -410,10 +423,10 @@ def channel_preferences(request): - disabled_verbs: Список отключенных типов (array) """ # Получаем или создаем настройки - prefs, created = UserChannelPreferences.objects.get_or_create( + prefs, _created = UserChannelPreferences.objects.get_or_create( user=request.user ) - + if request.method == 'GET': data = { 'web_enabled': prefs.web_enabled, @@ -421,72 +434,76 @@ def channel_preferences(request): 'email_frequency': prefs.email_frequency, 'push_enabled': prefs.push_enabled, 'dnd_enabled': prefs.dnd_enabled, - 'dnd_start_time': prefs.dnd_start_time.strftime('%H:%M') if prefs.dnd_start_time else None, - 'dnd_end_time': prefs.dnd_end_time.strftime('%H:%M') if prefs.dnd_end_time else None, + 'dnd_start_time': _format_time_or_none(prefs.dnd_start_time), + 'dnd_end_time': _format_time_or_none(prefs.dnd_end_time), 'disabled_verbs': prefs.disabled_verbs, } return Response(data) - - elif request.method == 'PUT': + + if request.method == 'PUT': # Обновляем настройки if 'web_enabled' in request.data: prefs.web_enabled = request.data.get('web_enabled') - + if 'email_enabled' in request.data: prefs.email_enabled = request.data.get('email_enabled') - + if 'email_frequency' in request.data: frequency = request.data.get('email_frequency') if frequency in ['instant', 'daily', 'weekly', 'disabled']: prefs.email_frequency = frequency - + if 'push_enabled' in request.data: prefs.push_enabled = request.data.get('push_enabled') - + if 'dnd_enabled' in request.data: prefs.dnd_enabled = request.data.get('dnd_enabled') - + if 'dnd_start_time' in request.data: - from datetime import datetime time_str = request.data.get('dnd_start_time') - if time_str: - prefs.dnd_start_time = datetime.strptime(time_str, '%H:%M').time() - + prefs.dnd_start_time = _parse_time_or_none(time_str) + if 'dnd_end_time' in request.data: - from datetime import datetime time_str = request.data.get('dnd_end_time') - if time_str: - prefs.dnd_end_time = datetime.strptime(time_str, '%H:%M').time() - + prefs.dnd_end_time = _parse_time_or_none(time_str) + if 'disabled_verbs' in request.data: prefs.disabled_verbs = request.data.get('disabled_verbs') - + prefs.save() - + return Response({ 'status': 'success', 'message': 'Preferences updated' }) + return Response( + {'status': 'error', 'message': 'Method not allowed'}, + status=status.HTTP_405_METHOD_NOT_ALLOWED, + ) + @extend_schema( tags=["Notifications"], summary="Получить публичный VAPID ключ", - responses={200: VapidPublicKeyResponseSerializer, 503: VapidPublicKeyResponseSerializer}, + responses={ + 200: VapidPublicKeyResponseSerializer, + 503: VapidPublicKeyResponseSerializer, + }, ) @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_vapid_public_key(request): """ - Получить публичный VAPID ключ для Web Push подписки - + Получить публичный VAPID ключ для Web Push подписки. + Returns: - vapid_public_key: публичный ключ в формате base64url """ from django.conf import settings - + vapid_key = getattr(settings, 'VAPID_PUBLIC_KEY', None) - + if not vapid_key: return Response( { @@ -496,7 +513,7 @@ def get_vapid_public_key(request): }, status=status.HTTP_503_SERVICE_UNAVAILABLE ) - + return Response({ 'status': 'success', 'vapid_public_key': vapid_key @@ -513,25 +530,25 @@ def get_vapid_public_key(request): @permission_classes([IsAuthenticated]) def subscribe_push(request): """ - Подписка на Web Push уведомления - + Подписка на Web Push уведомления. + Body: - endpoint: URL для отправки push - keys: {p256dh, auth} ключи шифрования - device_name: название устройства/браузера (опционально) """ from push_notifications.models import WebPushDevice - + endpoint = request.data.get('endpoint') keys = request.data.get('keys', {}) device_name = request.data.get('device_name', 'unknown') - + if not endpoint or not keys: return Response( {'status': 'error', 'message': 'endpoint and keys required'}, status=status.HTTP_400_BAD_REQUEST ) - + # Регистрируем устройство device, created = WebPushDevice.objects.update_or_create( user=request.user, @@ -543,10 +560,14 @@ def subscribe_push(request): 'active': True, } ) - + return Response({ 'status': 'success', - 'message': 'Push subscription created' if created else 'Push subscription updated', + 'message': ( + 'Push subscription created' + if created + else 'Push subscription updated' + ), 'created': created, 'device_id': device.id }) @@ -564,9 +585,9 @@ def subscribe_push(request): def unsubscribe_push(request): """Отписка от Web Push уведомлений""" from push_notifications.models import WebPushDevice - + endpoint = request.data.get('endpoint') - + if endpoint: deleted_count, _ = WebPushDevice.objects.filter( user=request.user, @@ -577,7 +598,7 @@ def unsubscribe_push(request): deleted_count, _ = WebPushDevice.objects.filter( user=request.user ).delete() - + return Response({ 'status': 'success', 'message': f'Deleted {deleted_count} push subscription(s)', diff --git a/backend/pytest.ini b/backend/pytest.ini index baa2e118..9db99a60 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -25,3 +25,9 @@ norecursedirs = .git .venv __pycache__ node_modules staticfiles media scripts te # Опции для вывода console_output_style = progress + +filterwarnings = + ignore:tagMap is deprecated\. Please use TAG_MAP instead\.:DeprecationWarning:pyasn1\.codec\.ber\.encoder + ignore:typeMap is deprecated\. Please use TYPE_MAP instead\.:DeprecationWarning:pyasn1\.codec\.ber\.encoder + ignore:datetime\.datetime\.utcfromtimestamp\(\) is deprecated:DeprecationWarning:ldapdb\.models\.fields + ignore:The 'django-fsm' package has been integrated into 'viewflow' as 'viewflow\.fsm'.*:UserWarning:django_fsm