# backend/api/v1/schedule/views.py """ ViewSets для django-scheduler (чистый, проверенный код). Параллельно работают со старым calendar_app. """ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.dateparse import parse_datetime from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from schedule.models import ( Calendar, Event, EventRelation, ) from schedule.periods import Period from scheduling.models import CalendarBinding from scheduling.services import ( filter_events_for_user, user_can_delete_event, user_can_edit_event, user_can_view_calendar, ) from ..permissions import ( CanEditCalendar, ) from ..serializers import ( EventListSerializer, EventSerializer, ) from .common import _admin_requested_all_scope, _user_accessible_calendar_ids class ScheduleEventViewSet(viewsets.ModelViewSet): """CRUD для django-scheduler Event. Endpoints: - GET /api/v1/schedule/events/ - список - GET /api/v1/schedule/events/?calendar=1 - события календаря - GET /api/v1/schedule/events/?start=2026-02-01&end=2026-03-01 - в диапазоне - POST /api/v1/schedule/events/ - создать - GET /api/v1/schedule/events/{id}/ - детали - PUT/PATCH /api/v1/schedule/events/{id}/ - обновить - DELETE /api/v1/schedule/events/{id}/ - удалить """ queryset = Event.objects.select_related( "calendar", "calendar__binding", "calendar__binding__context_content_type", "rule", "creator", ) permission_classes = [IsAuthenticated, CanEditCalendar] renderer_classes = [JSONRenderer] def get_serializer_class(self): """Выбор сериализатора.""" if self.action == "list": return EventListSerializer return EventSerializer def get_queryset(self): """Фильтрация событий. Пользователь видит события из: 1. Календарей, где он участник (CalendarRelation) 2. Событий, где он участник (EventRelation) """ from django.contrib.auth import get_user_model User = get_user_model() qs = ( super() .get_queryset() .select_related( "calendar", "calendar__binding", "calendar__binding__context_content_type", "rule", "creator", ) ) user = self.request.user calendar_id = self.request.query_params.get("calendar") show_all_scope = _admin_requested_all_scope(self.request) # Полный обзор для админа включается только явным scope=all # либо на detail actions, где нужен прямой доступ. if not ((user.is_staff or user.is_superuser) and show_all_scope): from django.contrib.auth import get_user_model User = get_user_model() ct = ContentType.objects.get_for_model(User) accessible_calendar_ids = _user_accessible_calendar_ids(user) participant_event_ids = EventRelation.objects.filter( content_type=ct, object_id=user.id, ).values_list("event_id", flat=True) visibility_filter = Q(calendar_id__in=accessible_calendar_ids) | Q( id__in=participant_event_ids ) if self.action != "list": visibility_filter |= Q( calendar__binding__type=CalendarBinding.BindingType.DEPARTMENT ) if calendar_id: try: calendar = Calendar.objects.select_related("binding").get( id=calendar_id ) except Calendar.DoesNotExist: calendar = None if calendar and user_can_view_calendar(user, calendar): visibility_filter |= Q(calendar_id=calendar.id) qs = qs.filter(visibility_filter) # Фильтр по календарю if calendar_id: qs = qs.filter(calendar_id=calendar_id) if search := self.request.query_params.get("search", "").strip(): qs = qs.filter( Q(title__icontains=search) | Q(description__icontains=search) ) # Фильтр по диапазону дат start_str = self.request.query_params.get("start") end_str = self.request.query_params.get("end") if start_str and end_str: start_dt = parse_datetime(start_str) end_dt = parse_datetime(end_str) if start_dt and end_dt: # События, которые попадают в диапазон qs = qs.filter(start__lte=end_dt, end__gte=start_dt) return qs.order_by("start") def perform_create(self, serializer): """Установка creator при создании.""" serializer.save(creator=self.request.user) @action(detail=False, methods=["get"], url_path="occurrences") def occurrences(self, request): """Получить материализованные вхождения в диапазоне. Query params: - calendar: ID календаря (необязательно, если не указан - все доступные календари) - start: начало диапазона (ISO 8601) - end: конец диапазона (ISO 8601) Возвращает все вхождения recurring событий + обычные события. """ calendar_id = request.query_params.get("calendar") start_str = request.query_params.get("start") end_str = request.query_params.get("end") if not all([start_str, end_str]): return Response( {"detail": "Требуются параметры: start, end"}, status=status.HTTP_400_BAD_REQUEST, ) start_dt = parse_datetime(start_str) end_dt = parse_datetime(end_str) if not start_dt or not end_dt: return Response( {"detail": "Некорректный формат дат (используйте ISO 8601)"}, status=status.HTTP_400_BAD_REQUEST, ) events = filter_events_for_user( Event.objects.select_related("calendar", "rule"), request.user, allow_admin_all=_admin_requested_all_scope(request), ) # Если указан конкретный календарь - фильтруем по нему. Прямое # приглашение в событие также даёт доступ к его occurrences, даже если # пользователь не состоит в календаре целиком. if calendar_id: try: calendar = Calendar.objects.select_related("binding").get( id=calendar_id ) except Calendar.DoesNotExist: return Response( {"detail": "Календарь не найден"}, status=status.HTTP_404_NOT_FOUND, ) events = events.filter(calendar_id=calendar.id) if ( not user_can_view_calendar(request.user, calendar) and not events.exists() ): return Response( {"detail": "Нет доступа к календарю"}, status=status.HTTP_403_FORBIDDEN, ) # Результат occurrences = [] period = Period(events, start_dt, end_dt) for occurrence in period.get_occurrences(): # Создаем уникальный ID для каждого вхождения # (комбинация event_id и start) unique_id = f"{occurrence.event.id}_{occurrence.start.isoformat()}" occurrences.append( { "id": unique_id, # Уникальный ID для каждого вхождения "title": occurrence.title or occurrence.event.title, "description": occurrence.event.description, "start": occurrence.start.isoformat(), "end": occurrence.end.isoformat(), "calendar": occurrence.event.calendar_id, "color_event": occurrence.event.color_event, # ID оригинального события "event_id": occurrence.event.id, "can_edit": user_can_edit_event(request.user, occurrence.event), "can_delete": user_can_delete_event(request.user, occurrence.event), "is_recurring": occurrence.event.rule_id is not None, "rule": occurrence.event.rule_id, "end_recurring_period": ( occurrence.event.end_recurring_period.isoformat() if occurrence.event.end_recurring_period else None ), } ) return Response(occurrences) @action(detail=False, methods=["get"], url_path="my-events") def my_events(self, request): """Получить события текущего пользователя (где он участник). Query params: - start: начало диапазона (ISO 8601, необязательно) - end: конец диапазона (ISO 8601, необязательно) Возвращает только события, где пользователь является участником (EventRelation). """ from django.contrib.auth import get_user_model User = get_user_model() user = request.user ct = ContentType.objects.get_for_model(User) # ID событий где пользователь участник participant_event_ids = EventRelation.objects.filter( content_type=ct, object_id=user.id ).values_list("event_id", flat=True) # Фильтруем события qs = self.queryset.filter(id__in=participant_event_ids) # Фильтр по диапазону дат (если указан) start_str = request.query_params.get("start") end_str = request.query_params.get("end") if start_str and end_str: start_dt = parse_datetime(start_str) end_dt = parse_datetime(end_str) if start_dt and end_dt: qs = qs.filter(start__lte=end_dt, end__gte=start_dt) qs = qs.order_by("start") # Используем EventListSerializer для детальной информации serializer = EventListSerializer(qs, many=True) return Response(serializer.data)