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).
This commit is contained in:
igor_nadein 2026-02-24 17:35:30 +03:00
parent f67e625c14
commit d94e82d313
5 changed files with 603 additions and 0 deletions

View File

@ -0,0 +1 @@
# API v1 для django-scheduler (новая система)

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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 спринта