333 lines
11 KiB
Python
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)
|