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