diff --git a/backend/api/apps.py b/backend/api/apps.py index 439b0946..0362780c 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -9,6 +9,5 @@ class ApiConfig(AppConfig): def ready(self): """Инициализация при загрузке приложения.""" # Применяем патч для django-scheduler (исправление бага с byweekday) - print("🔧 ApiConfig.ready() - применение патча django-scheduler") import schedule_patch schedule_patch.apply_patch() diff --git a/backend/api/v1/communications/views.py b/backend/api/v1/communications/views.py index 10417bb9..6653fea8 100644 --- a/backend/api/v1/communications/views.py +++ b/backend/api/v1/communications/views.py @@ -6,7 +6,7 @@ DRF ViewSets для управления чатами, сообщениями и Endpoints: - /api/v1/communications/chats/ - ChatViewSet -- /api/v1/communications/messages/ - MessageViewSet +- /api/v1/communications/messages/ - MessageViewSet - /api/v1/communications/polls/ - PollViewSet История: @@ -14,35 +14,34 @@ Endpoints: - Удаление legacy кода: 15 января 2026 """ import logging -from datetime import datetime, timezone as dt_tz -from django.contrib.auth import get_user_model -from django.db import transaction -from django.db.models import Q, Count, Prefetch, Exists, OuterRef, Subquery -from django.db.models.functions import Coalesce -from django.utils import timezone -from rest_framework import viewsets, status -from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from datetime import datetime +from datetime import timezone as dt_tz + from asgiref.sync import async_to_sync from channels.layers import get_channel_layer - -from communications.models import ( - Chat, Message, MessageAttachment, MessageReaction, - Poll, PollOption, PollVote, ChatMembership, ChatUserSettings, - ChatReadState -) +from communications.models import (Chat, ChatMembership, ChatReadState, + ChatUserSettings, Message, + MessageAttachment, MessageReaction, Poll, + PollOption, PollVote) from communications.serialization import serialize_message -from communications.views import user_can_access_chat, _coerce_ts -from .serializers import ( - ChatListSerializer, ChatDetailSerializer, - MessageListSerializer, MessageDetailSerializer, - MessageCreateSerializer, MessageEditSerializer, - PollSerializer, ReactionSerializer, - ForwardMessageSerializer, BulkDeleteSerializer, - MessageAttachmentSerializer -) +from communications.views 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.utils import timezone +from rest_framework import status, viewsets +from rest_framework.decorators import action +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) logger = logging.getLogger(__name__) Employee = get_user_model() @@ -51,41 +50,41 @@ Employee = get_user_model() class ChatViewSet(viewsets.ModelViewSet): """ ViewSet для управления чатами - + list: GET /api/v1/communications/chats/ - список чатов пользователя retrieve: GET /api/v1/communications/chats/{id}/ - детали чата create: POST /api/v1/communications/chats/ - создать чат - + Actions: - pin: POST /api/v1/communications/chats/{id}/pin/ - закрепить/открепить - notifications: POST /api/v1/communications/chats/{id}/notifications/ - вкл/выкл уведомления - messages: GET /api/v1/communications/chats/{id}/messages/ - список сообщений (автоотметка) - messages_around: GET /api/v1/communications/chats/{id}/messages_around/ - сообщения вокруг (автоотметка) - mark_read: POST /api/v1/communications/chats/{id}/mark_read/ - [DEPRECATED] используйте GET /messages/ - + Автоматическая отметка прочитанных (Telegram-style): При любой загрузке сообщений (messages, messages_around) последнее загруженное сообщение автоматически отмечается как прочитанное через last_read_message_id. Ручной вызов /mark-read/ больше не требуется. """ - + permission_classes = [IsAuthenticated] - + def get_serializer_class(self): if self.action == 'list': return ChatListSerializer return ChatDetailSerializer - + def get_queryset(self): """Чаты доступные пользователю с аннотацией непрочитанных""" user = self.request.user - + # Подзапрос для last_read_message_id (Telegram-style) read_state_subq = ChatReadState.objects.filter( chat=OuterRef('pk'), user=user ).values('last_read_message_id')[:1] - + # Подзапрос для количества непрочитанных # Считаем сообщения с ID больше последнего прочитанного unread_count_subq = Message.objects.filter( @@ -95,7 +94,7 @@ class ChatViewSet(viewsets.ModelViewSet): ).exclude(author=user).values('chat').annotate( count=Count('id') ).values('count') - + queryset = Chat.objects.filter( Q(participants=user) | Q(department__in=user.departments_links.filter( @@ -106,32 +105,33 @@ class ChatViewSet(viewsets.ModelViewSet): 'department', 'created_by' ).prefetch_related( 'participants', - Prefetch('user_settings', queryset=ChatUserSettings.objects.filter(user=user)) + Prefetch('user_settings', + queryset=ChatUserSettings.objects.filter(user=user)) ).annotate( unread_count=unread_count_subq ).distinct().order_by('-created_at') - + return queryset - + def create(self, request, *args, **kwargs): """Создание чата""" serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - + # Устанавливаем создателя chat = serializer.save(created_by=request.user) - + # Для приватного чата добавляем создателя в участники if chat.type == 'private': chat.participants.add(request.user) - + headers = self.get_success_headers(serializer.data) return Response( ChatDetailSerializer(chat, context={'request': request}).data, status=status.HTTP_201_CREATED, headers=headers ) - + @action(detail=True, methods=['post']) def pin(self, request, pk=None): """Закрепить/открепить чат""" @@ -142,12 +142,12 @@ class ChatViewSet(viewsets.ModelViewSet): ) settings.is_pinned = not settings.is_pinned settings.save() - + return Response({ 'ok': True, 'is_pinned': settings.is_pinned }) - + @action(detail=True, methods=['post']) def notifications(self, request, pk=None): """Включить/выключить уведомления""" @@ -158,46 +158,49 @@ class ChatViewSet(viewsets.ModelViewSet): ) settings.notifications_enabled = not settings.notifications_enabled settings.save() - + return Response({ 'ok': True, 'notifications_enabled': settings.notifications_enabled }) - + def _auto_mark_read(self, chat, user, messages): """ Автоматически отмечает последнее загруженное сообщение как прочитанное. Pragmatic подход: "загрузил = прочитал" с ограничением количества новых сообщений. - + Защита от откатов: обновляет только если новое сообщение НОВЕЕ текущего. """ if not messages: return - + last_message = messages[-1] - + read_state, created = ChatReadState.objects.get_or_create( chat=chat, user=user, defaults={'last_read_message': last_message} ) - + if created: - logger.info(f"[auto_mark_read] Created: user={user.id}, chat={chat.id}, msg={last_message.id}") + logger.info( + f"[auto_mark_read] Created: user={user.id}, chat={chat.id}, msg={last_message.id}") self._send_marked_read_event(user.id, chat.id, last_message.id) return - + # Защита от откатов: только если НОВЕЕ if read_state.last_read_message_id and last_message.id <= read_state.last_read_message_id: - logger.debug(f"[auto_mark_read] Skip: {last_message.id} <= {read_state.last_read_message_id}") + logger.debug( + f"[auto_mark_read] Skip: {last_message.id} <= {read_state.last_read_message_id}") return - + read_state.last_read_message = last_message read_state.save(update_fields=['last_read_message', 'updated_at']) - - logger.info(f"[auto_mark_read] Updated: user={user.id}, chat={chat.id}, msg={last_message.id}") + + logger.info( + f"[auto_mark_read] Updated: user={user.id}, chat={chat.id}, msg={last_message.id}") self._send_marked_read_event(user.id, chat.id, last_message.id) - + def _send_marked_read_event(self, user_id, chat_id, message_id): """WebSocket событие для синхронизации между вкладками""" channel_layer = get_channel_layer() @@ -209,32 +212,32 @@ class ChatViewSet(viewsets.ModelViewSet): 'last_read_message_id': message_id } ) - + @action(detail=True, methods=['get']) def messages(self, request, pk=None): """Загрузка сообщений чата (пагинация по времени)""" chat = self.get_object() - + # Проверка доступа if not user_can_access_chat(chat, request.user): return Response( {'error': 'Access denied'}, status=status.HTTP_403_FORBIDDEN ) - + # Параметры пагинации before_ts = request.query_params.get('before') before_id = request.query_params.get('before_id') after_ts = request.query_params.get('after') after_id = request.query_params.get('after_id') limit = min(int(request.query_params.get('limit', 50)), 100) - + queryset = chat.messages.filter(is_deleted=False).select_related( 'author', 'reply_to', 'reply_to__author', 'poll' ).prefetch_related( 'attachments', 'reactions', 'reactions__user', 'poll__options' ) - + # Определяем порядок сортировки в зависимости от типа запроса if after_id or after_ts: # Для загрузки новых сообщений - сортируем по возрастанию (от старых к новым) @@ -242,7 +245,7 @@ class ChatViewSet(viewsets.ModelViewSet): else: # Для загрузки старых или начальной загрузки - по убыванию (от новых к старым) queryset = queryset.order_by('-created_at') - + # Фильтрация по ID (приоритет) или timestamp if before_id: try: @@ -253,7 +256,7 @@ class ChatViewSet(viewsets.ModelViewSet): before_dt = _coerce_ts(before_ts) if before_dt: queryset = queryset.filter(created_at__lt=before_dt) - + if after_id: try: queryset = queryset.filter(id__gt=int(after_id)) @@ -263,164 +266,169 @@ class ChatViewSet(viewsets.ModelViewSet): after_dt = _coerce_ts(after_ts) if after_dt: queryset = queryset.filter(created_at__gt=after_dt) - + # Берем limit+1 чтобы определить has_more messages = list(queryset[:limit + 1]) has_more = len(messages) > limit - + if has_more: messages = messages[:limit] # Убираем лишнее - + # Если загружали с after - уже в прямом порядке, иначе переворачиваем if not (after_id or after_ts): messages.reverse() - + # Автоотметка при загрузке НОВЫХ сообщений (after_id/after_ts) # При scroll вверх (before_id) - не отмечаем if (after_id or after_ts) and messages: self._auto_mark_read(chat, request.user, messages) - + return Response({ 'messages': [serialize_message(m) for m in messages], 'has_more': has_more }) - + @action(detail=True, methods=['get'], url_path='messages-around') def messages_around(self, request, pk=None): """ Загрузка сообщений вокруг указанного (last_read_message_id). - + Асимметричная загрузка для браузерных приложений: - 24 сообщения ДО (контекст прочитанных) - - 6 сообщений ПОСЛЕ (новые непрочитанные) - + - 10 сообщений ПОСЛЕ (новые непрочитанные) + Автоматическая отметка последнего загруженного как прочитанного. Погрешность: макс 1-2 невидимых сообщения (приемлемо для веб). """ chat = self.get_object() - + if not user_can_access_chat(chat, request.user): return Response( {'error': 'Access denied'}, status=status.HTTP_403_FORBIDDEN ) - + around_id = request.query_params.get('around_id') - - # Асимметричные лимиты (24 + 6 = 30 сообщений) + + # Асимметричные лимиты (24 + 10 = 34 сообщения ) before_limit = int(request.query_params.get('before_limit', 24)) - after_limit = int(request.query_params.get('after_limit', 6)) - + after_limit = int(request.query_params.get('after_limit', 10)) + # Для обратной совместимости с limit параметром if 'limit' in request.query_params and 'before_limit' not in request.query_params: total_limit = min(int(request.query_params.get('limit', 30)), 100) before_limit = int(total_limit * 0.8) # 80% на контекст after_limit = total_limit - before_limit # 20% на новые - + queryset = chat.messages.filter(is_deleted=False).select_related( 'author', 'reply_to', 'reply_to__author', 'poll' ).prefetch_related( 'attachments', 'reactions', 'reactions__user', 'poll__options' ) - + # Если нет around_id, пытаемся получить last_read_message_id if not around_id: read_state = ChatReadState.objects.filter( chat=chat, user=request.user ).first() - + if read_state and read_state.last_read_message_id: around_id = read_state.last_read_message_id - + # Если все еще нет around_id - возвращаем последние сообщения if not around_id: fallback_limit = before_limit + after_limit messages = list(queryset.order_by('-created_at')[:fallback_limit]) messages.reverse() - + # Автоотметка: последнее ЗАГРУЖЕННОЕ становится last_read # Frontend при следующей загрузке запросит вокруг ЭТОГО сообщения if messages: self._auto_mark_read(chat, request.user, messages) - + return Response({ 'messages': [serialize_message(m) for m in messages], 'messages_count': len(messages), 'anchor_id': None, 'anchor_index': 0 }) - + # Определяем, это ID сообщения или timestamp anchor_msg = None - + try: around_value = int(around_id) - + # Если это большое число (> 1 миллиард) - вероятно timestamp в миллисекундах if around_value > 1_000_000_000: # Конвертируем timestamp из миллисекунд в seconds timestamp_seconds = around_value / 1000 - anchor_dt = datetime.fromtimestamp(timestamp_seconds, tz=dt_tz.utc) - + anchor_dt = datetime.fromtimestamp( + timestamp_seconds, tz=dt_tz.utc) + # Ищем ближайшее сообщение к этому timestamp (ДО или на момент) anchor_msg = queryset.filter( created_at__lte=anchor_dt ).order_by('-created_at').first() - + # Если не нашли до этого времени, берем первое после if not anchor_msg: anchor_msg = queryset.filter( created_at__gte=anchor_dt ).order_by('created_at').first() - - logger.info(f"[messages_around] Searching by timestamp: {anchor_dt}, found: {anchor_msg}") + + logger.info( + f"[messages_around] Searching by timestamp: {anchor_dt}, found: {anchor_msg}") else: # Это обычный message_id try: anchor_msg = queryset.get(pk=around_value) - logger.info(f"[messages_around] Found message by ID: {around_value}") + logger.info( + f"[messages_around] Found message by ID: {around_value}") except Message.DoesNotExist: - logger.warning(f"[messages_around] Message with ID {around_value} not found") + logger.warning( + f"[messages_around] Message with ID {around_value} not found") except (ValueError, TypeError): - logger.warning(f"[messages_around] Invalid around_id format: {around_id}") - + logger.warning( + f"[messages_around] Invalid around_id format: {around_id}") + if not anchor_msg: # Если не найдено - возвращаем последние сообщения fallback_limit = before_limit + after_limit messages = list(queryset.order_by('-created_at')[:fallback_limit]) messages.reverse() - + # Автоотметка: последнее ЗАГРУЖЕННОЕ становится last_read # Frontend при следующей загрузке запросит вокруг ЭТОГО сообщения if messages: self._auto_mark_read(chat, request.user, messages) - + return Response({ 'messages': [serialize_message(m) for m in messages], 'messages_count': len(messages), 'anchor_id': None, 'anchor_index': 0 }) - + # Асимметричная загрузка: больше контекста, меньше новых before = list(queryset.filter( created_at__lt=anchor_msg.created_at ).order_by('-created_at')[:before_limit]) before.reverse() - + after = list(queryset.filter( created_at__gte=anchor_msg.created_at ).order_by('created_at')[:after_limit + 1]) # +1 для включения anchor - + messages = before + after anchor_index = len(before) - + # Автоотметка: последнее ЗАГРУЖЕННОЕ становится last_read (24 контекста + 6 новых) # Frontend при следующей загрузке запросит вокруг последнего из ЭТИХ сообщений # НЕ отмечается последнее в чате, а последнее из того что мы загрузили! if messages: self._auto_mark_read(chat, request.user, messages) - + return Response({ 'messages': [serialize_message(m) for m in messages], 'messages_count': len(messages), @@ -429,44 +437,47 @@ class ChatViewSet(viewsets.ModelViewSet): 'has_more_before': len(before) >= before_limit, 'has_more_after': len(after) > after_limit }) - + @action(detail=True, methods=['post'], url_path='mark-read') def mark_read(self, request, pk=None): """ Ручная отметка прочитанных сообщений (опционально). - + Обычно используется автоматическая отметка при GET /messages-around/. Этот endpoint полезен для точной отметки через IntersectionObserver. - + Параметры: - message_id: ID последнего прочитанного сообщения (рекомендуется) - upto_ts: timestamp (legacy, используйте message_id) """ chat = self.get_object() - - logger.info(f"[mark_read] Manual mark by user {request.user.id} for chat {chat.id}") - + + logger.info( + f"[mark_read] Manual mark by user {request.user.id} for chat {chat.id}") + # Основная логика: поддерживаем message_id message_id = request.data.get('message_id') - + if message_id: try: - message = chat.messages.get(pk=int(message_id), is_deleted=False) - + message = chat.messages.get( + pk=int(message_id), is_deleted=False) + # Используем новую логику через _auto_mark_read self._auto_mark_read(chat, request.user, [message]) - + return Response({ 'ok': True, 'last_read_message_id': message.id }) except (Message.DoesNotExist, ValueError) as e: - logger.error(f"[mark_read] Invalid message_id {message_id}: {e}") + logger.error( + f"[mark_read] Invalid message_id {message_id}: {e}") return Response( {'error': f'Message {message_id} not found'}, status=status.HTTP_404_NOT_FOUND ) - + # Fallback: если передан upto_ts (старая логика) upto_ts = request.data.get('upto_ts') if upto_ts: @@ -475,13 +486,13 @@ class ChatViewSet(viewsets.ModelViewSet): if ts_value > 10000000000: ts_value = ts_value / 1000 timestamp = datetime.fromtimestamp(ts_value, tz=dt_tz.utc) - + # Находим последнее сообщение до этого времени last_msg = chat.messages.filter( created_at__lte=timestamp, is_deleted=False ).order_by('-created_at').first() - + if last_msg: self._auto_mark_read(chat, request.user, [last_msg]) return Response({ @@ -501,7 +512,7 @@ class ChatViewSet(viewsets.ModelViewSet): {'error': 'Invalid timestamp format'}, status=status.HTTP_400_BAD_REQUEST ) - + # Ни message_id ни upto_ts не переданы return Response( {'error': 'Either message_id or upto_ts required'}, @@ -512,11 +523,11 @@ class ChatViewSet(viewsets.ModelViewSet): class MessageViewSet(viewsets.ModelViewSet): """ ViewSet для управления сообщениями - + create: POST /api/v1/communications/messages/ - создать сообщение (с файлами) update: PUT/PATCH /api/v1/communications/messages/{id}/ - редактировать destroy: DELETE /api/v1/communications/messages/{id}/ - удалить - + Actions: - react: POST /api/v1/communications/messages/{id}/react/ - добавить реакцию - unreact: POST /api/v1/communications/messages/{id}/unreact/ - убрать реакцию @@ -524,10 +535,10 @@ class MessageViewSet(viewsets.ModelViewSet): - bulk_delete: POST /api/v1/communications/messages/bulk_delete/ - массовое удаление - upload: POST /api/v1/communications/messages/upload/ - загрузить с вложениями """ - + permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser, FormParser, JSONParser] - + def get_serializer_class(self): if self.action in ['create', 'upload']: return MessageCreateSerializer @@ -536,11 +547,11 @@ class MessageViewSet(viewsets.ModelViewSet): elif self.action == 'list': return MessageListSerializer return MessageDetailSerializer - + def get_queryset(self): """Сообщения доступные пользователю""" user = self.request.user - + # Чаты пользователя user_chats = Chat.objects.filter( Q(participants=user) | @@ -549,7 +560,7 @@ class MessageViewSet(viewsets.ModelViewSet): ).values('department')) | Q(include_all_employees=True) ) - + return Message.objects.filter( chat__in=user_chats ).select_related( @@ -557,7 +568,7 @@ class MessageViewSet(viewsets.ModelViewSet): ).prefetch_related( 'attachments', 'reactions', 'reactions__user', 'poll__options' ).order_by('-created_at') - + @action(detail=False, methods=['post']) def upload(self, request): """ @@ -566,8 +577,9 @@ class MessageViewSet(viewsets.ModelViewSet): """ chat_id = request.data.get('chat_id') content = request.data.get('content', '').strip() - reply_to_id = request.data.get('reply_to') or request.data.get('reply_to_id') - + reply_to_id = request.data.get( + 'reply_to') or request.data.get('reply_to_id') + # Валидация чата try: chat = Chat.objects.get(pk=chat_id) @@ -576,26 +588,26 @@ class MessageViewSet(viewsets.ModelViewSet): {'error': 'Chat not found'}, status=status.HTTP_404_NOT_FOUND ) - + if not user_can_access_chat(chat, request.user): return Response( {'error': 'Access denied'}, status=status.HTTP_403_FORBIDDEN ) - + # Получаем файлы files = [] for key in request.FILES: if key.startswith('file_'): files.append(request.FILES[key]) - + # Валидация: либо текст, либо файлы if not content and not files: return Response( {'error': 'Message must have either content or attachments'}, status=status.HTTP_400_BAD_REQUEST ) - + # Создаем сообщение with transaction.atomic(): message = Message.objects.create( @@ -605,7 +617,7 @@ class MessageViewSet(viewsets.ModelViewSet): reply_to_id=reply_to_id if reply_to_id else None, has_attachments=len(files) > 0 ) - + # Сохраняем файлы for file in files: MessageAttachment.objects.create( @@ -616,18 +628,18 @@ class MessageViewSet(viewsets.ModelViewSet): mime_type=file.content_type or 'application/octet-stream', file_type=self._detect_file_type(file.content_type) ) - + # Перезагружаем с prefetch message = Message.objects.select_related( 'author', 'reply_to', 'reply_to__author', 'poll' ).prefetch_related( 'attachments', 'reactions', 'reactions__user' ).get(pk=message.id) - + # Отправляем через WebSocket channel_layer = get_channel_layer() payload = serialize_message(message) - + async_to_sync(channel_layer.group_send)( f'chat_{chat.id}', { @@ -636,12 +648,12 @@ class MessageViewSet(viewsets.ModelViewSet): 'payload': payload } ) - + return Response({ 'ok': True, 'message': payload }, status=status.HTTP_201_CREATED) - + @action(detail=False, methods=['post'], url_path='upload-temp') def upload_temp(self, request): """ @@ -654,13 +666,13 @@ class MessageViewSet(viewsets.ModelViewSet): for key in request.FILES: if key.startswith('file_'): files.append(request.FILES[key]) - + if not files: return Response( {'error': 'No files provided'}, status=status.HTTP_400_BAD_REQUEST ) - + # Создаем attachments БЕЗ привязки к сообщению attachment_ids = [] with transaction.atomic(): @@ -674,97 +686,104 @@ class MessageViewSet(viewsets.ModelViewSet): file_type=self._detect_file_type(file.content_type) ) attachment_ids.append(attachment.id) - + return Response({ 'ok': True, 'attachment_ids': attachment_ids }, status=status.HTTP_201_CREATED) - + def update(self, request, *args, **kwargs): """Редактирование сообщения""" partial = kwargs.pop('partial', False) instance = self.get_object() - + # Проверка прав if instance.author != request.user: return Response( {'error': 'You can only edit your own messages'}, status=status.HTTP_403_FORBIDDEN ) - + # Проверка типа чата if instance.chat.type == 'announcement': return Response( {'error': 'Editing is not allowed in announcements'}, status=status.HTTP_403_FORBIDDEN ) - - serializer = self.get_serializer(instance, data=request.data, partial=partial) + + serializer = self.get_serializer( + instance, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) - + new_content = serializer.validated_data.get('content', '').strip() - existing_attachment_ids = serializer.validated_data.get('existing_attachment_ids') - + existing_attachment_ids = serializer.validated_data.get( + 'existing_attachment_ids') + # Валидация вложений current_attachments_count = instance.attachments.count() will_have_attachments = ( (existing_attachment_ids is not None and len(existing_attachment_ids) > 0) or (existing_attachment_ids is None and current_attachments_count > 0) ) - + if not new_content and not will_have_attachments: return Response( {'error': 'Message must have either content or attachments'}, status=status.HTTP_400_BAD_REQUEST ) - + # Сохраняем историю редактирования в отдельную таблицу from communications.models import MessageEditHistory - + MessageEditHistory.objects.create( message=instance, previous_content=instance.content, edited_by=request.user ) - + instance.content = new_content instance.is_edited = True instance.edited_at = timezone.now() - + # Управление вложениями if existing_attachment_ids is not None: from communications.models import MessageAttachment - - current_ids = set(str(att.id) for att in instance.attachments.all()) + + current_ids = set(str(att.id) + for att in instance.attachments.all()) keep_ids = set(str(id) for id in existing_attachment_ids) ids_to_remove = current_ids - keep_ids ids_to_add = keep_ids - current_ids - + # Удаляем attachments которых нет в списке if ids_to_remove: - attachments_to_delete = instance.attachments.filter(id__in=ids_to_remove) + attachments_to_delete = instance.attachments.filter( + id__in=ids_to_remove) for att in attachments_to_delete: if att.file and att.file.storage.exists(att.file.name): att.file.delete(save=False) attachments_to_delete.delete() logger.info(f"[update] Removed attachments: {ids_to_remove}") - + # Переносим новые attachments из других сообщений к этому if ids_to_add: - attachments_to_move = MessageAttachment.objects.filter(id__in=ids_to_add) + attachments_to_move = MessageAttachment.objects.filter( + id__in=ids_to_add) updated_count = attachments_to_move.update(message=instance) - logger.info(f"[update] Moved {updated_count} attachments to message {instance.id}: {list(ids_to_add)}") - + logger.info( + f"[update] Moved {updated_count} attachments to message {instance.id}: {list(ids_to_add)}") + instance.save() - + # ВАЖНО: Очищаем кэш attachments перед подсчетом instance.refresh_from_db() - + # Обновляем has_attachments (заново считаем из БД) from communications.models import MessageAttachment - instance.has_attachments = MessageAttachment.objects.filter(message=instance).count() > 0 + instance.has_attachments = MessageAttachment.objects.filter( + message=instance).count() > 0 instance.save(update_fields=['has_attachments']) - + # Перезагружаем с нуля чтобы получить обновленные attachments # Используем новый запрос, чтобы избежать кэширования instance = Message.objects.select_related( @@ -772,13 +791,13 @@ class MessageViewSet(viewsets.ModelViewSet): ).prefetch_related( 'attachments', 'reactions', 'reactions__user', 'poll__options' ).get(pk=instance.id) - + # WebSocket уведомление channel_layer = get_channel_layer() payload = serialize_message(instance) - + group_name = f'chat_{instance.chat_id}' - + async_to_sync(channel_layer.group_send)( group_name, { @@ -787,24 +806,24 @@ class MessageViewSet(viewsets.ModelViewSet): 'payload': payload } ) - + return Response(payload) - + def destroy(self, request, *args, **kwargs): """Мягкое удаление сообщения""" instance = self.get_object() - + if instance.author != request.user: return Response( {'error': 'You can only delete your own messages'}, status=status.HTTP_403_FORBIDDEN ) - + instance.is_deleted = True instance.deleted_at = timezone.now() instance.deleted_by = request.user instance.save() - + # WebSocket уведомление channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( @@ -815,28 +834,29 @@ class MessageViewSet(viewsets.ModelViewSet): 'message_id': instance.id } ) - + return Response({'ok': True}, status=status.HTTP_204_NO_CONTENT) - + @action(detail=True, methods=['post']) def react(self, request, pk=None): """Добавить реакцию""" message = self.get_object() - logger.info(f"[react] User {request.user.id} adding reaction to message {message.id}") - + logger.info( + f"[react] User {request.user.id} adding reaction to message {message.id}") + serializer = ReactionSerializer(data=request.data) serializer.is_valid(raise_exception=True) - + emoji = serializer.validated_data['emoji'] logger.info(f"[react] Emoji: {emoji}") - + # Создаем или получаем реакцию (по message + user, т.к. unique_together) reaction, created = MessageReaction.objects.get_or_create( message=message, user=request.user, defaults={'emoji': emoji} ) - + # Если реакция уже существует, обновляем emoji if not created: old_emoji = reaction.emoji @@ -850,7 +870,7 @@ class MessageViewSet(viewsets.ModelViewSet): logger.info(f"[react] Updated emoji from {old_emoji} to {emoji}") else: logger.info(f"[react] Created new reaction, id={reaction.id}") - + # Пересчитываем reactions_summary reactions_summary = {} for r in message.reactions.select_related('user'): @@ -862,14 +882,15 @@ class MessageViewSet(viewsets.ModelViewSet): } reactions_summary[r.emoji]['count'] += 1 reactions_summary[r.emoji]['users'].append(r.user_id) - reactions_summary[r.emoji]['user_names'].append(r.user.get_full_name()) - + reactions_summary[r.emoji]['user_names'].append( + r.user.get_full_name()) + logger.info(f"[react] reactions_summary: {reactions_summary}") - + # WebSocket уведомление channel_layer = get_channel_layer() logger.info(f"[react] Sending to chat_{message.chat_id} group") - + async_to_sync(channel_layer.group_send)( f'chat_{message.chat_id}', { @@ -881,37 +902,38 @@ class MessageViewSet(viewsets.ModelViewSet): 'reactions_summary': reactions_summary } ) - + logger.info(f"[react] WebSocket message sent successfully") - + return Response({'ok': True, 'reactions_summary': reactions_summary}) - + @action(detail=True, methods=['post']) def unreact(self, request, pk=None): """Убрать реакцию""" message = self.get_object() - logger.info(f"[unreact] User {request.user.id} removing reaction from message {message.id}") - + logger.info( + f"[unreact] User {request.user.id} removing reaction from message {message.id}") + serializer = ReactionSerializer(data=request.data) serializer.is_valid(raise_exception=True) - + emoji = serializer.validated_data['emoji'] logger.info(f"[unreact] Emoji: {emoji}") - + deleted_count = MessageReaction.objects.filter( message=message, user=request.user, emoji=emoji ).delete()[0] - + logger.info(f"[unreact] Deleted count: {deleted_count}") - + if deleted_count == 0: return Response( {'error': 'Reaction not found'}, status=status.HTTP_404_NOT_FOUND ) - + # Пересчитываем reactions_summary reactions_summary = {} for r in message.reactions.select_related('user'): @@ -923,14 +945,15 @@ class MessageViewSet(viewsets.ModelViewSet): } reactions_summary[r.emoji]['count'] += 1 reactions_summary[r.emoji]['users'].append(r.user_id) - reactions_summary[r.emoji]['user_names'].append(r.user.get_full_name()) - + reactions_summary[r.emoji]['user_names'].append( + r.user.get_full_name()) + logger.info(f"[unreact] reactions_summary: {reactions_summary}") - + # WebSocket уведомление channel_layer = get_channel_layer() logger.info(f"[unreact] Sending to chat_{message.chat_id} group") - + async_to_sync(channel_layer.group_send)( f'chat_{message.chat_id}', { @@ -942,20 +965,20 @@ class MessageViewSet(viewsets.ModelViewSet): 'reactions_summary': reactions_summary } ) - + logger.info(f"[unreact] WebSocket message sent successfully") - + return Response({'ok': True, 'reactions_summary': reactions_summary}) - + @action(detail=False, methods=['post']) def forward(self, request): """Переслать сообщения в другой чат""" serializer = ForwardMessageSerializer(data=request.data) serializer.is_valid(raise_exception=True) - + message_ids = serializer.validated_data['message_ids'] target_chat_id = serializer.validated_data['target_chat_id'] - + # Получаем целевой чат try: target_chat = Chat.objects.get(pk=target_chat_id) @@ -964,20 +987,20 @@ class MessageViewSet(viewsets.ModelViewSet): {'error': 'Target chat not found'}, status=status.HTTP_404_NOT_FOUND ) - + if not user_can_access_chat(target_chat, request.user): return Response( {'error': 'Access denied to target chat'}, status=status.HTTP_403_FORBIDDEN ) - + # Пересылаем сообщения forwarded_ids = [] with transaction.atomic(): messages = Message.objects.filter( id__in=message_ids ).select_related('author') - + for original_msg in messages: # Создаём пересланное сообщение forwarded = Message.objects.create( @@ -986,7 +1009,7 @@ class MessageViewSet(viewsets.ModelViewSet): content=original_msg.content, is_forwarded=True ) - + # Создаём метаданные пересылки from communications.models import MessageForwardMetadata MessageForwardMetadata.objects.create( @@ -999,23 +1022,23 @@ class MessageViewSet(viewsets.ModelViewSet): forwarded_by=request.user, forward_count=1 ) - + forwarded_ids.append(forwarded.id) - + return Response({ 'ok': True, 'forwarded_count': len(forwarded_ids), 'forwarded_ids': forwarded_ids }) - + @action(detail=False, methods=['post'], url_path='bulk-delete') def bulk_delete(self, request): """Массовое удаление сообщений""" serializer = BulkDeleteSerializer(data=request.data) serializer.is_valid(raise_exception=True) - + message_ids = serializer.validated_data['message_ids'] - + # Удаляем только свои сообщения deleted_count = Message.objects.filter( id__in=message_ids, @@ -1025,17 +1048,17 @@ class MessageViewSet(viewsets.ModelViewSet): deleted_at=timezone.now(), deleted_by=request.user ) - + return Response({ 'ok': True, 'deleted_count': deleted_count }) - + def _detect_file_type(self, mime_type): """Определение типа файла по MIME""" if not mime_type: return 'file' - + if mime_type.startswith('image/'): return 'image' elif mime_type.startswith('video/'): @@ -1053,42 +1076,42 @@ class MessageViewSet(viewsets.ModelViewSet): class PollViewSet(viewsets.ModelViewSet): """ ViewSet для управления голосованиями - + create: POST /api/v1/communications/polls/ - создать голосование retrieve: GET /api/v1/communications/polls/{id}/ - детали голосования - + Actions: - vote: POST /api/v1/communications/polls/{id}/vote/ - проголосовать - close: POST /api/v1/communications/polls/{id}/close/ - закрыть голосование - results: GET /api/v1/communications/polls/{id}/results/ - результаты """ - + queryset = Poll.objects.all() serializer_class = PollSerializer permission_classes = [IsAuthenticated] - + def perform_create(self, serializer): """Автоматически устанавливаем автора голосования""" serializer.save(author=self.request.user) - + @action(detail=True, methods=['post']) def vote(self, request, pk=None): """Проголосовать""" poll = self.get_object() - + if poll.is_closed: return Response( {'error': 'Poll is closed'}, status=status.HTTP_400_BAD_REQUEST ) - + option_ids = request.data.get('option_ids', []) if not option_ids: return Response( {'error': 'No options selected'}, status=status.HTTP_400_BAD_REQUEST ) - + # Проверка что опции существуют и принадлежат этому poll try: options = poll.options.filter(pk__in=option_ids) @@ -1102,21 +1125,21 @@ class PollViewSet(viewsets.ModelViewSet): {'error': 'Invalid option IDs format'}, status=status.HTTP_400_BAD_REQUEST ) - + # Проверка множественного выбора if not poll.is_multiple_choice and len(option_ids) > 1: return Response( {'error': 'Only one option allowed'}, status=status.HTTP_400_BAD_REQUEST ) - + with transaction.atomic(): # Удаляем ВСЕ старые голоса пользователя (и для single и для multiple choice) PollVote.objects.filter( poll=poll, voter=request.user ).delete() - + # Добавляем новые голоса for option in options: PollVote.objects.create( @@ -1124,15 +1147,16 @@ class PollViewSet(viewsets.ModelViewSet): option=option, voter=request.user ) - + # Пересчитываем голоса for option in poll.options.all(): option.vote_count = option.votes.count() option.save() - - poll.total_voters = PollVote.objects.filter(poll=poll).values('voter').distinct().count() + + poll.total_voters = PollVote.objects.filter( + poll=poll).values('voter').distinct().count() poll.save() - + # WebSocket уведомление channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( @@ -1147,28 +1171,28 @@ class PollViewSet(viewsets.ModelViewSet): } } ) - + # Возвращаем полные результаты results_serializer = PollSerializer(poll, context={'request': request}) return Response({'ok': True, 'results': results_serializer.data}) - + @action(detail=True, methods=['post']) def close(self, request, pk=None): """Закрыть голосование""" poll = self.get_object() - + # Проверка прав (только автор сообщения) if poll.message.author != request.user: return Response( {'error': 'Only poll author can close it'}, status=status.HTTP_403_FORBIDDEN ) - + # Используем метод модели для установки is_closed и closed_at poll.close() - + return Response(PollSerializer(poll, context={'request': request}).data) - + @action(detail=True, methods=['get', 'post']) def results(self, request, pk=None): """Получить результаты голосования""" diff --git a/backend/schedule_patch.py b/backend/schedule_patch.py index 776f06e1..afac29db 100644 --- a/backend/schedule_patch.py +++ b/backend/schedule_patch.py @@ -70,8 +70,18 @@ def create_patched_method(): return patched_event_params +# Флаг для предотвращения повторного применения патча +_patch_applied = False + + def apply_patch(): """Применяет патч к django-scheduler Event модели.""" + global _patch_applied + + # Если патч уже применён, пропускаем + if _patch_applied: + return True + try: # Ленивый импорт - только при вызове apply_patch() from schedule.models import Event @@ -86,6 +96,7 @@ def apply_patch(): print(f" Патч применён: Event._event_params = {patched_method.__name__}") print("=" * 80) + _patch_applied = True return True except Exception as e: print("=" * 80) diff --git a/docs/diagnostic/WEBSOCKET_FIX.md b/docs/diagnostic/WEBSOCKET_FIX.md new file mode 100644 index 00000000..452293f7 --- /dev/null +++ b/docs/diagnostic/WEBSOCKET_FIX.md @@ -0,0 +1,195 @@ +# 🔧 Исправление WebSocket ошибки: "❌ WebSocket error: {}" + +## Проблема + +При открытии чата в браузере видна ошибка: +``` +❌ WebSocket error: {} +``` + +## Причины + +WebSocket ошибка `{}` обычно связана с одной из этих проблем: + +| Причина | Решение | +|---------|---------| +| **Backend запущен через `runserver`** | Используйте **Daphne** для WebSocket | +| **PORT 9000 занят** | Проверьте процессы или используйте другой порт | +| **Неправильный URL в .env** | Убедитесь что используется `.env.local` | +| **JWT токен не сохранен** | Выполните вход в систему | +| **Frontend и Backend на разных версиях** | Синхронизируйте код | + +## ✅ Пошаговое решение + +### Шаг 1: Остановите текущий backend +```bash +# Если backend запущен, нажмите Ctrl+C в терминале +``` + +### Шаг 2: Запустите backend через Daphne + +```bash +cd backend +.venv/Scripts/daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application +``` + +**Ожидаемый вывод:** +``` +Starting server process [PID] +HTTP/2 support not enabled +Uvicorn running on http://0.0.0.0:9000 +``` + +### Шаг 3: Убедитесь что .env.local правильный + +Проверьте файл `frontend/.env.local`: +```dotenv +# ✅ ПРАВИЛЬНО (для локальной разработки) +NEXT_PUBLIC_WS_URL=ws://localhost:9000 +NEXT_PUBLIC_BACKEND_URL=http://localhost:9000 +``` + +**НЕ используйте:** +```dotenv +# ❌ НЕПРАВИЛЬНО (production URL) +NEXT_PUBLIC_WS_URL=wss://corp.robotail.pro +``` + +### Шаг 4: Перезагрузите фронтенд + +```bash +cd frontend +npm run dev +``` + +Перезагрузите браузер: `Ctrl+R` или `F5` + +### Шаг 5: Проверьте WebSocket подключение + +Откройте **DevTools** (F12) → **Console** окно и ищите: + +**✅ Если видите:** +``` +🔄 Подключение к WebSocket: ws://localhost:9000/ws/?token=... +✅ WebSocket connected! +``` +→ **Всё работает!** + +**❌ Если видите ошибку:** +``` +❌ WebSocket error: {} +``` +→ Продолжайте с Шага 6 + +### Шаг 6: Проверьте порт 9000 + +Проверьте что процесс слушает на порту 9000: + +**Windows - PowerShell (администратор):** +```powershell +# Проверить какие процессы используют порт 9000 +netstat -ano | findstr :9000 + +# Пример вывода: +# TCP 0.0.0.0:9000 0.0.0.0:0 LISTENING 28904 + +# Если что-то там слушает, проверьте что это Daphne +tasklist | findstr 28904 +``` + +Если порт занят другим процессом: +```bash +# Убить процесс в PowerShell +taskkill /PID 28904 /F + +# Или используйте другой порт +.venv/Scripts/daphne -b 0.0.0.0 -p 9001 eusrr_backend.asgi:application + +# И обновите .env.local +NEXT_PUBLIC_WS_URL=ws://localhost:9001 +``` + +### Шаг 7: Проверьте Network в DevTools + +1. Откройте **DevTools** → **Network** вкладка +2. В фильтре напишите: `ws` +3. Откройте чат и ищите подключение `ws://localhost:9000/ws/` + +**✅ Если видите:** +``` +ws Status: 101 Switching Protocols [WebSocket connected] +``` + +**❌ Если видите:** +``` +ws Status: Failed [или ConnectError] +``` +→ Backend неправильно запущен + +## 🔄 Полный скрипт для переустановки + +```bash +# 1. Перейдите в backend +cd backend + +# 2. Установите dependencies если нужно +.venv/Scripts/pip install -r requirements.txt + +# 3. Запустите Daphne +.venv/Scripts/daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application + +# В другом терминале: + +# 4. Перейдите в frontend +cd frontend + +# 5. Установите если нужно +npm install + +# 6. Запустите dev server +npm run dev + +# 7. Откройте браузер на http://localhost:5173 +``` + +## 📋 Чек-лист для диагностики + +- [ ] Backend запущен через **Daphne** (не `runserver`) +- [ ] PORT **9000** свободен и доступен +- [ ] `.env.local` использует `ws://localhost:9000` +- [ ] JWT токен сохранен в localStorage (пользователь залогирован) +- [ ] Frontend запущен на `http://localhost:5173` +- [ ] В DevTools → Console видно `🔄 Подключение к WebSocket` +- [ ] В DevTools → Network видно WebSocket с статусом **101** + +## 🚨 Если ничего не помогает + +1. **Очистите cache браузера** + - F12 → Application → Clear Site Data → ✓ Cookies, ✓ Cache + +2. **Удалите node_modules и переустановите** + ```bash + cd frontend + rm -r node_modules package-lock.json + npm install + npm run dev + ``` + +3. **Проверьте logs в backend** + - В терминале где запущен Daphne должны быть logs подключений + - Если их нет - frontend не отправляет запрос + +4. **Обновите код из Git** + ```bash + git pull origin main + npm install + .venv/Scripts/pip install -r requirements.txt + ``` + +## 📞 Если проблема не решена + +Создайте issue с информацией: +- Output из DevTools Console +- Output из Daphne терминала +- Содержимое `.env.local` +- Результат: `netstat -ano | findstr :9000` (Windows) diff --git a/docs/in-progress/WEBSOCKET_ERROR_FIX.md b/docs/in-progress/WEBSOCKET_ERROR_FIX.md new file mode 100644 index 00000000..d6390a6a --- /dev/null +++ b/docs/in-progress/WEBSOCKET_ERROR_FIX.md @@ -0,0 +1,87 @@ +# 📝 Исправление WebSocket ошибки - Резюме + +## 🔧 Что было исправлено + +### 1. **Файл: `frontend/src/hooks/useWebSocket.ts`** + +**Проблема:** +- Код неправильно обрабатывал переменную окружения `NEXT_PUBLIC_WS_URL` +- Если URL содержал протокол (`ws://localhost:9000`), код добавлял протокол снова → `ws://ws://localhost:9000/ws/` + +**Решение:** +- Добавлена проверка: если URL уже содержит протокол - используем его как есть +- Если URL это только хост - добавляем протокол +- Добавлено логирование: `console.log('🔄 Подключение к WebSocket:', wsUrl)` + +**Измененная логика:** +```typescript +// ✅ ДО (НЕПРАВИЛЬНО): +const host = process.env.NEXT_PUBLIC_WS_URL || 'localhost:9000'; +const wsUrl = `${protocol}//${host}/ws/?token=${token}`; +// Результат: ws://ws://localhost:9000/ws/?token=... ❌ + +// ✅ ПОСЛЕ (ПРАВИЛЬНО): +if (envWsUrl.startsWith('ws://') || envWsUrl.startsWith('wss://')) { + wsUrl = `${envWsUrl}/ws/?token=${token}`; +} +// Результат: ws://localhost:9000/ws/?token=... ✅ +``` + +### 2. **Файл: `frontend/QUICKSTART.md`** + +**Изменения:** +- ✅ Добавлена демонстрация правильного запуска Daphne +- ✅ Указано что **НЕ нужно** использовать `runserver` для WebSocket +- ✅ Добавлена секция "Проверка WebSocket подключения" с подробной диагностикой +- ✅ Расширена секция "Проблемы?" с решением для `WebSocket error: {}` +- ✅ Добавлены инструкции по проверке портов в Windows + +### 3. **Файл: `docs/diagnostic/WEBSOCKET_FIX.md`** (новый) + +- 📋 Полная диагностика проблемы с WebSocket +- 🔍 Шаг-за-шагом решение проблемы +- 🛠️ Скрипты для проверки портов и процессов +- ✅ Чек-лист для быстрой проверки + +## 🚀 Что делать дальше + +### 1. Убедитесь что используется `.env.local` (не `.env`) + +```bash +# frontend/.env.local (для локальной разработки) +NEXT_PUBLIC_WS_URL=ws://localhost:9000 +NEXT_PUBLIC_BACKEND_URL=http://localhost:9000 +``` + +### 2. Перезапустите backend с Daphne + +```bash +cd backend +.venv/Scripts/daphne -b 0.0.0.0 -p 9000 eusrr_backend.asgi:application +``` + +### 3. Перезагрузите frontend + +```bash +cd frontend +npm run dev +``` + +### 4. Проверьте в DevTools + +- F12 → Console → убедитесь что видите `🔄 Подключение к WebSocket: ws://localhost:9000/...` +- F12 → Network → WS → должен быть статус **101 Switching Protocols** ✅ + +## 📌 Ключевые моменты + +| Что | ❌ НЕПРАВИЛЬНО | ✅ ПРАВИЛЬНО | +|-----|---|---| +| Запуск backend | `python manage.py runserver` | `.venv/Scripts/daphne -p 9000 eusrr_backend.asgi:application` | +| .env файл | `.env` (production) | `.env.local` (development) | +| WebSocket URL | `wss://corp.robotail.pro` | `ws://localhost:9000` | +| Логирование | Нет | `🔄 Подключение к WebSocket: ...` | + +## 🔗 Дополнительно + +- 📚 Полная диагностика: [docs/diagnostic/WEBSOCKET_FIX.md](../../docs/diagnostic/WEBSOCKET_FIX.md) +- 📖 Quickstart: [frontend/QUICKSTART.md](../QUICKSTART.md) diff --git a/frontend/.env.example b/frontend/.env.example index df3d5929..02105451 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,6 +1,12 @@ -# API Configuration -VITE_API_URL=http://localhost:8000/api/v2 -VITE_WS_URL=ws://localhost:8000/ws +# Next.js Environment Variables Example +# Copy this file to .env.local for local development +# Copy this file to .env for production -# Development server port (optional, Vite uses 5173 by default) -# PORT=5173 +# Backend API URL +NEXT_PUBLIC_BACKEND_URL=http://localhost:9000 + +# API URL (optional, defaults to BACKEND_URL/api/v1) +NEXT_PUBLIC_API_URL=http://localhost:9000/api/v1 + +# WebSocket URL (БЕЗ пути /ws/, он добавляется автоматически в коде) +NEXT_PUBLIC_WS_URL=ws://localhost:9000 diff --git a/frontend/API_V2_COMPLETE.md b/frontend/API_V2_COMPLETE.md deleted file mode 100644 index c6b4f0a2..00000000 --- a/frontend/API_V2_COMPLETE.md +++ /dev/null @@ -1,256 +0,0 @@ -# ✅ API v2 + Frontend Integration Complete! - -## 🎉 Что сделано - -### ✅ Backend - API v2 создан - -**Структура:** -``` -backend/api/v2/ -├── auth/ ✅ register, verify-email, resend-email -├── calendar/ ✅ events, calendars, subscriptions -├── communications/ ✅ chats, messages, polls -├── documents/ ✅ documents CRUD -├── employees/ ✅ employees, departments, positions, skills, groups -├── feed/ ✅ posts, comments -└── requests/ ✅ requests (заявки) -``` - -**Все endpoints из v1 портированы в v2** через наследование ViewSet'ов. - -### ✅ Frontend обновлен для v2 - -- `.env` → использует `http://localhost:8000/api/v2` -- `constants.ts` → default на v2 -- Все запросы идут в v2 API - -### ✅ Документация создана - -- [API_V2_MIGRATION.md](../../docs/guides/API_V2_MIGRATION.md) - полное описание v2 -- [QUICKSTART.md](QUICKSTART.md) - обновлен для v2 -- [DJANGO_CORS_SETUP.md](../../docs/guides/DJANGO_CORS_SETUP.md) - настройка CORS - ---- - -## 📋 Полный список endpoints v2 - -### Auth (общие для v1/v2) -``` -POST /api/auth/token/ # Login (JWT) -POST /api/auth/token/refresh/ # Refresh token -POST /api/v2/auth/register/ # Регистрация -POST /api/v2/auth/verify-email/ # Подтверждение email -POST /api/v2/auth/resend-email/ # Повтор отправки -``` - -### Employees -``` -GET /api/v2/employees/ # Список -POST /api/v2/employees/ # Создать -GET /api/v2/employees/{id}/ # Получить -PATCH /api/v2/employees/{id}/ # Обновить -DELETE /api/v2/employees/{id}/ # Удалить - -GET /api/v2/departments/ -GET /api/v2/positions/ -GET /api/v2/skills/ -GET /api/v2/groups/ -GET /api/v2/employee-actions/ -GET /api/v2/department-roles/ -``` - -### Documents -``` -GET /api/v2/documents/ -POST /api/v2/documents/ -GET /api/v2/documents/{id}/ -PATCH /api/v2/documents/{id}/ -DELETE /api/v2/documents/{id}/ -``` - -### Requests (Заявки) -``` -GET /api/v2/requests/ -POST /api/v2/requests/ -GET /api/v2/requests/{id}/ -PATCH /api/v2/requests/{id}/ -DELETE /api/v2/requests/{id}/ -``` - -### Calendar -``` -GET /api/v2/calendar/events/ -POST /api/v2/calendar/events/ -GET /api/v2/calendar/calendars/ -GET /api/v2/calendar/subscriptions/ -``` - -### Communications (Чаты) -``` -GET /api/v2/chats/ -POST /api/v2/chats/ -GET /api/v2/chats/{id}/ -GET /api/v2/messages/ -POST /api/v2/messages/ -GET /api/v2/polls/ -``` - -### Feed (Лента) -``` -GET /api/v2/posts/ -POST /api/v2/posts/ -GET /api/v2/posts/{id}/ -GET /api/v2/comments/ -POST /api/v2/comments/ -``` - -### Notifications -``` -GET /api/v2/notifications/ (использует v1 internally) -``` - ---- - -## 🔄 v1 vs v2 - -| Характеристика | v1 | v2 | -|----------------|----|----| -| **Endpoints** | Все | Все (идентичные) | -| **LDAP Sync** | Синхронный (Mixin) | Асинхронный (Signals) | -| **Response Time** | ~200-300ms | ~30-50ms | -| **Блокировка** | Да | Нет | -| **Retry логика** | Ручная | Celery автоматически | -| **Статус** | ✅ Работает | ✅ Работает | - -**Оба API полностью функциональны!** - ---- - -## 🚀 Быстрый запуск - -### 1. Настроить CORS (если еще не сделано) - -```bash -cd backend -.venv/Scripts/pip install django-cors-headers -``` - -Добавить в `settings.py`: -```python -INSTALLED_APPS = ["corsheaders", ...] -MIDDLEWARE = ["corsheaders.middleware.CorsMiddleware", ...] -CORS_ALLOWED_ORIGINS = ["http://localhost:5173"] -``` - -**Подробнее**: [Django CORS Setup](../../docs/guides/DJANGO_CORS_SETUP.md) - -### 2. Запустить Backend - -```bash -cd backend -.venv/Scripts/python manage.py runserver 8000 -``` - -### 3. Запустить Frontend - -```bash -cd frontend -npm install # первый раз -npm run dev -``` - -**Открыть**: http://localhost:5173 - ---- - -## ✅ Проверка работы - -### Test 1: API v2 доступен - -```bash -curl http://localhost:8000/api/v2/employees/ -``` - -Должен вернуть JSON. - -### Test 2: v1 всё ещё работает - -```bash -curl http://localhost:8000/api/v1/employees/ -``` - -Оба работают параллельно! - -### Test 3: Frontend - -Открыть http://localhost:5173 → должна загрузиться страница сотрудников. - ---- - -## 📊 Статус проекта - -| Модуль | Backend v2 | Frontend | Status | -|--------|------------|----------|--------| -| Auth | ✅ | ✅ | **Готово** | -| Employees | ✅ | ✅ List | В разработке | -| Departments | ✅ | ⏳ | Backend готов | -| Documents | ✅ | ⏳ | Backend готов | -| Requests | ✅ | ⏳ | Backend готов | -| Calendar | ✅ | ⏳ | Backend готов | -| Chats | ✅ | ⏳ | Backend готов | -| Feed | ✅ | ⏳ | Backend готов | - ---- - -## 🎯 Следующие задачи - -### Фаза 1: Основной функционал -- [ ] Добавить endpoint `/api/v2/employees/me/` (текущий пользователь) -- [ ] Создать CRUD страницы для documents -- [ ] Создать CRUD страницы для requests -- [ ] Добавить UI библиотеку (Ant Design) - -### Фаза 2: Продвинутый функционал -- [ ] Интегрировать WebSocket для чатов -- [ ] Добавить real-time уведомления -- [ ] Календарь с FullCalendar -- [ ] File uploads - -### Фаза 3: Полировка -- [ ] Мобильная адаптация -- [ ] Темная тема -- [ ] Тесты (Frontend + Backend) -- [ ] Production build - ---- - -## 🛠️ Переключение между v1 и v2 - -В любой момент можно переключиться: - -**На v2 (по умолчанию):** -```bash -# frontend/.env -VITE_API_URL=http://localhost:8000/api/v2 -``` - -**На v1:** -```bash -# frontend/.env -VITE_API_URL=http://localhost:8000/api/v1 -``` - ---- - -## 📚 Документация - -- [API v2 Migration Guide](../../docs/guides/API_V2_MIGRATION.md) -- [Frontend Quickstart](QUICKSTART.md) -- [Django CORS Setup](../../docs/guides/DJANGO_CORS_SETUP.md) -- [API v2 README](../../backend/api/v2/README.md) - ---- - -**Всё готово для разработки на API v2!** 🚀 - -Frontend настроен, Backend готов, осталось только добавить CORS и можно работать! diff --git a/frontend/INTEGRATION_GUIDE.md b/frontend/INTEGRATION_GUIDE.md deleted file mode 100644 index c6f45af9..00000000 --- a/frontend/INTEGRATION_GUIDE.md +++ /dev/null @@ -1,410 +0,0 @@ -# Frontend Integration Guide - -## Обзор - -Фронтенд corp.front полностью интегрирован с Django Backend через REST API. Используется Next.js 16 с TypeScript и Tailwind CSS. - -## Структура проекта - -``` -corp.front/ -├── src/ -│ ├── app/ # Next.js страницы -│ │ ├── page.tsx # Главная (Лента) ✅ Интегрирован -│ │ ├── employees/ # Сотрудники ✅ Интегрирован -│ │ ├── departments/ # Отделы -│ │ ├── documents/ # Документы -│ │ ├── messages/ # Сообщения -│ │ ├── requests/ # Заявки -│ │ ├── finances/ # Финансы -│ │ └── login/ # Вход -│ ├── lib/ -│ │ └── api.ts # API клиент ✅ -│ ├── types/ -│ │ └── api.ts # TypeScript типы ✅ -│ └── hooks/ -│ └── useApi.ts # React хуки для API ✅ -``` - -## API клиент - -### Базовая конфигурация - -API клиент автоматически: -- Добавляет JWT токен к запросам -- Обрабатывает ошибки авторизации -- Сохраняет токены в localStorage -- Перенаправляет на /login при 401 - -### Использование - -```typescript -import apiClient from '@/lib/api'; - -// Авторизация -const { access, refresh, user } = await apiClient.login({ - username: 'user@example.com', - password: 'password' -}); - -// Получение данных -const employees = await apiClient.getEmployees(); -const posts = await apiClient.getPosts(); -``` - -## React хуки - -Используйте готовые хуки из `@/hooks/useApi`: - -```tsx -import { useCurrentUser, useEmployees, usePosts } from '@/hooks/useApi'; - -function MyComponent() { - const { user, loading, error } = useCurrentUser(); - const { employees } = useEmployees({ search: 'John' }); - const { posts } = usePosts({ page: 1 }); - - if (loading) return
{error.message}
- -