Corp/backend/scheduling/realtime.py
2026-09-07 19:43:48 +03:00

333 lines
11 KiB
Python

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.models import CalendarBinding
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)
| Q(
calendar__binding__type=CalendarBinding.BindingType.DEFAULT,
calendar__binding__access_scope=CalendarBinding.AccessScope.ALL,
)
)
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,
*,
event: Event | None = None,
) -> set[int]:
if calendar is None or not calendar.pk:
return set()
User = get_user_model()
user_content_type = ContentType.objects.get_for_model(User)
recipient_ids = set(
CalendarRelation.objects.filter(
calendar=calendar,
content_type=user_content_type,
object_id__isnull=False,
).values_list("object_id", flat=True)
)
if event is not None and event.pk:
recipient_ids.update(
EventRelation.objects.filter(
event=event,
content_type=user_content_type,
object_id__isnull=False,
).values_list("object_id", flat=True)
)
# Администраторы могут работать в scope=all без CalendarRelation.
recipient_ids.update(
User.objects.filter(is_active=True)
.filter(models.Q(is_staff=True) | models.Q(is_superuser=True))
.values_list("id", flat=True)
)
return set(
User.objects.filter(id__in=recipient_ids, is_active=True).values_list(
"id", flat=True
)
)
def send_calendar_update(
*,
action: str,
entity: str,
calendar_id: int,
recipient_ids: Iterable[int],
event_id: int | None = None,
changed_fields: Iterable[str] | None = None,
extra: dict | None = None,
) -> None:
recipients = {int(user_id) for user_id in recipient_ids if user_id}
if not recipients or not calendar_id:
return
payload = {
"action": action,
"entity": entity,
"calendar_id": calendar_id,
"event_id": event_id,
"changed_fields": list(changed_fields or []),
}
if extra:
payload.update(extra)
def _send() -> None:
channel_layer = get_channel_layer()
if channel_layer is None:
return
message = {
"type": "calendar_update",
"event": f"{entity}_{action}",
"data": payload,
}
for user_id in recipients:
try:
async_to_sync(channel_layer.group_send)(f"user_{user_id}", message)
except Exception as error:
logger.exception(
"Calendar WebSocket update failed",
extra={
"event": "calendar.websocket_update_failed",
"user_id": user_id,
"calendar_id": calendar_id,
"schedule_event_id": event_id,
"calendar_action": action,
"error_type": type(error).__name__,
},
)
transaction.on_commit(_send)