# 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 ( Calendar, CalendarEvent, CalendarSubscription, 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).", ) calendar_id = serializers.IntegerField(required=False, allow_null=True) department_id = serializers.IntegerField(required=False, allow_null=True) employee_id = serializers.IntegerField(required=False, allow_null=True) class Meta: model = CalendarEvent fields = ( "id", "calendar_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) # Поддержка calendar_id (новая архитектура) calendar_id = validated_data.pop("calendar_id", None) if calendar_id is not None: from calendar_app.models import Calendar try: calendar = Calendar.objects.get(id=calendar_id) validated_data["calendar"] = calendar except Calendar.DoesNotExist: raise serializers.ValidationError( { "calendar_id": _("Календарь с ID {} не найден.").format( calendar_id ) } ) # Legacy поддержка department_id и employee_id department_id = validated_data.pop("department_id", None) employee_id = validated_data.pop("employee_id", None) if department_id is not None: from employees.models import Department try: department = Department.objects.get(id=department_id) validated_data["department"] = department except Department.DoesNotExist: raise serializers.ValidationError( { "department_id": _("Отдел с ID {} не найден.").format( department_id ) } ) if employee_id is not None: from django.contrib.auth import get_user_model User = get_user_model() try: employee = User.objects.get(id=employee_id) validated_data["employee"] = employee except User.DoesNotExist: raise serializers.ValidationError( { "employee_id": _("Сотрудник с ID {} не найден.").format( employee_id ) } ) return CalendarEvent.objects.create(**validated_data) def update( self, instance: CalendarEvent, validated_data: Dict[str, Any] ) -> CalendarEvent: validated_data.pop("weekdays", None) # Поддержка calendar_id (новая архитектура) if "calendar_id" in validated_data: calendar_id = validated_data.pop("calendar_id") if calendar_id is not None: from calendar_app.models import Calendar try: calendar = Calendar.objects.get(id=calendar_id) validated_data["calendar"] = calendar except Calendar.DoesNotExist: raise serializers.ValidationError( { "calendar_id": _("Календарь с ID {} не найден.").format( calendar_id ) } ) else: # Явно обнуляем calendar, если передан null validated_data["calendar"] = None # Legacy поддержка department_id и employee_id if "department_id" in validated_data: department_id = validated_data.pop("department_id") if department_id is not None: from employees.models import Department try: department = Department.objects.get(id=department_id) validated_data["department"] = department except Department.DoesNotExist: raise serializers.ValidationError( { "department_id": _("Отдел с ID {} не найден.").format( department_id ) } ) else: # Явно обнуляем department, если передан null validated_data["department"] = None if "employee_id" in validated_data: employee_id = validated_data.pop("employee_id") if employee_id is not None: from django.contrib.auth import get_user_model User = get_user_model() try: employee = User.objects.get(id=employee_id) validated_data["employee"] = employee except User.DoesNotExist: raise serializers.ValidationError( { "employee_id": _("Сотрудник с ID {} не найден.").format( employee_id ) } ) else: # Явно обнуляем employee, если передан null validated_data["employee"] = None for f, v in validated_data.items(): setattr(instance, f, v) instance.save() return instance class CalendarEventSerializer(serializers.ModelSerializer): """Read-сериализатор (база модели, если нужен просмотр без развёртки).""" created_by_name = serializers.SerializerMethodField() calendar_title = serializers.CharField( source="calendar.title", read_only=True ) class Meta: model = CalendarEvent fields = "__all__" def get_created_by_name(self, obj): """Возвращает имя создателя события.""" if obj.created_by: return obj.created_by.get_full_name() or obj.created_by.username return None 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) # ===== Сериализаторы для новых моделей Calendar и CalendarSubscription ===== class CalendarSerializer(serializers.ModelSerializer): """Сериализатор для календаря (чтение).""" owner_user_name = serializers.SerializerMethodField() owner_department_name = serializers.SerializerMethodField() event_count = serializers.IntegerField(read_only=True, required=False) subscriber_count = serializers.IntegerField(read_only=True, required=False) is_subscribed = serializers.SerializerMethodField() user_can_edit = serializers.SerializerMethodField() # Computed-поля для типа календаря is_personal = serializers.SerializerMethodField() is_department = serializers.SerializerMethodField() is_global = serializers.SerializerMethodField() class Meta: model = Calendar fields = ( "id", "title", "description", "color", "icon", "visibility", "owner_user", "owner_user_id", # Добавлено: ID владельца-пользователя "owner_user_name", "owner_department", "owner_department_id", # Добавлено: ID отдела-владельца "owner_department_name", "auto_subscribe_new_users", "auto_subscribe_department_members", "is_active", "created_at", "updated_at", "event_count", "subscriber_count", "is_subscribed", "user_can_edit", "is_personal", "is_department", "is_global", ) read_only_fields = ("id", "created_at", "updated_at") def get_owner_user_name(self, obj): """Возвращает имя владельца-пользователя.""" if obj.owner_user: return obj.owner_user.get_full_name() or obj.owner_user.username return None def get_owner_department_name(self, obj): """Возвращает название отдела-владельца.""" return obj.owner_department.name if obj.owner_department else None def get_is_subscribed(self, obj): """Проверяет, подписан ли текущий пользователь.""" request = self.context.get("request") if request and request.user.is_authenticated: return obj.subscriptions.filter(user=request.user).exists() return False def get_is_personal(self, obj): """Проверяет, является ли календарь личным.""" return obj.owner_user_id is not None and obj.owner_department_id is None def get_is_department(self, obj): """Проверяет, является ли календарь отдела.""" return obj.owner_department_id is not None and obj.owner_user_id is None def get_is_global(self, obj): """Проверяет, является ли календарь глобальным.""" return obj.owner_user_id is None and obj.owner_department_id is None def get_user_can_edit(self, obj): """Проверяет, может ли текущий пользователь редактировать календарь.""" request = self.context.get("request") if not request or not request.user.is_authenticated: return False user = request.user # Админы могут всё if user.is_superuser or user.is_staff: return True # Владелец может редактировать if obj.owner_user_id == user.id: return True # Проверяем подписку с правом редактирования subscription = obj.subscriptions.filter(user=user).first() if subscription and (subscription.can_edit or subscription.can_manage): return True return False class CalendarWriteSerializer(serializers.ModelSerializer): """Сериализатор для создания/обновления календаря.""" class Meta: model = Calendar fields = ( "id", "title", "description", "color", "icon", "visibility", "owner_user", "owner_department", "default_can_edit", "auto_subscribe_new_users", "auto_subscribe_department_members", "is_active", ) read_only_fields = ("id",) def validate(self, attrs): """Валидация: нельзя одновременно указывать owner_user и owner_department.""" owner_user = attrs.get("owner_user") owner_department = attrs.get("owner_department") # Для update нужно учитывать существующие значения if self.instance: owner_user = ( owner_user if "owner_user" in attrs else self.instance.owner_user ) owner_department = ( owner_department if "owner_department" in attrs else self.instance.owner_department ) if owner_user and owner_department: raise serializers.ValidationError( _("Календарь не может одновременно принадлежать пользователю и отделу.") ) return attrs class CalendarSubscriptionSerializer(serializers.ModelSerializer): """Сериализатор для подписки на календарь (чтение).""" calendar_title = serializers.CharField(source="calendar.title", read_only=True) calendar_color = serializers.CharField(source="calendar.color", read_only=True) user_name = serializers.SerializerMethodField() class Meta: model = CalendarSubscription fields = ( "id", "calendar", "calendar_title", "calendar_color", "user", "user_name", "is_visible", "color_override", "can_edit", "can_manage", "notify_on_new_event", "notify_on_event_change", "subscribed_at", ) read_only_fields = ("id", "subscribed_at") def get_user_name(self, obj): """Возвращает имя пользователя.""" return obj.user.get_full_name() or obj.user.username class CalendarSubscriptionWriteSerializer(serializers.ModelSerializer): """Сериализатор для создания/обновления подписки.""" class Meta: model = CalendarSubscription fields = ( "id", "calendar", "user", "is_visible", "color_override", "can_edit", "can_manage", "notify_on_new_event", "notify_on_event_change", ) read_only_fields = ("id",) def validate(self, attrs): """Валидация подписки.""" calendar = attrs.get("calendar") or ( self.instance.calendar if self.instance else None ) user = attrs.get("user") or (self.instance.user if self.instance else None) # Проверяем, что пользователь не подписывается на свой личный календарь дважды if calendar and user and calendar.owner_user_id == user.id: # Владелец может иметь подписку, но это optional pass return attrs class CalendarInviteSerializer(serializers.Serializer): """Сериализатор для приглашения пользователя в календарь.""" user_id = serializers.IntegerField( required=False, help_text=_("ID пользователя для приглашения"), ) username = serializers.CharField( required=False, help_text=_("Username пользователя для приглашения"), ) can_edit = serializers.BooleanField( default=False, help_text=_("Может редактировать события"), ) can_manage = serializers.BooleanField( default=False, help_text=_("Может управлять правами других пользователей"), ) notify = serializers.BooleanField( default=True, help_text=_("Отправить уведомление о приглашении"), ) def validate(self, attrs): """Проверяет, что указан user_id или username.""" if not attrs.get("user_id") and not attrs.get("username"): raise serializers.ValidationError( _("Необходимо указать user_id или username.") ) if attrs.get("user_id") and attrs.get("username"): raise serializers.ValidationError( _("Укажите либо user_id, либо username, не оба параметра.") ) return attrs class CalendarInviteBulkSerializer(serializers.Serializer): """Сериализатор для массового приглашения пользователей.""" user_ids = serializers.ListField( child=serializers.IntegerField(), required=False, help_text=_("Список ID пользователей для приглашения"), ) usernames = serializers.ListField( child=serializers.CharField(), required=False, help_text=_("Список username пользователей для приглашения"), ) can_edit = serializers.BooleanField( default=False, help_text=_("Может редактировать события"), ) can_manage = serializers.BooleanField( default=False, help_text=_("Может управлять правами других пользователей"), ) notify = serializers.BooleanField( default=True, help_text=_("Отправить уведомления о приглашении"), ) def validate(self, attrs): """Проверяет, что указан хотя бы один список.""" if not attrs.get("user_ids") and not attrs.get("usernames"): raise serializers.ValidationError( _("Необходимо указать user_ids или usernames.") ) if attrs.get("user_ids") and attrs.get("usernames"): raise serializers.ValidationError( _("Укажите либо user_ids, либо usernames, не оба параметра.") ) # Проверка на пустые списки user_ids = attrs.get("user_ids", []) usernames = attrs.get("usernames", []) if user_ids and len(user_ids) == 0: raise serializers.ValidationError( _("Список user_ids не может быть пустым.") ) if usernames and len(usernames) == 0: raise serializers.ValidationError( _("Список usernames не может быть пустым.") ) return attrs