From b7e20c89564fb27691d88497b21824a1e1dda13d Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 14 Aug 2026 23:44:28 +0300 Subject: [PATCH] feat: stream calendar events over websocket --- .../docs/architecture/websocket-asyncapi.yaml | 140 ++++++++++- backend/realtime/consumers.py | 146 +++++++++++- backend/scheduling/realtime.py | 224 ++++++++++++++++++ .../tests/realtime/test_calendar_updates.py | 159 ++++++++++++- docs/guides/WEBSOCKET.md | 11 +- frontend/src/components/BigCalendar.tsx | 69 ++---- frontend/src/contexts/CalendarContext.tsx | 9 +- .../src/hooks/calendar/useCalendarEvents.ts | 102 ++++---- frontend/src/lib/push.ts | 4 + frontend/src/services/calendarService.test.js | 122 ++++++++++ frontend/src/services/calendarService.ts | 155 ++++++++++++ 11 files changed, 1012 insertions(+), 129 deletions(-) diff --git a/backend/docs/architecture/websocket-asyncapi.yaml b/backend/docs/architecture/websocket-asyncapi.yaml index 74222c0c..3cc6e0d6 100644 --- a/backend/docs/architecture/websocket-asyncapi.yaml +++ b/backend/docs/architecture/websocket-asyncapi.yaml @@ -1,7 +1,7 @@ asyncapi: 2.6.0 info: title: EUSRR Realtime WebSocket API - version: 1.1.0 + version: 1.2.0 description: | Отдельная AsyncAPI-спецификация для WebSocket-контура EUSRR. @@ -32,7 +32,7 @@ channels: - typing indicators - реакции и голосования - procurement updates - - календарные invalidation-события + - подписки на диапазоны календаря и calendar snapshots publish: operationId: clientAction summary: Команды от клиента к серверу @@ -49,6 +49,8 @@ channels: - $ref: '#/components/messages/StopTypingAction' - $ref: '#/components/messages/VotePollAction' - $ref: '#/components/messages/MarkReadAction' + - $ref: '#/components/messages/CalendarSubscribeAction' + - $ref: '#/components/messages/CalendarUnsubscribeAction' subscribe: operationId: serverEvent summary: События от сервера клиенту @@ -76,6 +78,8 @@ channels: - $ref: '#/components/messages/ProcurementUpdateEvent' - $ref: '#/components/messages/TaskBoardUpdateEvent' - $ref: '#/components/messages/CalendarUpdateEvent' + - $ref: '#/components/messages/CalendarSnapshotEvent' + - $ref: '#/components/messages/CalendarErrorEvent' components: schemas: ChatId: @@ -134,6 +138,44 @@ components: - verb - unread additionalProperties: true + CalendarEventPayload: + type: object + required: [id, title, start, end, calendar, is_recurring] + properties: + id: + oneOf: + - type: integer + - type: string + title: + type: string + description: + type: string + start: + type: string + format: date-time + end: + type: string + format: date-time + calendar: + type: integer + minimum: 1 + event_id: + type: integer + minimum: 1 + rule: + type: integer + minimum: 1 + nullable: true + color_event: + type: string + nullable: true + can_edit: + type: boolean + can_delete: + type: boolean + is_recurring: + type: boolean + additionalProperties: true messages: OpenChatAction: name: OpenChatAction @@ -283,6 +325,48 @@ components: note: type: string example: Deprecated, используйте HTTP mark-read endpoint + CalendarSubscribeAction: + name: CalendarSubscribeAction + title: Подписаться на диапазон календаря + payload: + type: object + required: [action, subscription_id, start, end] + properties: + action: + type: string + enum: [calendar_subscribe] + subscription_id: + type: string + minLength: 1 + maxLength: 80 + start: + type: string + format: date-time + end: + type: string + format: date-time + calendar_id: + type: integer + minimum: 1 + nullable: true + scope: + type: string + enum: [accessible, all] + default: accessible + CalendarUnsubscribeAction: + name: CalendarUnsubscribeAction + title: Отписаться от диапазона календаря + payload: + type: object + required: [action, subscription_id] + properties: + action: + type: string + enum: [calendar_unsubscribe] + subscription_id: + type: string + minLength: 1 + maxLength: 80 PingEvent: name: PingEvent payload: @@ -632,3 +716,55 @@ components: nullable: true is_recurring: type: boolean + CalendarSnapshotEvent: + name: CalendarSnapshotEvent + payload: + type: object + required: [type, reason, data] + properties: + type: + type: string + enum: [calendar_snapshot] + reason: + type: string + enum: [subscribed, updated] + data: + type: object + required: [subscription_id, start, end, scope, events] + properties: + subscription_id: + type: string + start: + type: string + format: date-time + end: + type: string + format: date-time + calendar_id: + type: integer + minimum: 1 + nullable: true + scope: + type: string + enum: [accessible, all] + events: + type: array + items: + $ref: '#/components/schemas/CalendarEventPayload' + CalendarErrorEvent: + name: CalendarErrorEvent + payload: + type: object + required: [type, code, detail] + properties: + type: + type: string + enum: [calendar_error] + subscription_id: + type: string + nullable: true + code: + type: string + enum: [invalid_subscription, subscription_limit, snapshot_failed] + detail: + type: string diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py index 70c77f1a..646599bf 100644 --- a/backend/realtime/consumers.py +++ b/backend/realtime/consumers.py @@ -21,6 +21,12 @@ from django.utils import timezone from communications.consumers import ChatConsumerMixin from communications.services import ChatNotificationReadService +from scheduling.realtime import ( + MAX_CALENDAR_SUBSCRIPTIONS, + CalendarSubscriptionError, + build_calendar_snapshot, + parse_calendar_subscription, +) logger = logging.getLogger(__name__) @@ -45,6 +51,9 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): self.subscribed_chats = set() # Чаты, на которые подписан self.user = None self.chat_notification_read_service = ChatNotificationReadService() + self.calendar_subscriptions = {} + self.calendar_refresh_task = None + self.pending_calendar_refresh_ids = set() @database_sync_to_async def _mark_chat_notifications_as_read(self, chat_id): @@ -106,6 +115,16 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): except asyncio.CancelledError: pass + if self.calendar_refresh_task: + self.calendar_refresh_task.cancel() + try: + await self.calendar_refresh_task + except asyncio.CancelledError: + pass + self.calendar_refresh_task = None + self.calendar_subscriptions.clear() + self.pending_calendar_refresh_ids.clear() + # Сбрасываем статус "набирает текст" если был активный чат if self.active_chat_id and self.user: chat = await self._get_chat(self.active_chat_id) @@ -205,6 +224,116 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): elif action == "vote_poll": await self._handle_vote_poll(content) + # Подписки календаря на диапазоны событий + elif action == "calendar_subscribe": + await self._handle_calendar_subscribe(content) + elif action == "calendar_unsubscribe": + await self._handle_calendar_unsubscribe(content) + + @database_sync_to_async + def _build_calendar_snapshot(self, subscription): + return build_calendar_snapshot(user=self.user, subscription=subscription) + + async def _send_calendar_snapshot(self, subscription_id, *, reason): + subscription = self.calendar_subscriptions.get(subscription_id) + if subscription is None: + return + + try: + snapshot = await self._build_calendar_snapshot(subscription) + except Exception as error: + logger.exception( + "Calendar WebSocket snapshot failed", + extra={ + "event": "calendar.websocket_snapshot_failed", + "user_id": self.user.id, + "subscription_id": subscription_id, + "error_type": type(error).__name__, + }, + ) + await self.send_json( + { + "type": "calendar_error", + "subscription_id": subscription_id, + "code": "snapshot_failed", + "detail": "Не удалось загрузить события календаря.", + } + ) + return + + # Подписка могла измениться, пока выполнялся запрос к БД. + if self.calendar_subscriptions.get(subscription_id) != subscription: + return + await self.send_json( + { + "type": "calendar_snapshot", + "reason": reason, + "data": snapshot, + } + ) + + async def _handle_calendar_subscribe(self, content): + try: + subscription = parse_calendar_subscription(content, user=self.user) + except CalendarSubscriptionError as error: + await self.send_json( + { + "type": "calendar_error", + "subscription_id": content.get("subscription_id"), + "code": "invalid_subscription", + "detail": str(error), + } + ) + return + + if ( + subscription.subscription_id not in self.calendar_subscriptions + and len(self.calendar_subscriptions) >= MAX_CALENDAR_SUBSCRIPTIONS + ): + await self.send_json( + { + "type": "calendar_error", + "subscription_id": subscription.subscription_id, + "code": "subscription_limit", + "detail": "Превышен лимит подписок календаря.", + } + ) + return + + self.calendar_subscriptions[subscription.subscription_id] = subscription + await self._send_calendar_snapshot( + subscription.subscription_id, + reason="subscribed", + ) + + async def _handle_calendar_unsubscribe(self, content): + subscription_id = str(content.get("subscription_id") or "").strip() + if subscription_id: + self.calendar_subscriptions.pop(subscription_id, None) + + async def _refresh_calendar_subscriptions(self): + try: + while self.pending_calendar_refresh_ids: + # Несколько сигналов одной транзакции объединяются в один snapshot. + await asyncio.sleep(0.05) + calendar_ids = set(self.pending_calendar_refresh_ids) + self.pending_calendar_refresh_ids.clear() + subscriptions = list(self.calendar_subscriptions.values()) + for subscription in subscriptions: + if ( + subscription.calendar_id is not None + and subscription.calendar_id not in calendar_ids + ): + continue + await self._send_calendar_snapshot( + subscription.subscription_id, + reason="updated", + ) + except asyncio.CancelledError: + pass + finally: + self.calendar_refresh_task = None + @staticmethod def _extract_notification_chat_id(notification): """Достаёт chat_id из payload уведомления, если он есть.""" @@ -339,11 +468,24 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer): ) async def calendar_update(self, event): - """Инвалидация календарей и событий в подключённых клиентах.""" + """Передать метаданные изменения и обновить активные snapshots.""" + data = event.get("data", {}) await self.send_json( { "type": "calendar_update", "event": event.get("event", "event_updated"), - "data": event.get("data", {}), + "data": data, } ) + + if not self.calendar_subscriptions: + return + try: + calendar_id = int(data.get("calendar_id")) + except (TypeError, ValueError): + return + self.pending_calendar_refresh_ids.add(calendar_id) + if not self.calendar_refresh_task: + self.calendar_refresh_task = asyncio.create_task( + self._refresh_calendar_subscriptions() + ) diff --git a/backend/scheduling/realtime.py b/backend/scheduling/realtime.py index e9332b4b..75040463 100644 --- a/backend/scheduling/realtime.py +++ b/backend/scheduling/realtime.py @@ -1,15 +1,239 @@ import logging from collections.abc import Iterable +from dataclasses import dataclass +from datetime import datetime, time, timedelta from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db import models, transaction +from django.db.models import Q +from django.utils import timezone +from django.utils.dateparse import parse_date, parse_datetime from schedule.models import Calendar, CalendarRelation, Event, EventRelation +from schedule.periods import Period + +from scheduling.services import is_department_calendar logger = logging.getLogger(__name__) +MAX_CALENDAR_SUBSCRIPTIONS = 8 +MAX_CALENDAR_RANGE_DAYS = 400 + + +class CalendarSubscriptionError(ValueError): + """Некорректные параметры WebSocket-подписки календаря.""" + + +@dataclass(frozen=True) +class CalendarSubscription: + subscription_id: str + start: datetime + end: datetime + calendar_id: int | None + scope: str + + +def _parse_boundary(value, *, field: str) -> datetime: + raw_value = str(value or "").strip() + parsed = parse_datetime(raw_value) + if parsed is None: + parsed_date = parse_date(raw_value) + if parsed_date is not None: + parsed = datetime.combine(parsed_date, time.min) + if parsed is None: + raise CalendarSubscriptionError( + f"Поле {field} должно содержать дату в формате ISO 8601." + ) + if timezone.is_naive(parsed): + parsed = timezone.make_aware(parsed) + return parsed + + +def parse_calendar_subscription(content: dict, *, user) -> CalendarSubscription: + subscription_id = str(content.get("subscription_id") or "").strip() + if not subscription_id or len(subscription_id) > 80: + raise CalendarSubscriptionError("Некорректный subscription_id.") + + start = _parse_boundary(content.get("start"), field="start") + end = _parse_boundary(content.get("end"), field="end") + if end <= start: + raise CalendarSubscriptionError("Конец диапазона должен быть позже начала.") + if end - start > timedelta(days=MAX_CALENDAR_RANGE_DAYS): + raise CalendarSubscriptionError( + f"Диапазон календаря не может превышать {MAX_CALENDAR_RANGE_DAYS} дней." + ) + + calendar_id = content.get("calendar_id") + if calendar_id in (None, ""): + calendar_id = None + else: + try: + calendar_id = int(calendar_id) + except (TypeError, ValueError) as error: + raise CalendarSubscriptionError("Некорректный calendar_id.") from error + if calendar_id <= 0: + raise CalendarSubscriptionError("Некорректный calendar_id.") + + requested_scope = str(content.get("scope") or "accessible") + scope = ( + "all" + if requested_scope == "all" and (user.is_staff or user.is_superuser) + else "accessible" + ) + return CalendarSubscription( + subscription_id=subscription_id, + start=start, + end=end, + calendar_id=calendar_id, + scope=scope, + ) + + +def _visible_event_queryset(*, user, subscription: CalendarSubscription): + queryset = Event.objects.select_related( + "calendar", + "calendar__binding", + "rule", + "creator", + ) + if subscription.scope != "all": + User = get_user_model() + user_content_type = ContentType.objects.get_for_model(User) + calendar_ids = CalendarRelation.objects.filter( + content_type=user_content_type, + object_id=user.id, + ).values_list("calendar_id", flat=True) + participant_event_ids = EventRelation.objects.filter( + content_type=user_content_type, + object_id=user.id, + ).values_list("event_id", flat=True) + queryset = queryset.filter( + Q(calendar_id__in=calendar_ids) | Q(id__in=participant_event_ids) + ) + + if subscription.calendar_id is not None: + queryset = queryset.filter(calendar_id=subscription.calendar_id) + return queryset.distinct() + + +def _calendar_roles_for_user(user) -> dict[int, str]: + if user.is_staff or user.is_superuser: + return {} + User = get_user_model() + user_content_type = ContentType.objects.get_for_model(User) + return dict( + CalendarRelation.objects.filter( + content_type=user_content_type, + object_id=user.id, + ).values_list("calendar_id", "distinction") + ) + + +def _can_edit_event(user, event: Event, calendar_roles: dict[int, str]) -> bool: + if user.is_staff or user.is_superuser: + return True + role = calendar_roles.get(event.calendar_id) + if is_department_calendar(event.calendar): + return role in {"owner", "editor"} or event.creator_id == user.id + return role in {"owner", "editor"} + + +def _serialize_regular_event( + event: Event, + *, + user, + calendar_roles: dict[int, str], +) -> dict: + can_edit = _can_edit_event(user, event, calendar_roles) + return { + "id": event.id, + "title": event.title, + "description": event.description, + "start": event.start.isoformat(), + "end": event.end.isoformat(), + "calendar": event.calendar_id, + "calendar_name": event.calendar.name, + "color_event": event.color_event or None, + "rule": None, + "end_recurring_period": None, + "can_edit": can_edit, + "can_delete": can_edit, + "is_recurring": False, + } + + +def _serialize_occurrence( + occurrence, + *, + user, + calendar_roles: dict[int, str], +) -> dict: + event = occurrence.event + can_edit = _can_edit_event(user, event, calendar_roles) + return { + "id": f"{event.id}_{occurrence.start.isoformat()}", + "title": occurrence.title or event.title, + "description": event.description, + "start": occurrence.start.isoformat(), + "end": occurrence.end.isoformat(), + "calendar": event.calendar_id, + "color_event": event.color_event or None, + "event_id": event.id, + "rule": event.rule_id, + "end_recurring_period": ( + event.end_recurring_period.isoformat() + if event.end_recurring_period + else None + ), + "can_edit": can_edit, + "can_delete": can_edit, + "is_recurring": True, + } + + +def build_calendar_snapshot(*, user, subscription: CalendarSubscription) -> dict: + """Собрать разрешённые пользователю события для WebSocket-подписки.""" + visible_events = _visible_event_queryset(user=user, subscription=subscription) + calendar_roles = _calendar_roles_for_user(user) + regular_events = visible_events.filter( + rule__isnull=True, + start__lte=subscription.end, + end__gte=subscription.start, + ).order_by("start", "id") + serialized_events = [ + _serialize_regular_event( + event, + user=user, + calendar_roles=calendar_roles, + ) + for event in regular_events + ] + + recurring_events = visible_events.filter(rule__isnull=False).order_by( + "start", "id" + ) + period = Period(recurring_events, subscription.start, subscription.end) + serialized_events.extend( + _serialize_occurrence( + occurrence, + user=user, + calendar_roles=calendar_roles, + ) + for occurrence in period.get_occurrences() + ) + serialized_events.sort(key=lambda event: (event["start"], str(event["id"]))) + + return { + "subscription_id": subscription.subscription_id, + "start": subscription.start.isoformat(), + "end": subscription.end.isoformat(), + "calendar_id": subscription.calendar_id, + "scope": subscription.scope, + "events": serialized_events, + } + def get_calendar_recipient_ids( calendar: Calendar | None, diff --git a/backend/tests/realtime/test_calendar_updates.py b/backend/tests/realtime/test_calendar_updates.py index 861c5ec1..e28b2331 100644 --- a/backend/tests/realtime/test_calendar_updates.py +++ b/backend/tests/realtime/test_calendar_updates.py @@ -2,12 +2,18 @@ from unittest.mock import patch from datetime import timedelta import pytest +from channels.db import database_sync_to_async from django.contrib.contenttypes.models import ContentType from django.utils import timezone -from schedule.models import Calendar, CalendarRelation, Event, EventRelation +from schedule.models import Calendar, CalendarRelation, Event, EventRelation, Rule from employees.models import Employee -from scheduling.realtime import get_calendar_recipient_ids, send_calendar_update +from scheduling.realtime import ( + build_calendar_snapshot, + get_calendar_recipient_ids, + parse_calendar_subscription, + send_calendar_update, +) pytestmark = pytest.mark.django_db @@ -113,6 +119,155 @@ def test_event_lifecycle_emits_calendar_invalidation(user): assert send_update.call_args.kwargs["event_id"] == event_id +def test_calendar_snapshot_contains_visible_regular_event(user): + calendar = _calendar_with_owner(user) + event = Event.objects.create( + calendar=calendar, + title="WebSocket snapshot", + start=timezone.now(), + end=timezone.now() + timedelta(hours=1), + ) + subscription = parse_calendar_subscription( + { + "subscription_id": "calendar-main", + "start": (timezone.now() - timedelta(days=1)).isoformat(), + "end": (timezone.now() + timedelta(days=1)).isoformat(), + "calendar_id": calendar.id, + "scope": "accessible", + }, + user=user, + ) + + snapshot = build_calendar_snapshot(user=user, subscription=subscription) + + assert snapshot["subscription_id"] == "calendar-main" + assert snapshot["calendar_id"] == calendar.id + assert snapshot["events"] == [ + { + "id": event.id, + "title": "WebSocket snapshot", + "description": "", + "start": event.start.isoformat(), + "end": event.end.isoformat(), + "calendar": calendar.id, + "calendar_name": calendar.name, + "color_event": None, + "rule": None, + "end_recurring_period": None, + "can_edit": True, + "can_delete": True, + "is_recurring": False, + } + ] + + +def test_calendar_snapshot_expands_recurring_events_and_enforces_access(user, user2): + calendar = _calendar_with_owner(user) + rule = Rule.objects.create( + name="Daily realtime", + description="Two occurrences", + frequency="DAILY", + params="count:2", + ) + event = Event.objects.create( + calendar=calendar, + title="Повтор", + start=timezone.now(), + end=timezone.now() + timedelta(hours=1), + rule=rule, + ) + content = { + "subscription_id": "calendar-recurring", + "start": (timezone.now() - timedelta(days=1)).isoformat(), + "end": (timezone.now() + timedelta(days=3)).isoformat(), + "scope": "accessible", + } + + owner_subscription = parse_calendar_subscription(content, user=user) + owner_snapshot = build_calendar_snapshot( + user=user, + subscription=owner_subscription, + ) + assert len(owner_snapshot["events"]) == 2 + assert {item["event_id"] for item in owner_snapshot["events"]} == {event.id} + assert all(item["is_recurring"] for item in owner_snapshot["events"]) + + outsider_subscription = parse_calendar_subscription(content, user=user2) + outsider_snapshot = build_calendar_snapshot( + user=user2, + subscription=outsider_subscription, + ) + assert outsider_snapshot["events"] == [] + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +async def test_consumer_serves_calendar_snapshot(ws_communicator, user): + @database_sync_to_async + def create_event(): + calendar = _calendar_with_owner(user) + event = Event.objects.create( + calendar=calendar, + title="Realtime range", + start=timezone.now(), + end=timezone.now() + timedelta(hours=1), + ) + return calendar.id, event.id + + calendar_id, event_id = await create_event() + communicator = await ws_communicator(user=user) + connected, _ = await communicator.connect() + assert connected + + await communicator.send_json_to( + { + "action": "calendar_subscribe", + "subscription_id": "calendar-test", + "start": (timezone.now() - timedelta(days=1)).isoformat(), + "end": (timezone.now() + timedelta(days=1)).isoformat(), + "calendar_id": calendar_id, + "scope": "accessible", + } + ) + + response = await communicator.receive_json_from(timeout=5) + assert response["type"] == "calendar_snapshot" + assert response["reason"] == "subscribed" + assert response["data"]["subscription_id"] == "calendar-test" + assert [event["id"] for event in response["data"]["events"]] == [event_id] + + @database_sync_to_async + def rename_event(): + event = Event.objects.get(pk=event_id) + event.title = "Realtime range updated" + with patch("scheduling.realtime_signals.send_calendar_update"): + event.save(update_fields=["title"]) + + await rename_event() + from channels.layers import get_channel_layer + + await get_channel_layer().group_send( + f"user_{user.id}", + { + "type": "calendar_update", + "event": "event_updated", + "data": { + "action": "updated", + "entity": "event", + "calendar_id": calendar_id, + "event_id": event_id, + }, + }, + ) + update = await communicator.receive_json_from(timeout=5) + refreshed = await communicator.receive_json_from(timeout=5) + assert update["type"] == "calendar_update" + assert refreshed["type"] == "calendar_snapshot" + assert refreshed["reason"] == "updated" + assert refreshed["data"]["events"][0]["title"] == "Realtime range updated" + await communicator.disconnect() + + @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_consumer_forwards_calendar_update(ws_communicator, user): diff --git a/docs/guides/WEBSOCKET.md b/docs/guides/WEBSOCKET.md index 429fb5f2..e28efcb2 100644 --- a/docs/guides/WEBSOCKET.md +++ b/docs/guides/WEBSOCKET.md @@ -75,6 +75,8 @@ JWT проходит `SessionAwareJWTAuthentication`: проверяется п | `stop_typing` | — | Остановить typing | | `vote_poll` | `poll_id`, `option_ids` | Изменить голос в опросе активного чата | | `mark_read` | `chat_id` | Deprecated; используйте HTTP mark-read action | +| `calendar_subscribe` | `subscription_id`, `start`, `end` | Подписаться на snapshot событий диапазона; optional `calendar_id`, `scope` | +| `calendar_unsubscribe` | `subscription_id` | Закрыть подписку диапазона календаря | Пример: @@ -93,9 +95,12 @@ JWT проходит `SessionAwareJWTAuthentication`: проверяется п - typing: `typing_start`, `typing_stop`; - polls/read receipts: `poll_update`, `marked_read`; - notifications: `notification`, `unread_count`, `notification_read`, `notifications_read_all`; -- domain events: `procurement_update`, `task_board_update`, `calendar_update`. +- domain events: `procurement_update`, `task_board_update`, `calendar_update`; +- calendar stream: `calendar_snapshot`, `calendar_error`. -`calendar_update` сообщает о создании, изменении, удалении события или календаря и об изменении состава участников. Payload используется как invalidation signal: начальная загрузка, диапазоны и recurring occurrences остаются в REST, а frontend очищает calendar cache и пакетно обновляет видимый диапазон. +`calendar_subscribe` открывает независимую подписку на диапазон до 400 дней. Одно соединение поддерживает до восьми подписок, поэтому большой и боковой календари могут иметь разные диапазоны. Backend проверяет доступ пользователя, разворачивает recurring occurrences и отправляет `calendar_snapshot` сразу после подписки и после связанных изменений. `scope=all` применяется только для staff/superuser. + +`calendar_update` остаётся компактным доменным событием для синхронизации списка календарей. Сами видимые события поступают в `calendar_snapshot`; REST-загрузка используется frontend только как fallback, если WebSocket недоступен. Создание, изменение, удаление, импорт и экспорт календаря остаются HTTP-командами. Channel-layer handler names вроде `chat_message`, `notification_message` и `notification_count_update` — внутренний Python protocol. Frontend должен обрабатывать JSON `type`, отправленный consumer-ом, а не имена внутренних handlers. @@ -112,7 +117,7 @@ Channel-layer handler names вроде `chat_message`, `notification_message` и Не создавайте WebSocket напрямую в page/component. Chat-specific hook подписывается на shared manager и не владеет отдельным соединением. -Realtime не заменяет REST как источник состояния. После reconnect или исчерпания попыток frontend выполняет HTTP fallback/refetch. +После reconnect frontend повторяет активные календарные подписки. Если snapshot не приходит за три секунды или исчерпаны попытки reconnect, выполняется HTTP fallback для текущего диапазона. ## Изменение протокола diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index 0477957b..8f89aacd 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -7,6 +7,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { apiClient } from "@/lib/api"; import { useCalendar } from "@/contexts/CalendarContext"; +import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents"; import { Plus, ChevronDown, ChevronLeft, ChevronRight, Users, Pencil, Trash2 } from "lucide-react"; import { CalendarModal } from "@/components/CalendarModal"; import CalendarParticipantsModal from "@/components/CalendarParticipantsModal"; @@ -152,11 +153,9 @@ export function BigCalendar() { calendarScope, setCalendarScope, canUseAllCalendarsMode, - eventsRevision, reloadCalendars, } = useCalendar(); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(false); + const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0); const [currentView, setCurrentView] = useState("month"); const [currentDate, setCurrentDate] = useState(new Date()); @@ -174,7 +173,6 @@ export function BigCalendar() { const [selectedDate, setSelectedDate] = useState(null); const [showEventDetailsModal, setShowEventDetailsModal] = useState(false); const [viewingEvent, setViewingEvent] = useState(null); - const eventsRequestIdRef = useRef(0); const linkedEventId = Number(searchParams.get("event") || ""); const linkedCalendarId = Number(searchParams.get("calendar") || ""); const linkedCalendarState = useMemo( @@ -193,6 +191,15 @@ export function BigCalendar() { const participantsModalAvailable = canOpenParticipantsModal(selectedCalendar); const departmentCalendar = isDepartmentCalendar(selectedCalendar); + const { events, loading } = useCalendarEvents({ + monthDate: currentDate, + selectedCalendarId, + calendarScope, + canUseAllCalendarsMode, + eventsRefreshTrigger, + includeCurrentWeek: false, + rangePaddingDays: 7, + }); const clearEventParam = useCallback(() => { if (!searchParams.get("event")) return; @@ -229,48 +236,6 @@ export function BigCalendar() { }, []); }, [events]); - // Загрузка событий - const loadEvents = useCallback(async () => { - const requestId = ++eventsRequestIdRef.current; - const requestedCalendarId = selectedCalendarId; - - try { - setLoading(true); - const start = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); - start.setDate(start.getDate() - 7); - - const end = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 7); - const nextEvents = await calendarService.loadEvents( - start, - end, - requestedCalendarId, - calendarScope, - canUseAllCalendarsMode, - eventsRevision, - ); - - // Если параллельно ушёл более новый запрос, его ответ приоритетнее. - if (requestId !== eventsRequestIdRef.current) { - return; - } - - setEvents(nextEvents); - } catch (err) { - if (requestId !== eventsRequestIdRef.current) { - return; - } - console.error("Ошибка загрузки событий:", err); - } finally { - if (requestId === eventsRequestIdRef.current) { - setLoading(false); - } - } - }, [calendarScope, canUseAllCalendarsMode, currentDate, eventsRevision, selectedCalendarId]); - - useEffect(() => { - loadEvents(); - }, [loadEvents]); - useEffect(() => { if (!linkedEventId || viewingEvent?.id === linkedEventId) return; @@ -405,12 +370,12 @@ export function BigCalendar() { setSelectedCalendarId(null); setShowCalendarActions(false); await reloadCalendars(); - loadEvents(); + setEventsRefreshTrigger((value) => value + 1); } catch (error) { console.error("Не удалось удалить календарь:", error); alert("Не удалось удалить календарь"); } - }, [departmentCalendar, loadEvents, reloadCalendars, selectedCalendar, setSelectedCalendarId]); + }, [departmentCalendar, reloadCalendars, selectedCalendar, setSelectedCalendarId]); const handleExportCalendar = useCallback(async () => { if (!selectedCalendar?.id) return; @@ -443,7 +408,7 @@ export function BigCalendar() { const result = await calendarService.importFromICS(selectedCalendar.id, file); alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`); calendarService.clearCache(); - loadEvents(); + setEventsRefreshTrigger((value) => value + 1); } catch (error: unknown) { console.error("Не удалось импортировать календарь:", error); alert(`Ошибка импорта: ${error instanceof Error ? error.message : "Не удалось импортировать календарь"}`); @@ -453,7 +418,7 @@ export function BigCalendar() { } setShowCalendarActions(false); } - }, [loadEvents, selectedCalendar]); + }, [selectedCalendar]); // Клик на событие из модала просмотра дня const handleEventClickFromDay = useCallback(async (event: CalendarEventData) => { @@ -749,7 +714,7 @@ export function BigCalendar() { event={editingEvent} onSave={() => { calendarService.clearCache(); - loadEvents(); // ✅ Обновляем список событий + setEventsRefreshTrigger((value) => value + 1); }} showParticipants={true} /> @@ -785,7 +750,7 @@ export function BigCalendar() { await calendarService.deleteEvent(viewingEvent.id); setShowEventDetailsModal(false); setViewingEvent(null); - loadEvents(); + setEventsRefreshTrigger((value) => value + 1); } catch (err) { console.error("Ошибка удаления события:", err); alert("Не удалось удалить событие"); diff --git a/frontend/src/contexts/CalendarContext.tsx b/frontend/src/contexts/CalendarContext.tsx index 5cd5380e..ae52af0e 100644 --- a/frontend/src/contexts/CalendarContext.tsx +++ b/frontend/src/contexts/CalendarContext.tsx @@ -18,7 +18,6 @@ type CalendarContextType = { canUseAllCalendarsMode: boolean; loading: boolean; error: string | null; - eventsRevision: number; reloadCalendars: () => Promise; }; @@ -31,7 +30,6 @@ export function CalendarProvider({ children }: { children: ReactNode }) { const [calendarScope, setCalendarScope] = useState("accessible"); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [eventsRevision, setEventsRevision] = useState(0); const initialLoadDone = useRef(false); const realtimeSyncTimerRef = useRef | null>(null); const shouldReloadCalendarsRef = useRef(false); @@ -90,14 +88,12 @@ export function CalendarProvider({ children }: { children: ReactNode }) { if (!data) return; calendarService.clearCache(); - if (data.entity === "calendar" || data.action === "membership_changed") { - shouldReloadCalendarsRef.current = true; - } + if (data.entity !== "calendar") return; + shouldReloadCalendarsRef.current = true; if (realtimeSyncTimerRef.current) return; realtimeSyncTimerRef.current = setTimeout(() => { realtimeSyncTimerRef.current = null; - setEventsRevision((revision) => revision + 1); if (shouldReloadCalendarsRef.current) { shouldReloadCalendarsRef.current = false; void loadCalendars(); @@ -126,7 +122,6 @@ export function CalendarProvider({ children }: { children: ReactNode }) { canUseAllCalendarsMode, loading, error, - eventsRevision, reloadCalendars: loadCalendars, }} > diff --git a/frontend/src/hooks/calendar/useCalendarEvents.ts b/frontend/src/hooks/calendar/useCalendarEvents.ts index 0455aa78..624a0329 100644 --- a/frontend/src/hooks/calendar/useCalendarEvents.ts +++ b/frontend/src/hooks/calendar/useCalendarEvents.ts @@ -3,9 +3,8 @@ * Обрабатывает кэширование, загрузку и ошибки */ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState } from "react"; import { calendarService, type CalendarEvent, startOfWeekMonday } from "@/services/calendarService"; -import { useCalendar } from "@/contexts/CalendarContext"; interface UseCalendarEventsOptions { monthDate: Date; @@ -14,6 +13,8 @@ interface UseCalendarEventsOptions { canUseAllCalendarsMode: boolean; eventsRefreshTrigger: number; onEventsLoaded?: (events: CalendarEvent[]) => void; + includeCurrentWeek?: boolean; + rangePaddingDays?: number; } interface UseCalendarEventsResult { @@ -29,83 +30,62 @@ export function useCalendarEvents({ canUseAllCalendarsMode, eventsRefreshTrigger, onEventsLoaded, + includeCurrentWeek = true, + rangePaddingDays = 0, }: UseCalendarEventsOptions): UseCalendarEventsResult { - const { eventsRevision } = useCalendar(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const requestIdRef = useRef(0); useEffect(() => { - let cancelled = false; - - // Вычисляем диапазон дат для загрузки const start = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); const end = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1); - const weekStart = startOfWeekMonday(new Date()); - const weekEndExclusive = new Date(weekStart); - weekEndExclusive.setDate(weekEndExclusive.getDate() + 7); + start.setDate(start.getDate() - rangePaddingDays); + end.setDate(end.getDate() + rangePaddingDays); - // Расширяем диапазон для включения текущей недели - const fetchStart = weekStart < start ? weekStart : start; - const fetchEnd = weekEndExclusive > end ? weekEndExclusive : end; - - async function loadEvents() { - const requestId = ++requestIdRef.current; - - try { - setLoading(true); - setError(null); - - const loadedEvents = await calendarService.loadEvents( - fetchStart, - fetchEnd, - selectedCalendarId, - calendarScope, - canUseAllCalendarsMode, - `${eventsRefreshTrigger}:${eventsRevision}` - ); - - if (!cancelled) { - setEvents(loadedEvents); - onEventsLoaded?.(loadedEvents); - } - } catch (err) { - if (requestId !== requestIdRef.current) { - return; - } - - if (!cancelled) { - const message = err instanceof Error ? err.message : String(err || ""); - const isNetworkLike = message === "NetworkError" || /fetch failed/i.test(message); - - if (!isNetworkLike) { - console.error("Ошибка загрузки событий календаря:", err); - } - - setError("Не удалось загрузить события"); - setEvents([]); - } - } finally { - if (!cancelled && requestId === requestIdRef.current) { - setLoading(false); - } - } + let fetchStart = start; + let fetchEnd = end; + if (includeCurrentWeek) { + const weekStart = startOfWeekMonday(new Date()); + const weekEndExclusive = new Date(weekStart); + weekEndExclusive.setDate(weekEndExclusive.getDate() + 7); + fetchStart = weekStart < start ? weekStart : start; + fetchEnd = weekEndExclusive > end ? weekEndExclusive : end; } - loadEvents(); - - return () => { - cancelled = true; - }; + return calendarService.subscribeEvents({ + startDate: fetchStart, + endDate: fetchEnd, + calendarId: selectedCalendarId, + calendarScope, + canUseAllCalendarsMode, + refreshTrigger: eventsRefreshTrigger, + onEvents: (loadedEvents) => { + setEvents(loadedEvents); + onEventsLoaded?.(loadedEvents); + }, + onLoading: (isLoading) => { + setLoading(isLoading); + if (isLoading) setError(null); + }, + onError: (err) => { + const message = err instanceof Error ? err.message : String(err || ""); + if (message !== "NetworkError" && !/fetch failed/i.test(message)) { + console.error("Ошибка загрузки событий календаря:", err); + } + setError("Не удалось загрузить события"); + setEvents([]); + }, + }); }, [ monthDate, selectedCalendarId, calendarScope, canUseAllCalendarsMode, eventsRefreshTrigger, - eventsRevision, onEventsLoaded, + includeCurrentWeek, + rangePaddingDays, ]); return { events, loading, error }; diff --git a/frontend/src/lib/push.ts b/frontend/src/lib/push.ts index 60ffaf8c..a2684682 100644 --- a/frontend/src/lib/push.ts +++ b/frontend/src/lib/push.ts @@ -29,6 +29,10 @@ export function urlBase64ToUint8Array(base64String: string): Uint8Array { */ export function isPushSupported(): boolean { if (typeof window === 'undefined') return false; + + // В development Service Worker намеренно отключён и удаляется в pwa.ts. + // Не запускаем Web Push инициализацию, которая заведомо завершится ошибкой. + if (process.env.NODE_ENV !== 'production') return false; if (!('serviceWorker' in navigator)) { console.warn('[PushNotifications] Service Worker не поддерживается'); diff --git a/frontend/src/services/calendarService.test.js b/frontend/src/services/calendarService.test.js index ebe8a868..fabd57f3 100644 --- a/frontend/src/services/calendarService.test.js +++ b/frontend/src/services/calendarService.test.js @@ -50,3 +50,125 @@ test("calendar service deduplicates identical event range requests", async () => calendarService.clearCache(); } }); + +test("calendar service receives range snapshots through the shared WebSocket", async () => { + const originalWindow = globalThis.window; + const originalLocalStorage = globalThis.localStorage; + const originalWebSocket = globalThis.WebSocket; + const originalLoadEvents = calendarService.loadEvents; + + class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + static instances = []; + + constructor(url) { + this.url = url; + this.readyState = FakeWebSocket.CONNECTING; + this.sent = []; + FakeWebSocket.instances.push(this); + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + send(payload) { + this.sent.push(JSON.parse(payload)); + } + + receive(payload) { + this.onmessage?.({ data: JSON.stringify(payload) }); + } + + close() { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.({ code: 1000, reason: "", wasClean: true }); + } + } + + globalThis.window = { addEventListener() {} }; + globalThis.localStorage = { + getItem(key) { + return key === "access_token" ? "calendar-token" : null; + }, + }; + globalThis.WebSocket = FakeWebSocket; + + const received = []; + const loading = []; + let unsubscribe = () => {}; + try { + unsubscribe = calendarService.subscribeEvents({ + startDate: new Date("2026-08-01T00:00:00Z"), + endDate: new Date("2026-09-01T00:00:00Z"), + calendarId: 7, + calendarScope: "accessible", + canUseAllCalendarsMode: false, + refreshTrigger: 0, + onEvents: (events) => received.push(events), + onLoading: (value) => loading.push(value), + }); + + assert.equal(FakeWebSocket.instances.length, 1); + const socket = FakeWebSocket.instances[0]; + socket.open(); + const subscription = socket.sent.find( + (payload) => payload.action === "calendar_subscribe", + ); + assert.ok(subscription); + assert.equal(subscription.calendar_id, 7); + + socket.receive({ + type: "calendar_snapshot", + data: { + subscription_id: subscription.subscription_id, + events: [ + { + id: 42, + title: "Планёрка", + description: "", + start: "2026-08-14T09:00:00+03:00", + end: "2026-08-14T10:00:00+03:00", + calendar: 7, + is_recurring: false, + }, + ], + }, + }); + + assert.equal(received.length, 1); + assert.equal(received[0][0].id, 42); + assert.deepEqual(loading, [true, false]); + + calendarService.loadEvents = async () => [ + { + id: 43, + title: "HTTP fallback", + description: "", + start: "2026-08-15T09:00:00+03:00", + end: "2026-08-15T10:00:00+03:00", + calendar: 7, + is_recurring: false, + }, + ]; + socket.receive({ + type: "calendar_error", + subscription_id: subscription.subscription_id, + detail: "temporary snapshot failure", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(received.length, 2); + assert.equal(received[1][0].id, 43); + } finally { + unsubscribe(); + calendarService.loadEvents = originalLoadEvents; + globalThis.window = originalWindow; + globalThis.localStorage = originalLocalStorage; + globalThis.WebSocket = originalWebSocket; + } +}); diff --git a/frontend/src/services/calendarService.ts b/frontend/src/services/calendarService.ts index d700c89d..b54bc1bd 100644 --- a/frontend/src/services/calendarService.ts +++ b/frontend/src/services/calendarService.ts @@ -4,6 +4,7 @@ */ import { apiClient } from "@/lib/api"; +import wsManager from "@/lib/websocketManager"; import type { CalendarEvent as CalendarEventDetails, CalendarListEvent, @@ -52,13 +53,41 @@ interface EventsCache { timestamp: number; } +export interface CalendarEventsSubscriptionOptions { + startDate: Date; + endDate: Date; + calendarId: number | null; + calendarScope: "accessible" | "all"; + canUseAllCalendarsMode: boolean; + refreshTrigger: number | string; + onEvents: (events: CalendarEvent[]) => void; + onLoading?: (loading: boolean) => void; + onError?: (error: unknown) => void; +} + +type CalendarSnapshotEvent = { + type: "calendar_snapshot"; + data?: { + subscription_id?: string; + events?: CalendarEvent[]; + }; +}; + +type CalendarErrorEvent = { + type: "calendar_error"; + subscription_id?: string; + detail?: string; +}; + // Константы const CACHE_TTL = 30000; // 30 секунд +const REALTIME_FALLBACK_DELAY_MS = 3000; // Глобальный кэш для событий календаря let eventsCache: EventsCache | null = null; let eventsCacheGeneration = 0; let eventsInFlight: { key: string; promise: Promise } | null = null; +let realtimeSubscriptionSequence = 0; // Утилиты для работы с датами const pad = (v: number) => String(v).padStart(2, "0"); @@ -105,8 +134,134 @@ function normalizeOccurrence(occurrence: CalendarOccurrence): CalendarEvent { }; } +function normalizeRealtimeEvent(event: CalendarEvent): CalendarEvent { + if (!event.is_recurring) { + return { + ...event, + color_event: event.color_event ?? null, + is_recurring: false, + }; + } + return { + ...event, + title: event.title.startsWith("⟲") ? event.title : `⟲ ${event.title}`, + color_event: event.color_event ?? null, + is_recurring: true, + }; +} + // Сервис для работы с событиями календаря class CalendarService { + /** + * Подписаться на диапазон событий через единое WebSocket-соединение. + * HTTP используется только как fallback, если snapshot не пришёл вовремя. + */ + subscribeEvents(options: CalendarEventsSubscriptionOptions): () => void { + const subscriptionId = `calendar-${++realtimeSubscriptionSequence}`; + let active = true; + let fallbackInFlight = false; + let snapshotVersion = 0; + let fallbackTimer: ReturnType | null = null; + + const clearFallbackTimer = () => { + if (fallbackTimer) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } + }; + + const sendSubscription = () => { + wsManager.send({ + action: "calendar_subscribe", + subscription_id: subscriptionId, + start: options.startDate.toISOString(), + end: options.endDate.toISOString(), + calendar_id: options.calendarId, + scope: + options.canUseAllCalendarsMode && options.calendarScope === "all" + ? "all" + : "accessible", + }); + }; + + const loadFallback = async () => { + if (!active || fallbackInFlight) return; + fallbackInFlight = true; + const requestedAtSnapshotVersion = snapshotVersion; + try { + const events = await this.loadEvents( + options.startDate, + options.endDate, + options.calendarId, + options.calendarScope, + options.canUseAllCalendarsMode, + `ws-fallback:${options.refreshTrigger}:${subscriptionId}`, + ); + if (active && requestedAtSnapshotVersion === snapshotVersion) { + options.onEvents(events); + options.onLoading?.(false); + } + } catch (error) { + if (active && requestedAtSnapshotVersion === snapshotVersion) { + options.onError?.(error); + options.onLoading?.(false); + } + } finally { + fallbackInFlight = false; + } + }; + + options.onLoading?.(true); + const unsubscribeMessages = wsManager.subscribe((event) => { + if (event.type === "calendar_snapshot") { + const snapshot = event as CalendarSnapshotEvent; + if (snapshot.data?.subscription_id !== subscriptionId) return; + snapshotVersion += 1; + clearFallbackTimer(); + const events = Array.isArray(snapshot.data.events) + ? snapshot.data.events.map(normalizeRealtimeEvent) + : []; + options.onEvents(events); + options.onLoading?.(false); + return; + } + + if (event.type === "calendar_error") { + const errorEvent = event as CalendarErrorEvent; + if (errorEvent.subscription_id !== subscriptionId) return; + clearFallbackTimer(); + void loadFallback(); + } + }); + const unsubscribeStatus = wsManager.subscribeStatus((status) => { + if (!active) return; + if (status.isConnected) { + sendSubscription(); + } else if ( + !status.isConnecting + && status.reconnectAttempts >= status.maxReconnectAttempts + ) { + clearFallbackTimer(); + void loadFallback(); + } + }); + + fallbackTimer = setTimeout(() => { + void loadFallback(); + }, REALTIME_FALLBACK_DELAY_MS); + + return () => { + active = false; + clearFallbackTimer(); + wsManager.send({ + action: "calendar_unsubscribe", + subscription_id: subscriptionId, + }); + unsubscribeMessages(); + unsubscribeStatus(); + }; + } + /** * Загружает события календаря с кэшированием * @param startDate Начальная дата периода