548 lines
18 KiB
Python
548 lines
18 KiB
Python
# backend/api/v1/schedule/serializers.py
|
||
"""
|
||
Serializers для django-scheduler.
|
||
Чистые, проверенные временем модели.
|
||
"""
|
||
|
||
from drf_spectacular.utils import extend_schema_field
|
||
from rest_framework import serializers
|
||
from schedule.models import (
|
||
Calendar,
|
||
Event,
|
||
Rule,
|
||
Occurrence,
|
||
EventRelation,
|
||
CalendarRelation,
|
||
)
|
||
from django.contrib.contenttypes.models import ContentType
|
||
|
||
|
||
class RelatedUserSummarySerializer(serializers.Serializer):
|
||
id = serializers.IntegerField()
|
||
username = serializers.CharField()
|
||
first_name = serializers.CharField(required=False, allow_blank=True)
|
||
last_name = serializers.CharField(required=False, allow_blank=True)
|
||
full_name = serializers.CharField(required=False)
|
||
role = serializers.CharField(required=False)
|
||
|
||
|
||
class EventRuleDataSerializer(serializers.Serializer):
|
||
frequency = serializers.CharField()
|
||
params = serializers.DictField()
|
||
description = serializers.CharField(allow_blank=True, allow_null=True)
|
||
|
||
|
||
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"]
|
||
|
||
@extend_schema_field(RelatedUserSummarySerializer(allow_null=True))
|
||
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 = {
|
||
# Сделаем auto-generated если не указан
|
||
"slug": {"required": False},
|
||
}
|
||
|
||
@extend_schema_field(serializers.IntegerField())
|
||
def get_events_count(self, obj):
|
||
"""Количество событий в календаре."""
|
||
return obj.event_set.count()
|
||
|
||
@extend_schema_field(RelatedUserSummarySerializer(allow_null=True))
|
||
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
|
||
|
||
@extend_schema_field(RelatedUserSummarySerializer(many=True))
|
||
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
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
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 из БД в dict для frontend.
|
||
|
||
Исходный формат — django-scheduler format.
|
||
"""
|
||
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
|
||
)
|
||
rule_data = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Event
|
||
fields = [
|
||
"id",
|
||
"title",
|
||
"description",
|
||
"start",
|
||
"end",
|
||
"calendar",
|
||
"calendar_name",
|
||
"rule",
|
||
"rule_description",
|
||
"rule_data",
|
||
"end_recurring_period",
|
||
"color_event",
|
||
"creator",
|
||
"created_on",
|
||
"updated_on",
|
||
]
|
||
read_only_fields = ["id", "created_on", "updated_on"]
|
||
|
||
@extend_schema_field(EventRuleDataSerializer(allow_null=True))
|
||
def get_rule_data(self, obj):
|
||
"""Возвращает детальную информацию о правиле повторения."""
|
||
if not obj.rule:
|
||
return None
|
||
|
||
rule = obj.rule
|
||
params = rule.get_params()
|
||
|
||
return {
|
||
"frequency": rule.frequency,
|
||
"params": params,
|
||
"description": rule.description,
|
||
}
|
||
|
||
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()
|
||
user_avatar = 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",
|
||
"user_avatar",
|
||
]
|
||
read_only_fields = ["id"]
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_user_name(self, obj):
|
||
"""Имя связанного объекта."""
|
||
if obj.content_object:
|
||
return str(obj.content_object)
|
||
return None
|
||
|
||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||
def get_user_avatar(self, obj):
|
||
"""URL аватара связанного сотрудника."""
|
||
user = getattr(obj, "content_object", None)
|
||
avatar = getattr(user, "avatar", None)
|
||
if avatar:
|
||
try:
|
||
return avatar.url
|
||
except Exception:
|
||
return str(avatar)
|
||
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)
|