- Исправлен 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: повторяющиеся события теперь генерируются только в выбранные дни недели
426 lines
15 KiB
Python
426 lines
15 KiB
Python
# backend/api/v1/schedule/serializers.py
|
||
"""
|
||
Serializers для django-scheduler.
|
||
Чистые, проверенные временем модели.
|
||
"""
|
||
from rest_framework import serializers
|
||
from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation, CalendarRelation
|
||
from django.contrib.contenttypes.models import ContentType
|
||
import pytz
|
||
|
||
|
||
class CalendarRelationSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для CalendarRelation (участники календаря)."""
|
||
|
||
user_id = serializers.IntegerField(write_only=True, required=False)
|
||
user = serializers.SerializerMethodField(read_only=True)
|
||
|
||
class Meta:
|
||
model = CalendarRelation
|
||
fields = ['id', 'calendar', 'user_id', 'user', 'distinction', 'inheritable']
|
||
read_only_fields = ['id']
|
||
|
||
def get_user(self, obj):
|
||
"""Информация о связанном пользователе."""
|
||
if obj.content_object:
|
||
return {
|
||
'id': obj.content_object.id,
|
||
'username': getattr(obj.content_object, 'username', str(obj.content_object)),
|
||
'first_name': getattr(obj.content_object, 'first_name', ''),
|
||
'last_name': getattr(obj.content_object, 'last_name', ''),
|
||
}
|
||
return None
|
||
|
||
def create(self, validated_data):
|
||
"""Создать связь календаря с пользователем."""
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
|
||
user_id = validated_data.pop('user_id')
|
||
calendar = validated_data['calendar']
|
||
distinction = validated_data.get('distinction', 'viewer')
|
||
inheritable = validated_data.get('inheritable', True)
|
||
|
||
user = User.objects.get(id=user_id)
|
||
|
||
# Используем встроенный метод create_relation
|
||
calendar.create_relation(user, distinction=distinction, inheritable=inheritable)
|
||
|
||
# Возвращаем созданную relation
|
||
ct = ContentType.objects.get_for_model(User)
|
||
return CalendarRelation.objects.get(
|
||
calendar=calendar,
|
||
content_type=ct,
|
||
object_id=user_id,
|
||
distinction=distinction
|
||
)
|
||
|
||
|
||
class CalendarSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для Calendar (django-scheduler) с совместимостью EUSRR."""
|
||
|
||
# Совместимость с EUSRR API - принимаем title, конвертируем в name
|
||
title = serializers.CharField(source='name', required=False)
|
||
events_count = serializers.SerializerMethodField()
|
||
owner = serializers.SerializerMethodField()
|
||
participants = serializers.SerializerMethodField()
|
||
user_role = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Calendar
|
||
fields = ['id', 'name', 'title', 'slug', 'events_count', 'owner', 'participants', 'user_role']
|
||
read_only_fields = ['id', 'owner', 'participants', 'user_role']
|
||
extra_kwargs = {
|
||
'slug': {'required': False}, # Сделаем auto-generated если не указан
|
||
}
|
||
|
||
def get_events_count(self, obj):
|
||
"""Количество событий в календаре."""
|
||
return obj.event_set.count()
|
||
|
||
def get_owner(self, obj):
|
||
"""Получить владельца календаря."""
|
||
try:
|
||
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=obj,
|
||
content_type=ct,
|
||
distinction='owner'
|
||
).first()
|
||
|
||
if owner_relation and owner_relation.content_object:
|
||
return {
|
||
'id': owner_relation.content_object.id,
|
||
'username': getattr(owner_relation.content_object, 'username', str(owner_relation.content_object)),
|
||
'full_name': str(owner_relation.content_object),
|
||
}
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def get_participants(self, obj):
|
||
"""Список всех участников календаря."""
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
ct = ContentType.objects.get_for_model(User)
|
||
|
||
relations = CalendarRelation.objects.filter(
|
||
calendar=obj,
|
||
content_type=ct
|
||
).select_related('content_type')
|
||
|
||
participants = []
|
||
for rel in relations:
|
||
if rel.content_object:
|
||
participants.append({
|
||
'id': rel.content_object.id,
|
||
'username': getattr(rel.content_object, 'username', str(rel.content_object)),
|
||
'full_name': str(rel.content_object),
|
||
'role': rel.distinction,
|
||
})
|
||
return participants
|
||
|
||
def get_user_role(self, obj):
|
||
"""Роль текущего пользователя для этого календаря."""
|
||
request = self.context.get('request')
|
||
if not request or not request.user.is_authenticated:
|
||
return None
|
||
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
ct = ContentType.objects.get_for_model(User)
|
||
|
||
relation = CalendarRelation.objects.filter(
|
||
calendar=obj,
|
||
content_type=ct,
|
||
object_id=request.user.id
|
||
).first()
|
||
|
||
return relation.distinction if relation else None
|
||
|
||
def create(self, validated_data):
|
||
"""Создать календарь с автогенерацией slug из name/title."""
|
||
from django.utils.text import slugify
|
||
|
||
# Если slug не указан - генерируем из name
|
||
if 'slug' not in validated_data or not validated_data.get('slug'):
|
||
name = validated_data.get('name', 'calendar')
|
||
base_slug = slugify(name)
|
||
if not base_slug:
|
||
base_slug = 'calendar'
|
||
|
||
# Проверяем уникальность slug
|
||
slug = base_slug
|
||
counter = 1
|
||
while Calendar.objects.filter(slug=slug).exists():
|
||
slug = f"{base_slug}-{counter}"
|
||
counter += 1
|
||
|
||
validated_data['slug'] = slug
|
||
|
||
return super().create(validated_data)
|
||
|
||
|
||
class RuleSerializer(serializers.ModelSerializer):
|
||
"""
|
||
Сериализатор для Rule (rrule RFC 5545).
|
||
|
||
Преобразует параметры между форматами:
|
||
- Frontend: dict {"byweekday": [0, 4], "interval": 1}
|
||
- django-scheduler: string "byweekday:0,4;interval:1"
|
||
"""
|
||
|
||
VALID_FREQUENCIES = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY']
|
||
|
||
frequency_display = serializers.CharField(source='get_frequency_display', read_only=True)
|
||
|
||
class Meta:
|
||
model = Rule
|
||
fields = ['id', 'name', 'description', 'frequency', 'frequency_display', 'params']
|
||
read_only_fields = ['id', 'frequency_display']
|
||
|
||
def _dict_to_params_string(self, params_dict):
|
||
"""
|
||
Преобразует dict в строковый формат django-scheduler.
|
||
|
||
Args:
|
||
params_dict: {"byweekday": [0, 3], "interval": 1}
|
||
|
||
Returns:
|
||
"byweekday:0,3;interval:1"
|
||
"""
|
||
if not params_dict or not isinstance(params_dict, dict):
|
||
return ""
|
||
|
||
parts = []
|
||
for key, value in params_dict.items():
|
||
if isinstance(value, (list, tuple)):
|
||
value_str = ",".join(str(v) for v in value)
|
||
else:
|
||
value_str = str(value)
|
||
parts.append(f"{key}:{value_str}")
|
||
|
||
return ";".join(parts)
|
||
|
||
def _params_string_to_dict(self, params_str):
|
||
"""
|
||
Преобразует строку django-scheduler в dict.
|
||
|
||
Args:
|
||
params_str: "byweekday:0,3;interval:1"
|
||
|
||
Returns:
|
||
{"byweekday": [0, 3], "interval": 1}
|
||
"""
|
||
if not params_str:
|
||
return {}
|
||
|
||
result = {}
|
||
for param_pair in params_str.split(";"):
|
||
if ":" not in param_pair:
|
||
continue
|
||
|
||
key, value_str = param_pair.split(":", 1)
|
||
key = key.strip()
|
||
|
||
# Если значение содержит запятые - это список
|
||
if "," in value_str:
|
||
try:
|
||
result[key] = [int(v.strip()) for v in value_str.split(",")]
|
||
except ValueError:
|
||
result[key] = [v.strip() for v in value_str.split(",")]
|
||
else:
|
||
# Пробуем преобразовать в число
|
||
try:
|
||
result[key] = int(value_str.strip())
|
||
except ValueError:
|
||
result[key] = value_str.strip()
|
||
|
||
return result
|
||
|
||
def _parse_params_input(self, params):
|
||
"""
|
||
Парсит params из любого формата в dict.
|
||
|
||
Поддерживает:
|
||
- dict (прямо от JSON)
|
||
- JSON-string (от некоторых клиентов)
|
||
- django-scheduler string (для обратной совместимости)
|
||
|
||
Args:
|
||
params: dict, JSON-string или django-scheduler string
|
||
|
||
Returns:
|
||
dict с параметрами
|
||
"""
|
||
import json
|
||
|
||
# Уже dict - возвращаем как есть
|
||
if isinstance(params, dict):
|
||
return params
|
||
|
||
# Пробуем распарсить как JSON-строку
|
||
if isinstance(params, str):
|
||
# Если начинается с { - это JSON
|
||
if params.strip().startswith('{'):
|
||
try:
|
||
return json.loads(params)
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
|
||
# Иначе пробуем как django-scheduler формат
|
||
return self._params_string_to_dict(params)
|
||
|
||
return {}
|
||
|
||
def to_representation(self, instance):
|
||
"""Преобразует params из БД (django-scheduler format) в dict для frontend."""
|
||
data = super().to_representation(instance)
|
||
data['params'] = self._params_string_to_dict(data.get('params', ''))
|
||
return data
|
||
|
||
def to_internal_value(self, data):
|
||
"""Преобразует params от frontend (dict/JSON) в django-scheduler format для БД."""
|
||
# Парсим params в dict из любого формата
|
||
params_dict = self._parse_params_input(data.get('params'))
|
||
|
||
# Вызываем родительский метод для валидации остальных полей
|
||
validated_data = super().to_internal_value(data)
|
||
|
||
# Конвертируем dict в формат django-scheduler для сохранения в БД
|
||
validated_data['params'] = self._dict_to_params_string(params_dict)
|
||
|
||
return validated_data
|
||
|
||
def validate_frequency(self, value):
|
||
"""Валидация поля frequency."""
|
||
if value not in self.VALID_FREQUENCIES:
|
||
raise serializers.ValidationError(
|
||
f'Invalid frequency. Must be one of: {", ".join(self.VALID_FREQUENCIES)}'
|
||
)
|
||
return value
|
||
|
||
|
||
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',
|
||
'description',
|
||
'start',
|
||
'end',
|
||
'calendar',
|
||
'calendar_name',
|
||
'color_event',
|
||
'rule',
|
||
'end_recurring_period',
|
||
]
|
||
|
||
|
||
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()
|
||
content_type = serializers.PrimaryKeyRelatedField(
|
||
queryset=ContentType.objects.all(),
|
||
required=False # Делаем необязательным
|
||
)
|
||
|
||
class Meta:
|
||
model = EventRelation
|
||
fields = [
|
||
'id',
|
||
'event',
|
||
'event_title',
|
||
'content_type',
|
||
'object_id',
|
||
'distinction',
|
||
'user_name',
|
||
]
|
||
read_only_fields = ['id']
|
||
|
||
def get_user_name(self, obj):
|
||
"""Имя связанного объекта."""
|
||
if obj.content_object:
|
||
return str(obj.content_object)
|
||
return None
|
||
|
||
def create(self, validated_data):
|
||
"""Автоматически устанавливает content_type для User."""
|
||
if 'content_type' not in validated_data:
|
||
from django.contrib.auth import get_user_model
|
||
User = get_user_model()
|
||
validated_data['content_type'] = ContentType.objects.get_for_model(User)
|
||
return super().create(validated_data)
|