feat: stream calendar events over websocket
This commit is contained in:
parent
aaa1da663c
commit
b7e20c8956
@ -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
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 для текущего диапазона.
|
||||
|
||||
## Изменение протокола
|
||||
|
||||
|
||||
@ -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<CalendarEventData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0);
|
||||
const [currentView, setCurrentView] = useState<View>("month");
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
@ -174,7 +173,6 @@ export function BigCalendar() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
|
||||
const [viewingEvent, setViewingEvent] = useState<CalendarEventDraft | null>(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("Не удалось удалить событие");
|
||||
|
||||
@ -18,7 +18,6 @@ type CalendarContextType = {
|
||||
canUseAllCalendarsMode: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
eventsRevision: number;
|
||||
reloadCalendars: () => Promise<void>;
|
||||
};
|
||||
|
||||
@ -31,7 +30,6 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
|
||||
const [calendarScope, setCalendarScope] = useState<CalendarScope>("accessible");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [eventsRevision, setEventsRevision] = useState(0);
|
||||
const initialLoadDone = useRef(false);
|
||||
const realtimeSyncTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
|
||||
@ -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 не поддерживается');
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@ -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<CalendarEvent[]> } | 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<typeof setTimeout> | 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 Начальная дата периода
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user