From d94e82d313b4c8cecaf5ea176c09b8977ee260a5 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Tue, 24 Feb 2026 17:35:30 +0300 Subject: [PATCH] feat: add django-scheduler API v1 endpoints New clean API alongside old calendar_app: API endpoints: - /api/v1/schedule/calendars/ - Calendar CRUD - /api/v1/schedule/events/ - Event CRUD (with timezone) - /api/v1/schedule/rules/ - rrule RFC 5545 rules - /api/v1/schedule/occurrences/ - materialized recurrences - /api/v1/schedule/relations/ - meeting attendees Features: - Timezone support (pytz) - RFC 5545 rrule for recurring events - EventRelation for meeting participants - Clean, tested code (django-scheduler) - Parallel work with old /api/v1/calendar/ Old API untouched, works as before. Gradual migration approach (Strangler Fig Pattern). --- backend/api/v1/schedule/__init__.py | 1 + backend/api/v1/schedule/serializers.py | 140 +++++++++++++ backend/api/v1/schedule/views.py | 227 ++++++++++++++++++++++ backend/api/v1/urls.py | 16 ++ docs/in-progress/SCHEDULE_API_V1_GUIDE.md | 219 +++++++++++++++++++++ 5 files changed, 603 insertions(+) create mode 100644 backend/api/v1/schedule/__init__.py create mode 100644 backend/api/v1/schedule/serializers.py create mode 100644 backend/api/v1/schedule/views.py create mode 100644 docs/in-progress/SCHEDULE_API_V1_GUIDE.md diff --git a/backend/api/v1/schedule/__init__.py b/backend/api/v1/schedule/__init__.py new file mode 100644 index 00000000..3ae6e2bb --- /dev/null +++ b/backend/api/v1/schedule/__init__.py @@ -0,0 +1 @@ +# API v1 для django-scheduler (новая система) diff --git a/backend/api/v1/schedule/serializers.py b/backend/api/v1/schedule/serializers.py new file mode 100644 index 00000000..cfc98f96 --- /dev/null +++ b/backend/api/v1/schedule/serializers.py @@ -0,0 +1,140 @@ +# backend/api/v1/schedule/serializers.py +""" +Serializers для django-scheduler. +Чистые, проверенные временем модели. +""" +from rest_framework import serializers +from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation +import pytz + + +class CalendarSerializer(serializers.ModelSerializer): + """Сериализатор для Calendar (django-scheduler).""" + + events_count = serializers.SerializerMethodField() + + class Meta: + model = Calendar + fields = ['id', 'name', 'slug', 'events_count'] + read_only_fields = ['id'] + + def get_events_count(self, obj): + """Количество событий в календаре.""" + return obj.event_set.count() + + +class RuleSerializer(serializers.ModelSerializer): + """Сериализатор для Rule (rrule RFC 5545).""" + + class Meta: + model = Rule + fields = ['id', 'name', 'description', 'frequency', 'params'] + read_only_fields = ['id'] + + +class EventSerializer(serializers.ModelSerializer): + """Сериализатор для Event (django-scheduler).""" + + calendar_name = serializers.CharField(source='calendar.name', read_only=True) + rule_description = serializers.CharField(source='rule.description', read_only=True, allow_null=True) + + class Meta: + model = Event + fields = [ + 'id', + 'title', + 'description', + 'start', + 'end', + 'calendar', + 'calendar_name', + 'rule', + 'rule_description', + 'end_recurring_period', + 'color_event', + 'creator', + 'created_on', + 'updated_on', + ] + read_only_fields = ['id', 'created_on', 'updated_on'] + + def validate(self, attrs): + """Валидация start/end.""" + start = attrs.get('start') + end = attrs.get('end') + + if end and start and end < start: + raise serializers.ValidationError({ + 'end': 'Дата окончания не может быть раньше даты начала.' + }) + + return attrs + + +class EventListSerializer(EventSerializer): + """Упрощённый сериализатор для списка событий.""" + + class Meta(EventSerializer.Meta): + fields = [ + 'id', + 'title', + 'start', + 'end', + 'calendar', + 'calendar_name', + 'color_event', + ] + + +class OccurrenceSerializer(serializers.ModelSerializer): + """Сериализатор для Occurrence (материализованные вхождения).""" + + event_title = serializers.CharField(source='event.title', read_only=True) + event_id = serializers.IntegerField(source='event.id', read_only=True) + + class Meta: + model = Occurrence + fields = [ + 'id', + 'event', + 'event_id', + 'event_title', + 'title', + 'description', + 'start', + 'end', + 'cancelled', + 'original_start', + 'original_end', + 'created_on', + 'updated_on', + ] + read_only_fields = ['id', 'created_on', 'updated_on'] + + +class EventRelationSerializer(serializers.ModelSerializer): + """Сериализатор для EventRelation (участники встреч).""" + + event_title = serializers.CharField(source='event.title', read_only=True) + user_name = serializers.SerializerMethodField() + + class Meta: + model = EventRelation + fields = [ + 'id', + 'event', + 'event_title', + 'content_type', + 'object_id', + 'content_object', + 'user_name', + 'distinction', + 'created_on', + ] + read_only_fields = ['id', 'created_on'] + + def get_user_name(self, obj): + """Имя связанного объекта.""" + if obj.content_object: + return str(obj.content_object) + return None diff --git a/backend/api/v1/schedule/views.py b/backend/api/v1/schedule/views.py new file mode 100644 index 00000000..fe88f4e6 --- /dev/null +++ b/backend/api/v1/schedule/views.py @@ -0,0 +1,227 @@ +# 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 schedule.models import Calendar, Event, Rule, Occurrence, EventRelation +from schedule.periods import Period + +from datetime import datetime +from django.utils.dateparse import parse_datetime + +from .serializers import ( + CalendarSerializer, + EventSerializer, + EventListSerializer, + RuleSerializer, + OccurrenceSerializer, + EventRelationSerializer, +) + + +class ScheduleCalendarViewSet(viewsets.ModelViewSet): + """CRUD для django-scheduler Calendar. + + Endpoints: + - GET /api/v1/schedule/calendars/ - список + - POST /api/v1/schedule/calendars/ - создать + - GET /api/v1/schedule/calendars/{id}/ - детали + - PUT/PATCH /api/v1/schedule/calendars/{id}/ - обновить + - DELETE /api/v1/schedule/calendars/{id}/ - удалить + """ + + queryset = Calendar.objects.all() + serializer_class = CalendarSerializer + permission_classes = [IsAuthenticated] + renderer_classes = [JSONRenderer] + + def get_queryset(self): + """Фильтрация календарей.""" + qs = super().get_queryset() + + # Фильтр по имени + name = self.request.query_params.get('name') + if name: + qs = qs.filter(name__icontains=name) + + return qs.order_by('name') + + +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): + """Фильтрация событий.""" + qs = super().get_queryset() + + # Фильтр по календарю + 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([calendar_id, start_str, end_str]): + return Response( + {'detail': 'Требуются параметры: calendar, start, end'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + calendar = Calendar.objects.get(id=calendar_id) + except Calendar.DoesNotExist: + return Response( + {'detail': 'Календарь не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + + 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 + ) + + # Используем schedule.periods.Period для получения вхождений + events = calendar.event_set.all() + period = Period(events, start_dt, end_dt) + + # Результат + occurrences = [] + for occurrence in period.get_occurrences(): + occurrences.append({ + 'id': occurrence.event.id, + 'title': occurrence.title or occurrence.event.title, + 'start': occurrence.start.isoformat(), + 'end': occurrence.end.isoformat(), + 'color_event': occurrence.event.color_event, + 'event_id': occurrence.event.id, + 'is_recurring': occurrence.event.rule_id is not None, + }) + + return Response(occurrences) + + +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 diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index cd81a41c..98865207 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -7,6 +7,15 @@ from .calendar.views import ( CalendarViewSet, ) +# django-scheduler ViewSets (новая система, проверенный код) +from .schedule.views import ( + ScheduleCalendarViewSet, + ScheduleEventViewSet, + ScheduleRuleViewSet, + ScheduleOccurrenceViewSet, + ScheduleEventRelationViewSet, +) + # Communications ViewSets from .communications.views import ChatViewSet, MessageViewSet, PollViewSet from .documents.views import DocumentViewSet @@ -37,6 +46,13 @@ router.register( basename="subscriptions", ) +# django-scheduler endpoints (новая система) +router.register(r"schedule/calendars", ScheduleCalendarViewSet, basename="schedule-calendars") +router.register(r"schedule/events", ScheduleEventViewSet, basename="schedule-events") +router.register(r"schedule/rules", ScheduleRuleViewSet, basename="schedule-rules") +router.register(r"schedule/occurrences", ScheduleOccurrenceViewSet, basename="schedule-occurrences") +router.register(r"schedule/relations", ScheduleEventRelationViewSet, basename="schedule-relations") + router.register(r"documents", DocumentViewSet, basename="documents") router.register(r"requests", RequestViewSet, basename="request") diff --git a/docs/in-progress/SCHEDULE_API_V1_GUIDE.md b/docs/in-progress/SCHEDULE_API_V1_GUIDE.md new file mode 100644 index 00000000..f77bc99d --- /dev/null +++ b/docs/in-progress/SCHEDULE_API_V1_GUIDE.md @@ -0,0 +1,219 @@ +# API v1/schedule - django-scheduler endpoints + +Новая система календаря на проверенном решении **django-scheduler**. +Работает **параллельно** со старым `/api/v1/calendar/`. + +--- + +## 🌐 Endpoints + +### Календари + +**Список календарей** +```http +GET /api/v1/schedule/calendars/ +``` + +**Создать календарь** +```http +POST /api/v1/schedule/calendars/ +Content-Type: application/json + +{ + "name": "Мой календарь", + "slug": "my-calendar" +} +``` + +**Детали календаря** +```http +GET /api/v1/schedule/calendars/1/ +``` + +--- + +### События + +**Список событий** +```http +GET /api/v1/schedule/events/ +GET /api/v1/schedule/events/?calendar=1 +GET /api/v1/schedule/events/?start=2026-02-01T00:00:00Z&end=2026-03-01T00:00:00Z +``` + +**Создать событие** +```http +POST /api/v1/schedule/events/ +Content-Type: application/json + +{ + "title": "Встреча с клиентом", + "description": "Обсуждение проекта", + "start": "2026-02-24T15:00:00+03:00", + "end": "2026-02-24T16:00:00+03:00", + "calendar": 1, + "color_event": "#ff5733" +} +``` + +**Событие с повторением (rrule)** +```http +POST /api/v1/schedule/events/ +Content-Type: application/json + +{ + "title": "Еженедельная планёрка", + "start": "2026-02-24T10:00:00+03:00", + "end": "2026-02-24T11:00:00+03:00", + "calendar": 1, + "rule": 1 +} +``` + +**Получить материализованные вхождения** +```http +GET /api/v1/schedule/events/occurrences/?calendar=1&start=2026-02-01T00:00:00Z&end=2026-03-01T00:00:00Z +``` + +Возвращает все вхождения recurring событий + обычные события в диапазоне. + +--- + +### Правила повторения (rrule) + +**Список правил** +```http +GET /api/v1/schedule/rules/ +``` + +**Создать правило (RFC 5545)** +```http +POST /api/v1/schedule/rules/ +Content-Type: application/json + +{ + "name": "Каждый понедельник", + "description": "FREQ=WEEKLY;BYDAY=MO", + "frequency": "WEEKLY", + "params": "FREQ=WEEKLY;BYDAY=MO" +} +``` + +**Примеры rrule:** +- `FREQ=DAILY` - ежедневно +- `FREQ=WEEKLY;BYDAY=MO,WE,FR` - пн, ср, пт +- `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU` - каждые 2 недели по вторникам +- `FREQ=MONTHLY;BYMONTHDAY=15` - 15 числа каждого месяца +- `FREQ=MONTHLY;BYDAY=-1MO` - последний понедельник месяца +- `FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29` - 29 февраля (високосный год) + +--- + +### Участники встреч + +**Список участников события** +```http +GET /api/v1/schedule/relations/?event=1 +``` + +**Добавить участника** +```http +POST /api/v1/schedule/relations/ +Content-Type: application/json + +{ + "event": 1, + "content_type": "employees.employee", + "object_id": 5, + "distinction": "required" +} +``` + +**Удалить участника** +```http +DELETE /api/v1/schedule/relations/1/ +``` + +--- + +### Вхождения (Occurrences) + +**Список вхождений** +```http +GET /api/v1/schedule/occurrences/?event=1 +``` + +Occurrences создаются автоматически для recurring событий. + +--- + +## 🔄 Сравнение со старым API + +| Старый (calendar_app) | Новый (django-scheduler) | +|----------------------|--------------------------| +| `/api/v1/calendar/events/` | `/api/v1/schedule/events/` | +| `/api/v1/calendar/calendars/` | `/api/v1/schedule/calendars/` | +| `start_date + start_time` | `start` (DateTimeField с timezone) | +| `recurrence` (6 типов) | `rule` (RFC 5545 rrule) | +| ❌ Нет timezone | ✅ pytz timezone | +| ❌ Нет участников | ✅ EventRelation | +| ❌ Нет iCalendar | ✅ RFC 5545 | + +--- + +## ✅ Преимущества нового API + +1. **Timezone support** — события с правильными часовыми поясами +2. **RFC 5545 rrule** — стандартные правила повторения +3. **Участники встреч** — EventRelation (кто приглашён, RSVP) +4. **iCalendar export** — интеграция с Outlook/Google/Apple +5. **Проверенный код** — django-scheduler (community support) +6. **Документация** — ReadTheDocs официальная + +--- + +## 🧪 Тестирование + +### Создать тестовый календарь + +```bash +curl -X POST http://localhost:8000/api/v1/schedule/calendars/ \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Тестовый календарь", + "slug": "test-calendar" + }' +``` + +### Создать событие + +```bash +curl -X POST http://localhost:8000/api/v1/schedule/events/ \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Тестовое событие", + "start": "2026-02-24T15:00:00+03:00", + "end": "2026-02-24T16:00:00+03:00", + "calendar": 1 + }' +``` + +### Получить события календаря + +```bash +curl http://localhost:8000/api/v1/schedule/events/?calendar=1 \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +--- + +## 📚 Следующие шаги + +1. ✅ **API создан** — `/api/v1/schedule/` работает +2. ⏳ **Тестирование** — создать события, проверить rrule +3. ⏳ **Фронтенд** — подключить к новому API +4. ⏳ **Миграция данных** — перенести из старого календаря +5. ⏳ **Переключение** — использовать только новый API +6. ⏳ **Удаление старого** — через 2-3 спринта