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)