207 lines
9.5 KiB
Python
207 lines
9.5 KiB
Python
# backend/api/v1/calendar/serializers.py
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import date, datetime, time, timedelta
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from calendar_app.models import CalendarEvent, Recurrence
|
||
from django.utils.translation import gettext_lazy as _
|
||
from rest_framework import serializers
|
||
|
||
|
||
class CalendarEventWriteSerializer(serializers.ModelSerializer):
|
||
"""Создание/обновление события календаря с полной валидацией."""
|
||
|
||
# Разрешаем пустой список для PATCH (когда меняют weekly -> daily и присылают weekdays: [])
|
||
weekdays = serializers.ListField(
|
||
child=serializers.IntegerField(min_value=0, max_value=6),
|
||
required=False,
|
||
allow_empty=True,
|
||
write_only=True,
|
||
help_text="Список дней недели для WEEKLY (0=Mon..6=Sun).",
|
||
)
|
||
department_id = serializers.IntegerField(required=False, allow_null=True)
|
||
employee_id = serializers.IntegerField(required=False, allow_null=True)
|
||
|
||
class Meta:
|
||
model = CalendarEvent
|
||
fields = (
|
||
"id",
|
||
"department_id",
|
||
"employee_id",
|
||
"title",
|
||
"description",
|
||
"start_date",
|
||
"end_date",
|
||
"start_time",
|
||
"end_time",
|
||
"all_day",
|
||
"recurrence",
|
||
"recurrence_interval",
|
||
"recurrence_until",
|
||
"recurrence_count",
|
||
"weekdays_mask",
|
||
"weekdays", # write-only
|
||
"color",
|
||
"location",
|
||
)
|
||
read_only_fields = ("id",)
|
||
|
||
# ---- helpers
|
||
def _coalesce(self, data: Dict[str, Any], instance: Optional[CalendarEvent], key: str, default=None):
|
||
if key in data:
|
||
return data.get(key)
|
||
if instance is not None:
|
||
return getattr(instance, key)
|
||
return default
|
||
|
||
@staticmethod
|
||
def _weekdays_to_mask(values: list[int]) -> int:
|
||
mask = 0
|
||
for v in values:
|
||
iv = int(v)
|
||
if not (0 <= iv <= 6):
|
||
raise ValueError("weekday out of range")
|
||
mask |= (1 << iv)
|
||
return mask
|
||
|
||
# ---- validation
|
||
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
||
inst: Optional[CalendarEvent] = getattr(self, "instance", None)
|
||
is_create = inst is None
|
||
|
||
s_date: date = self._coalesce(attrs, inst, "start_date")
|
||
if not s_date:
|
||
raise serializers.ValidationError({"start_date": _("Обязательное поле.")})
|
||
|
||
e_date: Optional[date] = self._coalesce(attrs, inst, "end_date", None)
|
||
s_time: Optional[time] = self._coalesce(attrs, inst, "start_time", None)
|
||
e_time: Optional[time] = self._coalesce(attrs, inst, "end_time", None)
|
||
|
||
all_day_raw = self._coalesce(attrs, inst, "all_day", None)
|
||
rec: str = self._coalesce(attrs, inst, "recurrence", Recurrence.ONE_TIME)
|
||
|
||
# ВАЖНО: не используем "or 1", чтобы 0 не превратился в 1
|
||
interval_val = self._coalesce(attrs, inst, "recurrence_interval", 1)
|
||
try:
|
||
interval = int(interval_val)
|
||
except Exception:
|
||
raise serializers.ValidationError({"recurrence_interval": _("Интервал должен быть числом.")})
|
||
|
||
until: Optional[date] = self._coalesce(attrs, inst, "recurrence_until", None)
|
||
rcount: Optional[int] = self._coalesce(attrs, inst, "recurrence_count", None)
|
||
|
||
wmask: int = int(self._coalesce(attrs, inst, "weekdays_mask", 0))
|
||
weekdays_input = attrs.get("weekdays", None)
|
||
if weekdays_input is not None:
|
||
try:
|
||
wmask = self._weekdays_to_mask(weekdays_input)
|
||
except Exception:
|
||
raise serializers.ValidationError({"weekdays": _("Неверный формат дней недели (ожидаются числа 0..6).")})
|
||
attrs["weekdays_mask"] = wmask
|
||
attrs.pop("weekdays", None)
|
||
|
||
color: Optional[str] = self._coalesce(attrs, inst, "color", None)
|
||
|
||
# 1) База
|
||
if interval < 1:
|
||
raise serializers.ValidationError({"recurrence_interval": _("Интервал должен быть ≥ 1.")})
|
||
if until is not None and until < s_date:
|
||
raise serializers.ValidationError({"recurrence_until": _("Дата окончания повторения не может быть раньше даты начала.")})
|
||
# Конфликтующие параметры
|
||
if until is not None and rcount is not None:
|
||
raise serializers.ValidationError({"recurrence_count": _("Нельзя одновременно задавать recurrence_until и recurrence_count.")})
|
||
|
||
if color and not re.fullmatch(r"#([0-9A-Fa-f]{6})", color):
|
||
raise serializers.ValidationError({"color": _("Цвет должен быть в формате #RRGGBB.")})
|
||
|
||
# 2) Время / all_day
|
||
has_any_time = (s_time is not None) or (e_time is not None)
|
||
if has_any_time and (s_time is None or e_time is None):
|
||
raise serializers.ValidationError(_("Если указываете время, заполните и начало, и окончание."))
|
||
|
||
if all_day_raw is None:
|
||
attrs["all_day"] = not has_any_time
|
||
else:
|
||
if all_day_raw and has_any_time:
|
||
raise serializers.ValidationError(_("Нельзя указывать время для события на весь день."))
|
||
if (all_day_raw is False) and not has_any_time:
|
||
raise serializers.ValidationError(_("Для события с временем укажите start_time и end_time."))
|
||
|
||
all_day: bool = attrs.get("all_day", inst.all_day if inst else (not has_any_time))
|
||
|
||
# end_date по умолчанию = start_date
|
||
if e_date is None:
|
||
e_date = s_date
|
||
attrs["end_date"] = e_date
|
||
|
||
# 3) Хронология
|
||
if all_day:
|
||
if e_date < s_date:
|
||
raise serializers.ValidationError({"end_date": _("Дата окончания не может быть раньше даты начала.")})
|
||
else:
|
||
start_dt = datetime.combine(s_date, s_time)
|
||
end_dt = datetime.combine(e_date, e_time)
|
||
if end_dt <= start_dt:
|
||
raise serializers.ValidationError({"end_time": _("Окончание должно быть позже начала.")})
|
||
|
||
# 4) Повторяемость
|
||
if rec == Recurrence.HOURLY and all_day:
|
||
raise serializers.ValidationError({"recurrence": _("Ежечасное повторение возможно только для событий с временем.")})
|
||
|
||
if rec == Recurrence.WEEKLY:
|
||
# create: требуем явные дни
|
||
if is_create and wmask == 0:
|
||
raise serializers.ValidationError({"weekdays_mask": _("Для еженедельного события укажите дни недели (weekdays или weekdays_mask).")})
|
||
# update: если маска не пришла и осталась 0 — подставим день недели старта
|
||
if (not is_create) and ("weekdays_mask" not in attrs) and (wmask == 0):
|
||
wmask = 1 << s_date.weekday()
|
||
attrs["weekdays_mask"] = wmask
|
||
if wmask < 0 or wmask > 0b1111111:
|
||
raise serializers.ValidationError({"weekdays_mask": _("Недопустимая маска дней недели.")})
|
||
else:
|
||
# для НЕ weekly игнорируем присланные weekdays/маску (если вдруг присланы пустыми)
|
||
attrs.pop("weekdays", None)
|
||
# маску не трогаем, если не прислана — оставляем как есть/0
|
||
|
||
if rec not in Recurrence.values:
|
||
raise serializers.ValidationError({"recurrence": _("Недопустимый тип повторения.")})
|
||
|
||
if rcount is not None and rcount < 1:
|
||
raise serializers.ValidationError({"recurrence_count": _("Количество повторов должно быть ≥ 1.")})
|
||
|
||
return attrs
|
||
|
||
def create(self, validated_data: Dict[str, Any]) -> CalendarEvent:
|
||
validated_data.pop("weekdays", None)
|
||
return CalendarEvent.objects.create(**validated_data)
|
||
|
||
def update(self, instance: CalendarEvent, validated_data: Dict[str, Any]) -> CalendarEvent:
|
||
validated_data.pop("weekdays", None)
|
||
for f, v in validated_data.items():
|
||
setattr(instance, f, v)
|
||
instance.save()
|
||
return instance
|
||
|
||
|
||
class CalendarEventSerializer(serializers.ModelSerializer):
|
||
"""Read-сериализатор (база модели, если нужен просмотр без развёртки)."""
|
||
|
||
class Meta:
|
||
model = CalendarEvent
|
||
fields = "__all__"
|
||
|
||
|
||
class EventOccurrenceSerializer(serializers.Serializer):
|
||
"""Сериализатор одного материализованного вхождения для FullCalendar."""
|
||
|
||
id = serializers.IntegerField()
|
||
title = serializers.CharField()
|
||
start = serializers.DateTimeField(required=False)
|
||
end = serializers.DateTimeField(required=False)
|
||
allDay = serializers.BooleanField()
|
||
color = serializers.CharField(allow_null=True, required=False)
|
||
recurrence = serializers.CharField()
|
||
department_id = serializers.IntegerField(allow_null=True, required=False)
|