- Исправлен RuleSerializer для корректной конвертации params между форматами:
* Frontend отправляет dict/JSON: {"byweekday": [0, 4], "interval": 1}
* Backend сохраняет в формате django-scheduler: "byweekday:0,4;interval:1"
* Добавлен универсальный парсер _parse_params_input() для всех форматов
- Добавлен UX в стиле Apple Calendar для еженедельных событий:
* Авто-выбор дня начала при включении WEEKLY
* Информационный баннер с датой первого события если начальный день не выбран
* Защита от перезаписи выбора пользователя через useRef
- Рефакторинг и очистка кода:
* Удалены все отладочные console.log() и print()
* Улучшены docstrings с примерами
* Добавлена константа VALID_FREQUENCIES
- Исправлена загрузка событий в режиме "Все события":
* Убрана блокировка для selectedCalendarId === null
* События и occurrences загружаются корректно для всех календарей
Closes: повторяющиеся события теперь генерируются только в выбранные дни недели
575 lines
23 KiB
Python
575 lines
23 KiB
Python
# backend/api/v1/schedule/views.py
|
||
"""
|
||
ViewSets для django-scheduler (чистый, проверенный код).
|
||
Параллельно работают со старым calendar_app.
|
||
"""
|
||
from rest_framework import viewsets, status
|
||
from rest_framework.decorators import action
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.renderers import JSONRenderer
|
||
from django.http import HttpResponse
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.db.models import Q
|
||
|
||
from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation, CalendarRelation
|
||
from schedule.periods import Period
|
||
from schedule.feeds import CalendarICalendar # Правильный импорт
|
||
|
||
from datetime import datetime
|
||
from django.utils.dateparse import parse_datetime
|
||
|
||
from .serializers import (
|
||
CalendarSerializer,
|
||
CalendarRelationSerializer,
|
||
EventSerializer,
|
||
EventListSerializer,
|
||
RuleSerializer,
|
||
OccurrenceSerializer,
|
||
EventRelationSerializer,
|
||
)
|
||
|
||
|
||
class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||
"""CRUD для django-scheduler Calendar с системой прав доступа.
|
||
|
||
Endpoints:
|
||
- GET /api/v1/schedule/calendars/ - список календарей (только доступные пользователю)
|
||
- POST /api/v1/schedule/calendars/ - создать (автоматически owner)
|
||
- GET /api/v1/schedule/calendars/{id}/ - детали
|
||
- PUT/PATCH /api/v1/schedule/calendars/{id}/ - обновить
|
||
- DELETE /api/v1/schedule/calendars/{id}/ - удалить
|
||
|
||
Дополнительные actions:
|
||
- POST /api/v1/schedule/calendars/{id}/add_participant/ - добавить участника
|
||
- DELETE /api/v1/schedule/calendars/{id}/remove_participant/ - удалить участника
|
||
- GET /api/v1/schedule/calendars/{id}/participants/ - список участников
|
||
- GET /api/v1/schedule/calendars/{id}/export_ical/ - экспорт в .ics
|
||
"""
|
||
|
||
queryset = Calendar.objects.all()
|
||
serializer_class = CalendarSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
renderer_classes = [JSONRenderer]
|
||
|
||
def get_queryset(self):
|
||
"""Фильтрация календарей по доступу пользователя."""
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
|
||
qs = super().get_queryset()
|
||
user = self.request.user
|
||
|
||
# Админы видят все
|
||
if user.is_staff or user.is_superuser:
|
||
return qs.order_by('name')
|
||
|
||
# Остальные видят только календари, где они участники
|
||
ct = ContentType.objects.get_for_model(User)
|
||
calendar_ids = CalendarRelation.objects.filter(
|
||
content_type=ct,
|
||
object_id=user.id
|
||
).values_list('calendar_id', flat=True)
|
||
|
||
qs = qs.filter(id__in=calendar_ids)
|
||
|
||
# Фильтр по имени
|
||
name = self.request.query_params.get('name')
|
||
if name:
|
||
qs = qs.filter(name__icontains=name)
|
||
|
||
return qs.order_by('name')
|
||
|
||
def perform_create(self, serializer):
|
||
"""При создании календаря автоматически добавляем creator как owner."""
|
||
calendar = serializer.save()
|
||
# Создаем CalendarRelation с distinction='owner'
|
||
calendar.create_relation(self.request.user, distinction='owner', inheritable=True)
|
||
|
||
@action(detail=True, methods=['post'], url_path='add-participant')
|
||
def add_participant(self, request, pk=None):
|
||
"""Добавить участника к календарю.
|
||
|
||
Body:
|
||
{
|
||
"user_id": 123,
|
||
"role": "viewer" # owner, editor, viewer
|
||
}
|
||
"""
|
||
calendar = self.get_object()
|
||
|
||
# Проверка прав (только owner может добавлять участников)
|
||
if not self._is_owner(calendar, request.user):
|
||
return Response(
|
||
{'detail': 'Только владелец может добавлять участников'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
serializer = CalendarRelationSerializer(data={
|
||
'calendar': calendar.id,
|
||
'user_id': request.data.get('user_id'),
|
||
'distinction': request.data.get('role', 'viewer'),
|
||
'inheritable': True
|
||
})
|
||
|
||
if serializer.is_valid():
|
||
serializer.save()
|
||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
@action(detail=True, methods=['delete'], url_path='remove-participant/(?P<user_id>[0-9]+)')
|
||
def remove_participant(self, request, pk=None, user_id=None):
|
||
"""Удалить участника из календаря."""
|
||
calendar = self.get_object()
|
||
|
||
# Проверка прав
|
||
if not self._is_owner(calendar, request.user):
|
||
return Response(
|
||
{'detail': 'Только владелец может удалять участников'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
ct = ContentType.objects.get_for_model(User)
|
||
|
||
# Нельзя удалить owner
|
||
relation = CalendarRelation.objects.filter(
|
||
calendar=calendar,
|
||
content_type=ct,
|
||
object_id=user_id
|
||
).first()
|
||
|
||
if not relation:
|
||
return Response(
|
||
{'detail': 'Участник не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
|
||
if relation.distinction == 'owner':
|
||
return Response(
|
||
{'detail': 'Нельзя удалить владельца календаря'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
relation.delete()
|
||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||
|
||
@action(detail=True, methods=['get'])
|
||
def participants(self, request, pk=None):
|
||
"""Получить список всех участников календаря."""
|
||
calendar = self.get_object()
|
||
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
ct = ContentType.objects.get_for_model(User)
|
||
|
||
relations = CalendarRelation.objects.filter(
|
||
calendar=calendar,
|
||
content_type=ct
|
||
)
|
||
|
||
serializer = CalendarRelationSerializer(relations, many=True)
|
||
return Response(serializer.data)
|
||
|
||
@action(detail=True, methods=['get'], url_path='export-ical')
|
||
def export_ical(self, request, pk=None):
|
||
"""Экспорт календаря в формат iCalendar (.ics).
|
||
|
||
Использует встроенный CalendarICalendar из django-scheduler.
|
||
"""
|
||
calendar = self.get_object()
|
||
|
||
# Создаем feed для экспорта
|
||
feed = CalendarICalendar()
|
||
response = feed(request, pk)
|
||
|
||
# Устанавливает имя файла
|
||
response['Content-Disposition'] = f'attachment; filename="{calendar.slug}.ics"'
|
||
|
||
return response
|
||
|
||
@action(detail=True, methods=['post'], url_path='import-ical')
|
||
def import_ical(self, request, pk=None):
|
||
"""Импорт событий из файла iCalendar (.ics) в календарь.
|
||
|
||
Ожидает файл в request.FILES['file'].
|
||
"""
|
||
from icalendar import Calendar as ICalendar
|
||
from django.utils import timezone
|
||
|
||
calendar = self.get_object()
|
||
|
||
# Проверка, что пользователь - владелец
|
||
if not self._is_owner(calendar, request.user):
|
||
return Response(
|
||
{'error': 'Только владелец может импортировать события в календарь'},
|
||
status=status.HTTP_403_FORBIDDEN
|
||
)
|
||
|
||
# Проверка наличия файла
|
||
if 'file' not in request.FILES:
|
||
return Response(
|
||
{'error': 'Файл не найден. Отправьте файл с ключом "file"'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
ics_file = request.FILES['file']
|
||
|
||
try:
|
||
# Парсим iCalendar файл
|
||
ical = ICalendar.from_ical(ics_file.read())
|
||
|
||
imported_count = 0
|
||
skipped_count = 0
|
||
errors = []
|
||
|
||
# Проходим по всем событиям в файле
|
||
for component in ical.walk():
|
||
if component.name == "VEVENT":
|
||
try:
|
||
# Извлекаем данные события
|
||
summary = str(component.get('summary', ''))
|
||
description = str(component.get('description', ''))
|
||
dtstart = component.get('dtstart')
|
||
dtend = component.get('dtend')
|
||
|
||
if not dtstart:
|
||
skipped_count += 1
|
||
continue
|
||
|
||
# Преобразуем даты
|
||
start_dt = dtstart.dt
|
||
if not timezone.is_aware(start_dt):
|
||
start_dt = timezone.make_aware(start_dt)
|
||
|
||
if dtend:
|
||
end_dt = dtend.dt
|
||
if not timezone.is_aware(end_dt):
|
||
end_dt = timezone.make_aware(end_dt)
|
||
else:
|
||
# Если нет времени окончания, делаем событие длиной 1 час
|
||
from datetime import timedelta
|
||
end_dt = start_dt + timedelta(hours=1)
|
||
|
||
# Создаем событие
|
||
Event.objects.create(
|
||
calendar=calendar,
|
||
title=summary or 'Без названия',
|
||
description=description,
|
||
start=start_dt,
|
||
end=end_dt,
|
||
creator=request.user
|
||
)
|
||
imported_count += 1
|
||
|
||
except Exception as e:
|
||
errors.append(f"Ошибка импорта события '{summary}': {str(e)}")
|
||
skipped_count += 1
|
||
|
||
return Response({
|
||
'success': True,
|
||
'imported': imported_count,
|
||
'skipped': skipped_count,
|
||
'errors': errors if errors else None
|
||
})
|
||
|
||
except Exception as e:
|
||
return Response(
|
||
{'error': f'Ошибка парсинга файла: {str(e)}'},
|
||
status=status.HTTP_400_BAD_REQUEST
|
||
)
|
||
|
||
def _is_owner(self, calendar, user):
|
||
"""Проверка, является ли пользователь владельцем календаря."""
|
||
if user.is_staff or user.is_superuser:
|
||
return True
|
||
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
ct = ContentType.objects.get_for_model(User)
|
||
|
||
return CalendarRelation.objects.filter(
|
||
calendar=calendar,
|
||
content_type=ct,
|
||
object_id=user.id,
|
||
distinction='owner'
|
||
).exists()
|
||
|
||
|
||
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', 'rule', 'creator')
|
||
permission_classes = [IsAuthenticated]
|
||
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()
|
||
user = self.request.user
|
||
|
||
# Админы видят все события
|
||
if not (user.is_staff or user.is_superuser):
|
||
ct = ContentType.objects.get_for_model(User)
|
||
|
||
# ID календарей где пользователь участник
|
||
accessible_calendar_ids = CalendarRelation.objects.filter(
|
||
content_type=ct,
|
||
object_id=user.id
|
||
).values_list('calendar_id', flat=True)
|
||
|
||
# ID событий где пользователь участник
|
||
participant_event_ids = EventRelation.objects.filter(
|
||
content_type=ct,
|
||
object_id=user.id
|
||
).values_list('event_id', flat=True)
|
||
|
||
# Объединяем: события из доступных календарей ИЛИ где пользователь участник
|
||
qs = qs.filter(
|
||
Q(calendar_id__in=accessible_calendar_ids) |
|
||
Q(id__in=participant_event_ids)
|
||
)
|
||
|
||
# Фильтр по календарю
|
||
calendar_id = self.request.query_params.get('calendar')
|
||
if calendar_id:
|
||
qs = qs.filter(calendar_id=calendar_id)
|
||
|
||
# Фильтр по диапазону дат
|
||
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
|
||
)
|
||
|
||
# Если указан конкретный календарь - фильтруем по нему
|
||
if calendar_id:
|
||
try:
|
||
calendar = Calendar.objects.get(id=calendar_id)
|
||
calendars = [calendar]
|
||
except Calendar.DoesNotExist:
|
||
return Response(
|
||
{'detail': 'Календарь не найден'},
|
||
status=status.HTTP_404_NOT_FOUND
|
||
)
|
||
else:
|
||
# Если календарь не указан - берем все доступные пользователю календари
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
user = request.user
|
||
|
||
if user.is_staff or user.is_superuser:
|
||
calendars = Calendar.objects.all()
|
||
else:
|
||
ct = ContentType.objects.get_for_model(User)
|
||
calendar_ids = CalendarRelation.objects.filter(
|
||
content_type=ct,
|
||
object_id=user.id
|
||
).values_list('calendar_id', flat=True)
|
||
calendars = Calendar.objects.filter(id__in=calendar_ids)
|
||
|
||
# Результат
|
||
occurrences = []
|
||
|
||
# Обрабатываем каждый календарь
|
||
for calendar in calendars:
|
||
events = Event.objects.filter(calendar=calendar)
|
||
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': calendar.id, # Добавляем ID календаря
|
||
'color_event': occurrence.event.color_event,
|
||
'event_id': occurrence.event.id, # ID оригинального события
|
||
'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)
|
||
|
||
|
||
class ScheduleRuleViewSet(viewsets.ModelViewSet):
|
||
"""CRUD для django-scheduler Rule (rrule RFC 5545).
|
||
|
||
Endpoints:
|
||
- GET /api/v1/schedule/rules/ - список правил
|
||
- POST /api/v1/schedule/rules/ - создать правило
|
||
- GET /api/v1/schedule/rules/{id}/ - детали
|
||
"""
|
||
|
||
queryset = Rule.objects.all()
|
||
serializer_class = RuleSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
renderer_classes = [JSONRenderer]
|
||
|
||
|
||
class ScheduleOccurrenceViewSet(viewsets.ReadOnlyModelViewSet):
|
||
"""Просмотр Occurrence (материализованных вхождений).
|
||
|
||
Read-only ViewSet (Occurrence создаются автоматически).
|
||
"""
|
||
|
||
queryset = Occurrence.objects.select_related('event', 'event__calendar')
|
||
serializer_class = OccurrenceSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
renderer_classes = [JSONRenderer]
|
||
|
||
def get_queryset(self):
|
||
"""Фильтрация по событию."""
|
||
qs = super().get_queryset()
|
||
|
||
event_id = self.request.query_params.get('event')
|
||
if event_id:
|
||
qs = qs.filter(event_id=event_id)
|
||
|
||
return qs.order_by('start')
|
||
|
||
|
||
class ScheduleEventRelationViewSet(viewsets.ModelViewSet):
|
||
"""CRUD для EventRelation (участники встреч).
|
||
|
||
Endpoints:
|
||
- GET /api/v1/schedule/relations/ - список участников
|
||
- GET /api/v1/schedule/relations/?event=1 - участники события
|
||
- POST /api/v1/schedule/relations/ - добавить участника
|
||
- DELETE /api/v1/schedule/relations/{id}/ - удалить участника
|
||
"""
|
||
|
||
queryset = EventRelation.objects.select_related('event', 'content_type')
|
||
serializer_class = EventRelationSerializer
|
||
permission_classes = [IsAuthenticated]
|
||
renderer_classes = [JSONRenderer]
|
||
|
||
def get_queryset(self):
|
||
"""Фильтрация по событию."""
|
||
qs = super().get_queryset()
|
||
|
||
event_id = self.request.query_params.get('event')
|
||
if event_id:
|
||
qs = qs.filter(event_id=event_id)
|
||
|
||
return qs
|