diff --git a/backend/api/apps.py b/backend/api/apps.py
index 0362780c..e30faecf 100644
--- a/backend/api/apps.py
+++ b/backend/api/apps.py
@@ -8,6 +8,5 @@ class ApiConfig(AppConfig):
def ready(self):
"""Инициализация при загрузке приложения."""
- # Применяем патч для django-scheduler (исправление бага с byweekday)
- import schedule_patch
- schedule_patch.apply_patch()
+ # Патч для django-scheduler теперь применяется в scheduling.apps.SchedulingConfig
+ pass
diff --git a/backend/api/v1/calendar/__init__.py b/backend/api/v1/calendar/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/api/v1/calendar/serializers.py b/backend/api/v1/calendar/serializers.py
deleted file mode 100644
index 28ea8b48..00000000
--- a/backend/api/v1/calendar/serializers.py
+++ /dev/null
@@ -1,707 +0,0 @@
-# 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
diff --git a/backend/api/v1/calendar/utils.py b/backend/api/v1/calendar/utils.py
deleted file mode 100644
index 9174ebf3..00000000
--- a/backend/api/v1/calendar/utils.py
+++ /dev/null
@@ -1,64 +0,0 @@
-# backend/api/v1/calendar/utils.py
-from __future__ import annotations
-
-import datetime as dt
-import hashlib
-from typing import Optional, Tuple
-
-from django.db.models import Max, Q
-from django.utils import timezone
-from django.utils.dateparse import parse_date, parse_datetime
-
-MAX_WINDOW_DAYS = 400
-CACHE_TTL = 60
-RESPONSE_HEADERS = {"Cache-Control": f"private, max-age={CACHE_TTL}"}
-
-
-def parse_to_date(value: Optional[str]) -> Optional[dt.date]:
- if not value:
- return None
- dtime = parse_datetime(value)
- return dtime.date() if dtime else parse_date(value)
-
-
-def get_range(request) -> Tuple[Optional[dt.date], Optional[dt.date]]:
- s = request.query_params.get("start") or request.query_params.get("from")
- e = request.query_params.get("end") or request.query_params.get("to")
- if not (s and e):
- return None, None
- start = parse_to_date(s)
- end = parse_to_date(e)
- if not (start and end):
- return None, None
- if end < start:
- start, end = end, start
- if (end - start).days > MAX_WINDOW_DAYS:
- end = start + dt.timedelta(days=MAX_WINDOW_DAYS)
- return start, end
-
-
-def etag_for(payload_key: str, last_modified: Optional[dt.datetime], count: int) -> str:
- src = f"{payload_key}:{int(last_modified.timestamp()) if last_modified else 0}:{count}"
- return hashlib.sha1(src.encode()).hexdigest()
-
-
-def last_modified(qs, model) -> Optional[dt.datetime]:
- field_names = {f.name for f in model._meta.get_fields() if hasattr(f, "name")}
- field = "updated_at" if "updated_at" in field_names else "created_at"
- return qs.aggregate(m=Max(field)).get("m")
-
-
-def is_hr(user) -> bool:
- return user.is_active and (
- user.groups.filter(name="HR").exists()
- or user.has_perm("requests_app.can_view_all_requests")
- or user.has_perm("requests_app.can_process_requests")
- )
-
-
-def can_view_department(user, dept_id: int) -> bool:
- if is_hr(user):
- return True
- if user.headed_departments.filter(pk=dept_id).exists():
- return True
- return user.departments_links.filter(department_id=dept_id, is_active=True).exists()
\ No newline at end of file
diff --git a/backend/api/v1/calendar/views.py b/backend/api/v1/calendar/views.py
deleted file mode 100644
index bc3abdde..00000000
--- a/backend/api/v1/calendar/views.py
+++ /dev/null
@@ -1,1095 +0,0 @@
-from __future__ import annotations
-
-import logging
-from datetime import date
-from typing import Any, Dict, List, Optional, Type
-
-from calendar_app.cache import (
- invalidate_calendar_cache,
- invalidate_event_cache,
- invalidate_subscription_cache,
-)
-from calendar_app.models import CalendarEvent
-from calendar_app.services.recurrence import expand_event_occurrences
-from django.http import Http404
-from django.utils.dateparse import parse_date
-from employees.constants import DeptPerm
-from rest_framework import serializers, status
-from rest_framework.decorators import action as rest_action
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.renderers import JSONRenderer
-from rest_framework.response import Response
-from rest_framework.viewsets import ModelViewSet
-
-from ..permissions import AdminOrDeptAllowed
-from .serializers import (
- CalendarEventSerializer,
- CalendarEventWriteSerializer,
- CalendarInviteBulkSerializer,
- CalendarInviteSerializer,
- CalendarSerializer,
- CalendarSubscriptionSerializer,
- CalendarSubscriptionWriteSerializer,
- CalendarWriteSerializer,
-)
-
-logger = logging.getLogger(__name__)
-
-
-class CalendarEventsViewSet(ModelViewSet):
- """Единый CRUD по событиям календаря (компания/отдел) с развёрткой повторяемости.
-
- list:
- Возвращает МАТЕРИАЛИЗОВАННЫЕ вхождения в окне [start, end).
- Если указан department_id — события отдела, иначе — компании.
-
- retrieve/create/update/partial_update/destroy:
- Работают с объектом CalendarEvent.
- """
-
- queryset = CalendarEvent.objects.select_related("department").order_by(
- "start_date", "start_time"
- )
- renderer_classes = [JSONRenderer]
- # ВАЖНО: Переопределяем глобальные DEFAULT_PERMISSION_CLASSES
- # Используем только get_permissions() для динамического определения прав
- permission_classes = []
-
- # ---------- Внутренний пермишен для отделов ----------
-
- class ManageCalendarPerm(AdminOrDeptAllowed):
- """Право на управление календарём отдела (для компании молчит)."""
-
- required_code = DeptPerm.MANAGE_CALENDAR
-
- # ---------- Подключение пермишенов ----------
-
- def get_permissions(self):
- """Настройка прав доступа в зависимости от действия.
-
- - list, retrieve: доступно всем авторизованным пользователям
- - create, update, partial_update, destroy для новой архитектуры:
- проверка через calendar.can_user_edit()
- - create, update, partial_update, destroy для компании (legacy):
- требуется staff/superuser
- - create, update, partial_update, destroy для отдела (legacy):
- требуется MANAGE_CALENDAR
- - create, update, partial_update, destroy для личного календаря
- (legacy): только владелец
- """
- action = getattr(self, "action", None)
- method = self.request.method if hasattr(self, "request") else None
-
- # Если action ещё не установлен, определяем по методу
- if action is None:
- if method in ["GET", "HEAD", "OPTIONS"]:
- return [IsAuthenticated()]
- elif method == "POST":
- action = "create"
- elif method in ["PUT", "PATCH"]:
- action = "update"
- elif method == "DELETE":
- action = "destroy"
-
- # Просмотр событий доступен всем авторизованным пользователям
- if action in ["list", "retrieve"]:
- return [IsAuthenticated()]
-
- # Для изменений проверяем новую архитектуру (calendar_id)
- from rest_framework.permissions import BasePermission
-
- class CalendarEditPermission(BasePermission):
- """Проверка прав редактирования через календари."""
-
- def has_permission(self, request, view):
- from calendar_app.models import Calendar
-
- # Пытаемся получить calendar_id из разных источников
- calendar_id = None
-
- # 1. Из тела запроса (create)
- if hasattr(request, "data") and isinstance(request.data, dict):
- calendar_id = request.data.get("calendar_id")
-
- # 2. Из query параметров
- if calendar_id is None:
- calendar_id = request.query_params.get("calendar_id")
-
- # 3. Для update/delete из объекта события
- if calendar_id is None and hasattr(view, "get_object"):
- try:
- event = view.get_object()
- calendar_id = event.calendar_id
- except Exception:
- pass
-
- # Если calendar_id указан — используем новую логику
- if calendar_id is not None:
- try:
- calendar = Calendar.objects.get(id=int(calendar_id))
- return calendar.can_user_edit(request.user)
- except (Calendar.DoesNotExist, ValueError):
- return False
-
- # Если calendar_id не указан — используем legacy логику
- return None # Пропускаем дальше к legacy проверкам
-
- # Проверяем через новую архитектуру
- calendar_perm = CalendarEditPermission()
- has_perm = calendar_perm.has_permission(self.request, self)
-
- # Если новая архитектура вернула результат — используем его
- if has_perm is not None:
- if has_perm:
- return [IsAuthenticated()]
- else:
- # Нет прав на редактирование календаря
- class DenyAll(BasePermission):
- def has_permission(self, request, view):
- return False
-
- return [DenyAll()]
-
- # LEGACY ЛОГИКА: calendar_id не указан, используем старую логику
- emp = self._employee_id(required=False)
- dep = self._dept_id(required=False)
-
- # Личный календарь — полный контроль только у владельца
- if emp is not None:
- # Проверка, что текущий пользователь = владелец календаря
- if self.request.user.id == emp:
- return [IsAuthenticated()]
- else:
- # Другой пользователь не может изменять чужой личный календарь
- class DenyAll(BasePermission):
- def has_permission(self, request, view):
- return False
-
- return [DenyAll()]
-
- # Календарь компании (legacy) — только администраторы
- if dep is None:
- from rest_framework.permissions import IsAdminUser
-
- return [IsAdminUser()]
-
- # Календарь отдела — требуется право MANAGE_CALENDAR
- self.kwargs = dict(self.kwargs)
- self.kwargs.setdefault("department_id", dep)
- return [self.ManageCalendarPerm()]
-
- # ---------- Сериализаторы ----------
-
- def get_serializer_class(self) -> Type[serializers.Serializer]:
- """Выбор сериализатора по действию.
-
- Returns:
- Type[serializers.Serializer]: Класс сериализатора.
- """
- if self.action in {"create", "update", "partial_update"}:
- return CalendarEventWriteSerializer
- return CalendarEventSerializer
-
- # ---------- Утилиты ----------
-
- def _dept_id(self, *, required: bool = False) -> Optional[int]:
- """Читает department_id из kwargs/query/body.
-
- ВАЖНО: *не* путать pk события с department_id.
-
- Args:
- required (bool): Если True — бросить 404 при отсутствии/ошибке.
-
- Raises:
- Http404: При некорректном или отсутствующем department_id (когда required=True).
-
- Returns:
- Optional[int]: PK отдела или None (компания или личный календарь).
- """
- # nested-url вида: /departments/{department_id}/events/
- for key in ("department_pk", "department_id"):
- if key in self.kwargs:
- try:
- return int(self.kwargs[key])
- except Exception:
- raise Http404("Некорректный department_id в URL.")
-
- req = getattr(self, "request", None)
- if req is not None:
- for key in ("department_id", "department"):
- val = req.query_params.get(key)
- if val is not None:
- try:
- return int(val)
- except Exception:
- raise Http404("Некорректный department_id в query.")
-
- data = getattr(req, "data", {}) if req is not None else {}
- if isinstance(data, dict):
- for key in ("department_id", "department"):
- if key in data and data[key] is not None:
- try:
- return int(data[key])
- except Exception:
- raise Http404("Некорректный department_id в теле запроса.")
-
- if required:
- raise Http404("Не указан department_id.")
- return None
-
- def _employee_id(self, *, required: bool = False) -> Optional[int]:
- """Читает employee_id из kwargs/query/body для личного календаря.
-
- Args:
- required (bool): Если True — бросить 404 при отсутствии/ошибке.
-
- Raises:
- Http404: При некорректном или отсутствующем employee_id (когда required=True).
-
- Returns:
- Optional[int]: PK сотрудника или None (компания/отдел).
- """
- # nested-url вида: /employees/{employee_id}/events/
- for key in ("employee_pk", "employee_id"):
- if key in self.kwargs:
- try:
- return int(self.kwargs[key])
- except Exception:
- raise Http404("Некорректный employee_id в URL.")
-
- req = getattr(self, "request", None)
- if req is not None:
- for key in ("employee_id", "employee"):
- val = req.query_params.get(key)
- if val is not None:
- try:
- return int(val)
- except Exception:
- raise Http404("Некорректный employee_id в query.")
-
- data = getattr(req, "data", {}) if req is not None else {}
- if isinstance(data, dict):
- for key in ("employee_id", "employee"):
- if key in data and data[key] is not None:
- try:
- return int(data[key])
- except Exception:
- raise Http404("Некорректный employee_id в теле запроса.")
-
- if required:
- raise Http404("Не указан employee_id.")
- return None
-
- # ---------- QuerySet ----------
-
- def get_queryset(self):
- """QS для list/list_raw фильтруем по компании/отделу/сотруднику/календарю, для detail — не фильтруем.
-
- Returns:
- QuerySet: Набор для текущего действия.
- """
- qs = super().get_queryset()
- action = getattr(self, "action", None)
-
- # Фильтруем для list и list_raw, для остальных (detail и т.д.) — полный QS
- if action not in ("list", "list_raw"):
- # detail — нужен полный QS, чтобы по pk находить любой объект
- return qs
-
- # Новая архитектура: calendar_id имеет приоритет
- calendar_id = self.request.query_params.get("calendar_id")
- if calendar_id is not None:
- try:
- return qs.filter(calendar_id=int(calendar_id))
- except ValueError:
- raise Http404("Некорректный calendar_id.")
-
- # Legacy логика: department_id / employee_id / company
- dep = self._dept_id(required=False)
- emp = self._employee_id(required=False)
-
- # Приоритет: employee_id > department_id > company
- if emp is not None:
- # Личный календарь сотрудника (legacy)
- return qs.filter(
- employee_id=emp, department__isnull=True, calendar__isnull=True
- )
- elif dep is not None:
- # Календарь отдела (legacy)
- return qs.filter(
- department_id=dep, employee__isnull=True, calendar__isnull=True
- )
- else:
- # Календарь компании (legacy)
- return qs.filter(
- department__isnull=True, employee__isnull=True, calendar__isnull=True
- )
-
- # ---------- LIST: материализация повторов ----------
-
- def list(self, request, *args, **kwargs):
- """Возвращает материализованные вхождения в окне [start, end).
-
- Query params:
- start (YYYY-MM-DD) — левая граница (включительно).
- end (YYYY-MM-DD) — правая граница (исключительно).
-
- Returns:
- Response: Массив объектов для FullCalendar.
- """
- start_str = request.query_params.get("start")
- end_str = request.query_params.get("end")
- if not start_str or not end_str:
- return Response([], status=status.HTTP_200_OK)
-
- r_start: Optional[date] = parse_date(start_str[:10])
- r_end: Optional[date] = parse_date(end_str[:10])
- if not r_start or not r_end:
- return Response(
- {"detail": "Некорректные параметры start/end."},
- status=status.HTTP_400_BAD_REQUEST,
- )
- if r_start >= r_end:
- return Response([], status=status.HTTP_200_OK)
-
- items: List[Dict[str, Any]] = []
- for ev in self.get_queryset():
- for oc in expand_event_occurrences(
- ev, range_start=r_start, range_end=r_end, max_instances=1000
- ):
- # 🔧 FIX: Автоматически конвертируем многодневные события в allDay
- # для корректного отображения в FullCalendar dayGridMonth
- all_day = oc.all_day
- start = oc.start
- end = oc.end
-
- if not all_day and start and end:
- # Проверяем длительность
- duration = end - start
- if duration.days > 1:
- # Многодневное событие -> делаем allDay и убираем время
- all_day = True
- start = start.replace(hour=0, minute=0, second=0, microsecond=0)
- end = end.replace(hour=0, minute=0, second=0, microsecond=0)
-
- items.append(
- {
- "id": ev.id,
- "title": ev.title,
- "start": start.isoformat() if start else None,
- "end": end.isoformat() if end else None,
- "allDay": all_day,
- "color": ev.color or None,
- "recurrence": ev.recurrence,
- "department_id": ev.department_id,
- }
- )
- return Response(items, status=status.HTTP_200_OK)
-
- @rest_action(detail=False, methods=["GET"])
- def list_raw(self, request, *args, **kwargs):
- """Возвращает список событий БЕЗ материализации вхождений.
-
- Используется для выбора события при перемещении между календарями.
- Поддерживает те же параметры фильтрации, что и get_queryset():
- - calendar_id (новая архитектура)
- - department_id (legacy)
- - employee_id (legacy)
- - без параметров = события компании (legacy)
-
- Returns:
- Response: Массив объектов CalendarEvent (сериализованных).
- """
- queryset = self.get_queryset()
- serializer = self.get_serializer(queryset, many=True)
- return Response(serializer.data, status=status.HTTP_200_OK)
-
- # ---------- Создание/изменение/удаление ----------
-
- def perform_create(self, serializer: CalendarEventWriteSerializer) -> None:
- """Сохраняет объект, подставляя department_id/employee_id из контекста и автора.
-
- Raises:
- ValidationError: Если сериализатор невалиден (поднимет сам DRF).
- """
- dep = self._dept_id(required=False)
- emp = self._employee_id(required=False)
-
- # Приоритет: employee_id > department_id
- if emp is not None:
- serializer.save(
- employee_id=emp,
- department_id=None,
- created_by=self.request.user,
- )
- else:
- serializer.save(
- department_id=dep
- if dep is not None
- else serializer.validated_data.get("department_id"),
- employee_id=None,
- created_by=self.request.user,
- )
- invalidate_event_cache()
-
- def perform_update(self, serializer: CalendarEventWriteSerializer) -> None:
- """Обновляет событие и чистит кеш."""
- serializer.save()
- invalidate_event_cache()
-
- def perform_destroy(self, instance: CalendarEvent) -> None:
- """Удаляет событие и чистит кеш."""
- instance.delete()
- invalidate_event_cache()
-
- # ---------- Проверка прав ----------
-
- @rest_action(
- detail=True,
- methods=["get"],
- permission_classes=[IsAuthenticated],
- url_path="permissions",
- )
- def permissions(self, request, pk=None):
- """Проверяет права пользователя на редактирование/удаление события.
-
- Returns:
- Response: {
- "can_edit": bool,
- "can_delete": bool,
- "can_view": bool (всегда True для авторизованных)
- }
- """
- event = self.get_object()
- user = request.user
-
- # Просмотр доступен всем авторизованным
- can_view = True
-
- # Права на редактирование и удаление
- can_edit = False
- can_delete = False
-
- # Проверка прав
- if user.is_superuser or user.is_staff:
- # Админы могут всё
- can_edit = True
- can_delete = True
- elif event.employee_id is not None:
- # Личное событие — редактировать может только владелец
- if event.employee_id == user.id:
- can_edit = True
- can_delete = True
- elif event.department_id is None:
- # Событие компании — только админы (уже проверено выше)
- pass
- else:
- # Событие отдела — проверяем право MANAGE_CALENDAR
- try:
- from employees.models import EmployeeDepartment
-
- # Проверяем, является ли пользователь руководителем отдела
- if hasattr(event.department, "head_id"):
- if event.department.head_id == user.id:
- can_edit = True
- can_delete = True
-
- # Проверяем наличие права MANAGE_CALENDAR
- if not can_edit:
- has_perm = EmployeeDepartment.objects.filter(
- employee_id=user.id,
- department_id=event.department_id,
- is_active=True,
- role__scoped_permissions__code=DeptPerm.MANAGE_CALENDAR,
- ).exists()
-
- if has_perm:
- can_edit = True
- can_delete = True
- except Exception as e:
- logger.error(f"Error checking permissions: {e}")
-
- return Response(
- {"can_view": can_view, "can_edit": can_edit, "can_delete": can_delete}
- )
-
-
-class CalendarViewSet(ModelViewSet):
- """CRUD операции для календарей.
-
- Доступные действия:
- - list: список календарей (доступные текущему пользователю)
- - create: создание нового календаря (только владельцы/админы)
- - retrieve: просмотр календаря
- - update/partial_update: обновление календаря (только владелец)
- - destroy: удаление календаря (только владелец)
- - subscribe: подписка на календарь
- - unsubscribe: отписка от календаря
- """
-
- permission_classes = [IsAuthenticated]
- renderer_classes = [JSONRenderer]
-
- def get_queryset(self):
- """Возвращает календари, доступные текущему пользователю."""
- from calendar_app.models import Calendar
-
- user = self.request.user
-
- if self.action == "list":
- # Для списка показываем только доступные календари
- # Сначала получаем доступные, потом оптимизируем
- qs = Calendar.objects.get_available_for_user(user)
- else:
- # Для остальных действий возвращаем все календари
- # (права проверяются в permissions)
- qs = Calendar.objects.all()
-
- # Оптимизация N+1 запросов для всех случаев
- return qs.select_related(
- "owner_user", "owner_department", "created_by"
- ).prefetch_related("subscriptions", "subscriptions__user")
-
- def get_serializer_class(self):
- """Выбор сериализатора по действию."""
- if self.action in {"create", "update", "partial_update"}:
- return CalendarWriteSerializer
- return CalendarSerializer
-
- def get_permissions(self):
- """Настройка прав доступа."""
- if self.action in ["list", "retrieve"]:
- return [IsAuthenticated()]
- elif self.action in ["create"]:
- # Создавать календари могут все авторизованные пользователи
- return [IsAuthenticated()]
- elif self.action in ["update", "partial_update", "destroy"]:
- # Изменять/удалять могут только владельцы
- return [IsAuthenticated()]
- else:
- return [IsAuthenticated()]
-
- def create(self, request, *args, **kwargs):
- """Создание календаря с корректным сериализатором для ответа."""
- write_serializer = self.get_serializer(data=request.data)
- write_serializer.is_valid(raise_exception=True)
- self.perform_create(write_serializer)
-
- # Для ответа используем CalendarSerializer
- instance = write_serializer.instance
- read_serializer = CalendarSerializer(instance, context={"request": request})
-
- from rest_framework import status
- from rest_framework.response import Response
-
- headers = self.get_success_headers(read_serializer.data)
- return Response(
- read_serializer.data, status=status.HTTP_201_CREATED, headers=headers
- )
-
- def perform_create(self, serializer):
- """Создание календаря."""
- # Владелец определяется в serializer.save()
- serializer.save()
- invalidate_calendar_cache()
-
- def perform_update(self, serializer):
- """Обновление календаря."""
- # Проверяем права владельца
- calendar = self.get_object()
- user = self.request.user
-
- if not (user.is_superuser or user.is_staff or calendar.is_owner(user)):
- from rest_framework.exceptions import PermissionDenied
-
- raise PermissionDenied("Только владелец может изменять календарь.")
-
- instance = serializer.save()
- invalidate_calendar_cache(calendar_id=instance.id)
-
- def perform_destroy(self, instance):
- """Удаление календаря."""
- user = self.request.user
-
- if not (user.is_superuser or user.is_staff or instance.is_owner(user)):
- from rest_framework.exceptions import PermissionDenied
-
- raise PermissionDenied("Только владелец может удалять календарь.")
-
- calendar_id = instance.id
- instance.delete()
- invalidate_calendar_cache(calendar_id=calendar_id)
-
- @rest_action(
- detail=True,
- methods=["post"],
- permission_classes=[IsAuthenticated],
- url_path="subscribe",
- )
- def subscribe(self, request, pk=None):
- """Подписка на календарь.
-
- Body params:
- can_edit (bool, optional): Право на редактирование событий
- can_manage (bool, optional): Право на управление календарем
- """
- from calendar_app.models import Calendar, CalendarSubscription
-
- calendar = self.get_object()
- user = request.user
-
- # Проверяем, доступен ли календарь для подписки
- if not calendar.can_user_view(user):
- from rest_framework.exceptions import PermissionDenied
-
- raise PermissionDenied("Календарь недоступен для подписки.")
-
- # Проверяем, не подписан ли уже пользователь
- existing = CalendarSubscription.objects.filter(
- calendar=calendar, user=user
- ).first()
-
- if existing:
- return Response(
- {"detail": "Вы уже подписаны на этот календарь."},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Создаем подписку
- can_edit = request.data.get("can_edit", False)
- can_manage = request.data.get("can_manage", False)
-
- # Только владелец может выдавать права can_edit и can_manage
- if (can_edit or can_manage) and not calendar.is_owner(user):
- can_edit = False
- can_manage = False
-
- subscription = CalendarSubscription.objects.create(
- calendar=calendar,
- user=user,
- can_edit=can_edit,
- can_manage=can_manage,
- )
-
- serializer = CalendarSubscriptionSerializer(
- subscription, context={"request": request}
- )
- return Response(serializer.data, status=status.HTTP_201_CREATED)
-
- @rest_action(
- detail=True,
- methods=["post"],
- permission_classes=[IsAuthenticated],
- url_path="unsubscribe",
- )
- def unsubscribe(self, request, pk=None):
- """Отписка от календаря."""
- from calendar_app.models import CalendarSubscription
-
- calendar = self.get_object()
- user = request.user
-
- subscription = CalendarSubscription.objects.filter(
- calendar=calendar, user=user
- ).first()
-
- if not subscription:
- return Response(
- {"detail": "Вы не подписаны на этот календарь."},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- subscription.delete()
- return Response(
- {"detail": "Подписка успешно отменена."},
- status=status.HTTP_200_OK,
- )
-
- @rest_action(
- detail=False,
- methods=["get"],
- permission_classes=[IsAuthenticated],
- url_path="my-calendars",
- )
- def my_calendars(self, request):
- """Список всех календарей, доступных текущему пользователю."""
- from calendar_app.models import Calendar
-
- user = request.user
- calendars = Calendar.objects.get_available_for_user(user)
-
- serializer = CalendarSerializer(
- calendars, many=True, context={"request": request}
- )
- return Response(serializer.data, status=status.HTTP_200_OK)
-
- @rest_action(
- detail=True,
- methods=["post"],
- permission_classes=[IsAuthenticated],
- url_path="invite",
- )
- def invite(self, request, pk=None):
- """Приглашение пользователя в календарь.
-
- Только владелец календаря может приглашать пользователей.
-
- Body params:
- user_id (int, optional): ID пользователя для приглашения
- username (str, optional): Username пользователя
- can_edit (bool, optional): Право редактирования событий
- can_manage (bool, optional): Право управления календарем
- notify (bool, optional): Отправить уведомление (default: True)
- """
- from calendar_app.models import CalendarSubscription
- from django.contrib.auth import get_user_model
-
- User = get_user_model()
- calendar = self.get_object()
- owner = request.user
-
- # Проверяем, что пользователь - владелец календаря
- if not calendar.is_owner(owner):
- from rest_framework.exceptions import PermissionDenied
-
- raise PermissionDenied(
- "Только владелец календаря может приглашать пользователей."
- )
-
- # Валидация данных
- serializer = CalendarInviteSerializer(data=request.data)
- serializer.is_valid(raise_exception=True)
-
- # Получаем пользователя по user_id или username
- user_id = serializer.validated_data.get("user_id")
- username = serializer.validated_data.get("username")
-
- try:
- if user_id:
- invited_user = User.objects.get(id=user_id)
- else:
- invited_user = User.objects.get(username=username)
- except User.DoesNotExist:
- return Response(
- {"detail": "Пользователь не найден."},
- status=status.HTTP_404_NOT_FOUND,
- )
-
- # Проверяем, не приглашает ли владелец сам себя
- if invited_user.id == owner.id:
- return Response(
- {"detail": "Вы не можете пригласить самого себя."},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Проверяем, не подписан ли уже пользователь
- existing = CalendarSubscription.objects.filter(
- calendar=calendar, user=invited_user
- ).first()
-
- if existing:
- return Response(
- {
- "detail": "Пользователь уже подписан на этот календарь.",
- "subscription_id": existing.id,
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Создаем подписку с правами
- can_edit = serializer.validated_data.get("can_edit", False)
- can_manage = serializer.validated_data.get("can_manage", False)
- notify = serializer.validated_data.get("notify", True)
-
- subscription = CalendarSubscription.objects.create(
- calendar=calendar,
- user=invited_user,
- can_edit=can_edit,
- can_manage=can_manage,
- is_visible=True,
- )
-
- # Отправляем уведомление
- if notify:
- self._send_invitation_notification(
- calendar=calendar,
- invited_user=invited_user,
- owner=owner,
- can_edit=can_edit,
- can_manage=can_manage,
- )
-
- invalidate_subscription_cache(user_id=invited_user.id)
-
- response_serializer = CalendarSubscriptionSerializer(
- subscription, context={"request": request}
- )
- return Response(response_serializer.data, status=status.HTTP_201_CREATED)
-
- @rest_action(
- detail=True,
- methods=["post"],
- permission_classes=[IsAuthenticated],
- url_path="invite-bulk",
- )
- def invite_bulk(self, request, pk=None):
- """Массовое приглашение пользователей в календарь.
-
- Body params:
- user_ids (list[int], optional): Список ID пользователей
- usernames (list[str], optional): Список username
- can_edit (bool, optional): Право редактирования событий
- can_manage (bool, optional): Право управления календарем
- notify (bool, optional): Отправить уведомления (default: True)
- """
- from calendar_app.models import CalendarSubscription
- from django.contrib.auth import get_user_model
-
- User = get_user_model()
- calendar = self.get_object()
- owner = request.user
-
- # Проверяем, что пользователь - владелец календаря
- if not calendar.is_owner(owner):
- from rest_framework.exceptions import PermissionDenied
-
- raise PermissionDenied(
- "Только владелец календаря может приглашать пользователей."
- )
-
- # Валидация данных
- serializer = CalendarInviteBulkSerializer(data=request.data)
- serializer.is_valid(raise_exception=True)
-
- # Получаем список пользователей
- user_ids = serializer.validated_data.get("user_ids")
- usernames = serializer.validated_data.get("usernames")
- can_edit = serializer.validated_data.get("can_edit", False)
- can_manage = serializer.validated_data.get("can_manage", False)
- notify = serializer.validated_data.get("notify", True)
-
- # Находим пользователей
- if user_ids:
- users = User.objects.filter(id__in=user_ids)
- else:
- users = User.objects.filter(username__in=usernames)
-
- if not users.exists():
- return Response(
- {"detail": "Ни один пользователь не найден."},
- status=status.HTTP_404_NOT_FOUND,
- )
-
- # Исключаем владельца из списка
- users = users.exclude(id=owner.id)
-
- # Создаем подписки
- created_subscriptions = []
- already_subscribed = []
- errors = []
-
- for user in users:
- # Проверяем, не подписан ли уже
- existing = CalendarSubscription.objects.filter(
- calendar=calendar, user=user
- ).first()
-
- if existing:
- already_subscribed.append(
- {
- "user_id": user.id,
- "username": user.username,
- "subscription_id": existing.id,
- }
- )
- continue
-
- try:
- subscription = CalendarSubscription.objects.create(
- calendar=calendar,
- user=user,
- can_edit=can_edit,
- can_manage=can_manage,
- is_visible=True,
- )
-
- # Отправляем уведомление
- if notify:
- self._send_invitation_notification(
- calendar=calendar,
- invited_user=user,
- owner=owner,
- can_edit=can_edit,
- can_manage=can_manage,
- )
-
- invalidate_subscription_cache(user_id=user.id)
-
- created_subscriptions.append(
- CalendarSubscriptionSerializer(
- subscription, context={"request": request}
- ).data
- )
- except Exception as e:
- logger.error(f"Error creating subscription for user {user.id}: {e}")
- errors.append(
- {"user_id": user.id, "username": user.username, "error": str(e)}
- )
-
- return Response(
- {
- "created": created_subscriptions,
- "already_subscribed": already_subscribed,
- "errors": errors,
- "total_created": len(created_subscriptions),
- "total_already_subscribed": len(already_subscribed),
- "total_errors": len(errors),
- },
- status=status.HTTP_201_CREATED
- if created_subscriptions
- else status.HTTP_200_OK,
- )
-
- def _send_invitation_notification(
- self, calendar, invited_user, owner, can_edit, can_manage
- ):
- """Отправляет уведомление о приглашении в календарь."""
- try:
- from notifications.signals import notify
-
- # Формируем текст о правах
- permissions_text = []
- if can_edit:
- permissions_text.append("редактирование событий")
- if can_manage:
- permissions_text.append("управление календарем")
-
- permissions_str = (
- f" с правами: {', '.join(permissions_text)}" if permissions_text else ""
- )
-
- owner_name = owner.get_full_name() or owner.username
-
- notify.send(
- sender=owner,
- recipient=invited_user,
- verb='calendar_invitation',
- action_object=calendar,
- description=(
- f'{owner_name} пригласил вас в календарь '
- f'"{calendar.title}"{permissions_str}.'
- ),
- action_url='/calendar',
- data={
- 'title': f'Приглашение в календарь: {calendar.title}',
- 'calendar_id': calendar.id,
- 'can_edit': can_edit,
- 'can_manage': can_manage,
- },
- )
- logger.info(
- f"Sent invitation notification to user {invited_user.id} "
- f"for calendar {calendar.id}"
- )
- except Exception as e:
- logger.error(f"Error sending invitation notification: {e}")
-
-
-class CalendarSubscriptionViewSet(ModelViewSet):
- """CRUD операции для подписок на календари.
-
- Доступные действия:
- - list: список подписок текущего пользователя
- - create: создание новой подписки
- - retrieve: просмотр подписки
- - update/partial_update: обновление подписки (права доступа)
- - destroy: удаление подписки
- """
-
- permission_classes = [IsAuthenticated]
- renderer_classes = [JSONRenderer]
-
- def get_queryset(self):
- """Подписки пользователя + подписки на его календари."""
- from calendar_app.models import CalendarSubscription
- from django.db.models import Q
-
- user = self.request.user
-
- if user.is_superuser or user.is_staff:
- # Админы видят все подписки
- return CalendarSubscription.objects.all()
-
- # Обычные пользователи видят:
- # 1. Свои подписки (user=user)
- # 2. Подписки на календари, где они владельцы
- return CalendarSubscription.objects.filter(
- Q(user=user) # Свои подписки
- | Q(calendar__owner_user=user) # Владелец календаря
- ).distinct()
-
- def get_serializer_class(self):
- """Выбор сериализатора по действию."""
- if self.action in {"create", "update", "partial_update"}:
- return CalendarSubscriptionWriteSerializer
- return CalendarSubscriptionSerializer
-
- def perform_create(self, serializer):
- """Создание подписки."""
- # Пользователь устанавливается автоматически в сериализаторе
- subscription = serializer.save(user=self.request.user)
- invalidate_subscription_cache(user_id=subscription.user_id)
-
- def perform_update(self, serializer):
- """Обновление подписки."""
- subscription = self.get_object()
- user = self.request.user
-
- # Получаем изменяемые поля
- validated_data = serializer.validated_data
-
- # Проверяем, какие поля пытаются изменить
- permission_fields = {"can_edit", "can_manage"}
- personal_fields = {"is_visible", "color_override"}
-
- changing_permissions = any(
- field in validated_data for field in permission_fields
- )
- changing_personal = any(field in validated_data for field in personal_fields)
-
- # Права (can_edit, can_manage) - только владелец календаря
- if changing_permissions and not subscription.calendar.is_owner(user):
- from rest_framework.exceptions import PermissionDenied
-
- raise PermissionDenied(
- "Только владелец календаря может изменять права подписки."
- )
-
- # Личные настройки (is_visible, color_override) - владелец подписки
- is_subscription_owner = subscription.user == user
- is_calendar_owner = subscription.calendar.is_owner(user)
-
- if changing_personal and not is_subscription_owner:
- if not is_calendar_owner:
- from rest_framework.exceptions import PermissionDenied
-
- raise PermissionDenied(
- "Вы можете изменять только свои личные настройки."
- )
-
- updated = serializer.save()
- invalidate_subscription_cache(user_id=updated.user_id)
-
- def perform_destroy(self, instance):
- """Удаление подписки."""
- user = self.request.user
-
- # Удалять подписку может владелец подписки или календаря
- if not (instance.user == user or instance.calendar.is_owner(user)):
- from rest_framework.exceptions import PermissionDenied
-
- raise PermissionDenied("Вы не можете удалить эту подписку.")
-
- user_id = instance.user_id
- instance.delete()
- invalidate_subscription_cache(user_id=user_id)
diff --git a/backend/calendar_app/__init__.py b/backend/calendar_app/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/calendar_app/admin.py b/backend/calendar_app/admin.py
deleted file mode 100644
index 65675b9e..00000000
--- a/backend/calendar_app/admin.py
+++ /dev/null
@@ -1,306 +0,0 @@
-# backend/calendar_app/admin.py
-from __future__ import annotations
-
-from typing import Iterable
-
-from django.contrib import admin, messages
-from django.db.models import QuerySet
-from django.http import HttpRequest
-from django.utils.html import format_html
-from django.utils.translation import gettext_lazy as _
-
-from .admin_calendar import CalendarAdmin # noqa: F401
-from .admin_subscription import (
- CalendarSubscriptionAdmin, # noqa: F401
- CalendarSubscriptionInline,
-)
-from .models import Calendar, CalendarEvent, CalendarSubscription, Recurrence
-
-
-class ScopeListFilter(admin.SimpleListFilter):
- """Фильтр области события: компания или отдел.
-
- Values:
- - 'company' → department IS NULL
- - 'dept' → department IS NOT NULL
- """
-
- title = _("Область")
- parameter_name = "scope"
-
- def lookups(
- self, request: HttpRequest, model_admin: admin.ModelAdmin
- ) -> Iterable[tuple[str, str]]:
- """Возвращает варианты выбора фильтра.
-
- Args:
- request: Текущий запрос.
- model_admin: Админ-класс.
-
- Returns:
- Iterable[tuple[str, str]]: (value, label)
- """
- return (
- ("company", _("Компания")),
- ("dept", _("Отдел")),
- )
-
- def queryset(self, request: HttpRequest, queryset):
- """Применяет фильтр к queryset.
-
- Args:
- request: Текущий запрос.
- queryset: Исходный queryset.
-
- Returns:
- QuerySet: Отфильтрованный queryset.
- """
- val = self.value()
- if val == "company":
- return queryset.filter(department__isnull=True)
- if val == "dept":
- return queryset.filter(department__isnull=False)
- return queryset
-
-
-class HasTimeListFilter(admin.SimpleListFilter):
- """Фильтр наличия времени у события (есть оба time или нет ни одного)."""
-
- title = _("Есть время")
- parameter_name = "has_time"
-
- def lookups(
- self, request: HttpRequest, model_admin: admin.ModelAdmin
- ) -> Iterable[tuple[str, str]]:
- """Опции фильтра (Да/Нет)."""
- return (
- ("yes", _("Да")),
- ("no", _("Нет")),
- )
-
- def queryset(self, request: HttpRequest, queryset):
- """Применяет фильтр к queryset."""
- val = self.value()
- if val == "yes":
- return queryset.filter(start_time__isnull=False, end_time__isnull=False)
- if val == "no":
- return queryset.filter(start_time__isnull=True, end_time__isnull=True)
- return queryset
-
-
-@admin.register(CalendarEvent)
-class CalendarEventAdmin(admin.ModelAdmin):
- """Админка для модели CalendarEvent (события календаря компании и отделов)."""
-
- # ===== Список =====
- list_display = (
- "id",
- "title",
- "calendar_display",
- "scope_display",
- "start_date",
- "end_date",
- "start_time",
- "end_time",
- "all_day",
- "recurrence",
- "color_swatch",
- "created_by",
- "created_at",
- )
- list_select_related = ("calendar", "department", "employee", "created_by")
- list_per_page = 50
- ordering = ("start_date", "start_time")
-
- # ===== Поиск/Фильтры/Иерархия =====
- search_fields = (
- "title",
- "description",
- "location",
- "calendar__title",
- "department__name",
- "department__title",
- "department__code",
- "employee__username",
- "employee__first_name",
- "employee__last_name",
- )
- list_filter = (
- "calendar",
- "department",
- "recurrence",
- "all_day",
- ("start_date", admin.DateFieldListFilter),
- ("created_at", admin.DateFieldListFilter),
- HasTimeListFilter,
- ScopeListFilter,
- )
- date_hierarchy = "start_date"
-
- # ===== Поля формы =====
- readonly_fields = ("created_at",)
- raw_id_fields = ("calendar", "department", "employee", "created_by")
- fieldsets = (
- (
- _("Календарь"),
- {
- "fields": ("calendar",),
- "description": _(
- "Если указан календарь, поля department и employee "
- "игнорируются (новый режим)"
- ),
- },
- ),
- (
- _("Основное (LEGACY)"),
- {
- "fields": (
- "title",
- "description",
- "department",
- "employee",
- "location",
- ),
- "description": _(
- "Department/Employee используются только если calendar=NULL"
- ),
- },
- ),
- (
- _("Даты и время"),
- {
- "fields": (
- ("start_date", "end_date"),
- ("start_time", "end_time"),
- "all_day",
- ),
- },
- ),
- (
- _("Отображение"),
- {
- "fields": ("recurrence", "color"),
- },
- ),
- (
- _("Служебное"),
- {
- "fields": ("created_by", "created_at"),
- },
- ),
- )
-
- # ===== Экшены =====
- actions = ("make_all_day", "clear_time", "set_annual", "set_one_time")
-
- @admin.display(description=_("Календарь"), ordering="calendar__title")
- def calendar_display(self, obj: CalendarEvent) -> str:
- """Возвращает название календаря или '—'."""
- if obj.calendar:
- return str(obj.calendar)
- return "—"
-
- @admin.display(description=_("Область (LEGACY)"), ordering="department_id")
- def scope_display(self, obj: CalendarEvent) -> str:
- """Возвращает область для legacy событий.
-
- Args:
- obj: Объект CalendarEvent.
-
- Returns:
- str: Человекочитаемое имя области.
- """
- if obj.calendar_id:
- return _("→ используется Calendar")
- if obj.employee_id:
- return f"Личный ({obj.employee})"
- if obj.department_id:
- return str(obj.department)
- return _("Компания")
-
- @admin.display(description=_("Цвет"))
- def color_swatch(self, obj: CalendarEvent) -> str:
- """Отрисовывает mini-swatch цвета + hex-код.
-
- Args:
- obj: Объект CalendarEvent.
-
- Returns:
- str: HTML.
- """
- if not obj.color:
- return "—"
- return format_html(
- '{}',
- obj.color,
- obj.color,
- )
-
- # ===== Экшены логика =====
- @admin.action(description=_("Сделать целодневными (очистить время)"))
- def make_all_day(
- self, request: HttpRequest, queryset: QuerySet[CalendarEvent]
- ) -> None:
- """Делает выбранные события целодневными.
-
- Args:
- request: Запрос администратора.
- queryset: Выбранные события.
- """
- updated = queryset.update(start_time=None, end_time=None, all_day=True)
- self.message_user(
- request, _(f"Обновлено событий: {updated}"), level=messages.SUCCESS
- )
-
- @admin.action(description=_("Очистить время (оставить даты как есть)"))
- def clear_time(
- self, request: HttpRequest, queryset: QuerySet[CalendarEvent]
- ) -> None:
- """Очищает время начала/окончания у выбранных событий.
-
- Args:
- request: Запрос администратора.
- queryset: Выбранные события.
- """
- updated = queryset.update(start_time=None, end_time=None)
- self.message_user(
- request, _(f"Очищено время у событий: {updated}"), level=messages.SUCCESS
- )
-
- @admin.action(description=_("Пометить как ежегодные"))
- def set_annual(
- self, request: HttpRequest, queryset: QuerySet[CalendarEvent]
- ) -> None:
- """Ставит повторяемость 'Ежегодно' у выбранных событий."""
- updated = queryset.update(recurrence=Recurrence.ANNUAL)
- self.message_user(request, _(f"Обновлено: {updated}"), level=messages.SUCCESS)
-
- @admin.action(description=_("Пометить как одноразовые"))
- def set_one_time(
- self, request: HttpRequest, queryset: QuerySet[CalendarEvent]
- ) -> None:
- """Ставит повторяемость 'Одноразовое' у выбранных событий."""
- updated = queryset.update(recurrence=Recurrence.ONE_TIME)
- self.message_user(request, _(f"Обновлено: {updated}"), level=messages.SUCCESS)
-
- # ===== Сохранение =====
- def save_model(
- self, request: HttpRequest, obj: CalendarEvent, form, change: bool
- ) -> None:
- """Проставляет автора при первом сохранении.
-
- Args:
- request: Текущий запрос.
- obj: Объект события.
- form: Форма админки.
- change: True, если редактирование; False, если создание.
- """
- if not change and obj.created_by_id is None:
- obj.created_by = request.user
- super().save_model(request, obj, form, change)
-
-
-# Добавляем инлайн подписок в админку календаря после импорта
-CalendarAdmin.inlines = [CalendarSubscriptionInline]
diff --git a/backend/calendar_app/admin_calendar.py b/backend/calendar_app/admin_calendar.py
deleted file mode 100644
index 11c217bd..00000000
--- a/backend/calendar_app/admin_calendar.py
+++ /dev/null
@@ -1,159 +0,0 @@
-"""Админка для модели Calendar."""
-
-from django.contrib import admin
-from django.http import HttpRequest
-from django.utils.translation import gettext_lazy as _
-
-from .models import Calendar
-
-
-@admin.register(Calendar)
-class CalendarAdmin(admin.ModelAdmin):
- """Админка для управления календарями."""
-
- # ===== Инлайны =====
- inlines = [] # Будет заполнено после импорта CalendarSubscriptionInline
-
- # ===== Список =====
- list_display = (
- "id",
- "title",
- "visibility_display",
- "owner_display",
- "color_swatch",
- "is_active",
- "event_count",
- "subscriber_count",
- "created_at",
- )
- list_select_related = ("owner_user", "owner_department")
- list_per_page = 50
- ordering = ("-created_at",)
-
- # ===== Поиск/Фильтры =====
- search_fields = (
- "title",
- "description",
- "owner_user__username",
- "owner_user__first_name",
- "owner_user__last_name",
- "owner_department__name",
- "owner_department__title",
- )
- list_filter = (
- "visibility",
- "is_active",
- "auto_subscribe_new_users",
- "auto_subscribe_department_members",
- ("created_at", admin.DateFieldListFilter),
- )
- date_hierarchy = "created_at"
-
- # ===== Поля формы =====
- readonly_fields = ("created_at", "updated_at", "event_count", "subscriber_count")
- raw_id_fields = ("owner_user", "owner_department", "created_by")
- fieldsets = (
- (
- _("Основное"),
- {
- "fields": ("title", "description", "icon"),
- },
- ),
- (
- _("Владелец"),
- {
- "fields": ("owner_user", "owner_department"),
- "description": _("Указывается либо пользователь, либо отдел"),
- },
- ),
- (
- _("Видимость"),
- {
- "fields": ("visibility",),
- },
- ),
- (
- _("Отображение"),
- {
- "fields": ("color", "sort_order"),
- },
- ),
- (
- _("Автоподписка"),
- {
- "fields": (
- "auto_subscribe_new_users",
- "auto_subscribe_department_members",
- ),
- },
- ),
- (
- _("Статус"),
- {
- "fields": ("is_active",),
- },
- ),
- (
- _("Статистика"),
- {
- "fields": (
- "event_count",
- "subscriber_count",
- "created_at",
- "updated_at",
- ),
- "classes": ("collapse",),
- },
- ),
- )
-
- @admin.display(description=_("Видимость"), ordering="visibility")
- def visibility_display(self, obj: Calendar) -> str:
- """Возвращает человекочитаемое название видимости."""
- return obj.get_visibility_display()
-
- @admin.display(description=_("Владелец"))
- def owner_display(self, obj: Calendar) -> str:
- """Возвращает владельца календаря."""
- if obj.owner_user_id:
- return f"👤 {obj.owner_user}"
- if obj.owner_department_id:
- return f"🏢 {obj.owner_department}"
- return _("🌐 Система")
-
- @admin.display(description=_("Цвет"))
- def color_swatch(self, obj: Calendar) -> str:
- """Возвращает HTML со свотчем цвета."""
- return (
- f'
{obj.color}'
- )
-
- color_swatch.allow_tags = True # type: ignore[attr-defined]
-
- @admin.display(description=_("События"))
- def event_count(self, obj: Calendar) -> int:
- """Возвращает количество событий в календаре."""
- return obj.events.count()
-
- @admin.display(description=_("Подписчики"))
- def subscriber_count(self, obj: Calendar) -> int:
- """Возвращает количество подписчиков календаря."""
- return obj.subscriptions.count()
-
- def save_model(
- self, request: HttpRequest, obj: Calendar, form, change: bool
- ) -> None:
- """Валидация перед сохранением.
-
- Args:
- request: Текущий запрос.
- obj: Объект календаря.
- form: Форма админки.
- change: True, если редактирование.
- """
- # Проставляем created_by при создании
- if not change and obj.created_by_id is None:
- obj.created_by = request.user
-
- super().save_model(request, obj, form, change)
diff --git a/backend/calendar_app/admin_subscription.py b/backend/calendar_app/admin_subscription.py
deleted file mode 100644
index 6e5aaebe..00000000
--- a/backend/calendar_app/admin_subscription.py
+++ /dev/null
@@ -1,152 +0,0 @@
-"""Админка для модели CalendarSubscription."""
-
-from django.contrib import admin
-from django.http import HttpRequest
-from django.utils.translation import gettext_lazy as _
-
-from .models import CalendarSubscription
-
-
-@admin.register(CalendarSubscription)
-class CalendarSubscriptionAdmin(admin.ModelAdmin):
- """Админка для управления подписками на календари."""
-
- # ===== Список =====
- list_display = (
- "id",
- "user_display",
- "calendar_display",
- "is_visible",
- "can_edit",
- "can_manage",
- "color_display",
- "notify_on_new_event",
- "notify_on_event_change",
- "subscribed_at",
- )
- list_select_related = ("user", "calendar")
- list_per_page = 50
- ordering = ("-subscribed_at",)
-
- # ===== Поиск/Фильтры =====
- search_fields = (
- "user__username",
- "user__first_name",
- "user__last_name",
- "calendar__title",
- )
- list_filter = (
- "is_visible",
- "can_edit",
- "can_manage",
- "notify_on_new_event",
- "notify_on_event_change",
- ("subscribed_at", admin.DateFieldListFilter),
- )
- date_hierarchy = "subscribed_at"
-
- # ===== Поля формы =====
- readonly_fields = ("subscribed_at",)
- raw_id_fields = ("user", "calendar")
- fieldsets = (
- (
- _("Подписка"),
- {
- "fields": ("user", "calendar"),
- },
- ),
- (
- _("Отображение"),
- {
- "fields": ("is_visible", "color_override"),
- },
- ),
- (
- _("Права доступа"),
- {
- "fields": ("can_edit", "can_manage"),
- "description": _(
- "can_edit - может создавать/редактировать события; "
- "can_manage - может управлять календарем и правами"
- ),
- },
- ),
- (
- _("Уведомления"),
- {
- "fields": (
- "notifications_enabled",
- "notify_on_new_event",
- "notify_on_event_change",
- "notify_on_event_delete",
- ),
- },
- ),
- (
- _("Служебное"),
- {
- "fields": ("subscribed_at",),
- },
- ),
- )
-
- @admin.display(description=_("Пользователь"), ordering="user__username")
- def user_display(self, obj: CalendarSubscription) -> str:
- """Возвращает имя пользователя."""
- return str(obj.user)
-
- @admin.display(description=_("Календарь"), ordering="calendar__title")
- def calendar_display(self, obj: CalendarSubscription) -> str:
- """Возвращает название календаря."""
- return str(obj.calendar)
-
- @admin.display(description=_("Цвет"))
- def color_display(self, obj: CalendarSubscription) -> str:
- """Возвращает цвет с учетом переопределения."""
- color = obj.color_override or obj.calendar.color
- return (
- f' {color}'
- )
-
- color_display.allow_tags = True # type: ignore[attr-defined]
-
- def save_model(
- self,
- request: HttpRequest,
- obj: CalendarSubscription,
- form,
- change: bool,
- ) -> None:
- """Валидация перед сохранением.
-
- Args:
- request: Текущий запрос.
- obj: Объект подписки.
- form: Форма админки.
- change: True, если редактирование.
- """
- # Проверка: can_manage требует can_edit
- if obj.can_manage and not obj.can_edit:
- from django.core.exceptions import ValidationError
-
- raise ValidationError(_("can_manage требует can_edit=True"))
-
- super().save_model(request, obj, form, change)
-
-
-class CalendarSubscriptionInline(admin.TabularInline):
- """Инлайн для отображения подписок в календаре."""
-
- model = CalendarSubscription
- extra = 0
- raw_id_fields = ("user",)
- fields = (
- "user",
- "is_visible",
- "can_edit",
- "can_manage",
- "color_override",
- "subscribed_at",
- )
- readonly_fields = ("subscribed_at",)
diff --git a/backend/calendar_app/apps.py b/backend/calendar_app/apps.py
deleted file mode 100644
index 8ae429be..00000000
--- a/backend/calendar_app/apps.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# backend/calendar_app/apps.py
-from __future__ import annotations
-
-from django.apps import AppConfig
-from django.apps import apps as django_apps
-from django.db.models.signals import post_delete, post_save
-
-
-class CalendarAppConfig(AppConfig):
- """Конфиг приложения календаря. Подключает сигналы к модели сотрудника."""
- name = "calendar_app"
- verbose_name = "Календарь"
-
- def ready(self) -> None:
- """Подключает обработчики сигналов к employees.Employee.
-
- Raises:
- LookupError: Если приложение 'employees' или модель 'Employee' не найдены.
- """
- import calendar_app.notifications # Модуль уведомлений (signals регистрируются автоматически)
- from calendar_app import signals # noqa: F401 - старая система
- import calendar_app.rules # django-rules: регистрация предикатов и правил доступа
-
- try:
- Employee = django_apps.get_model("employees", "Employee")
- except LookupError:
- # Если в проекте иное имя модели — замените здесь.
- return
-
- # ===== СТАРАЯ СИСТЕМА (calendar_app) =====
- # Оставлена для совместимости во время миграции
- post_save.connect(
- signals.handle_employee_saved,
- sender=Employee,
- dispatch_uid="calendar_app.employee_saved_sync_birthday",
- )
- post_delete.connect(
- signals.handle_employee_deleted,
- sender=Employee,
- dispatch_uid="calendar_app.employee_deleted_remove_birthday",
- )
-
- # NOTE: Синхронизация дней рождения через django-scheduler
- # перенесена в employees/signals_birthday.py
-
diff --git a/backend/calendar_app/cache.py b/backend/calendar_app/cache.py
deleted file mode 100644
index d8b82176..00000000
--- a/backend/calendar_app/cache.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# backend/calendar_app/cache.py
-"""Утилиты для управления кешем календаря."""
-
-from django.core.cache import cache
-
-
-def invalidate_calendar_cache(calendar_id=None, user_id=None):
- """Инвалидация кеша календаря.
-
- Args:
- calendar_id: ID календаря (опционально)
- user_id: ID пользователя (опционально)
- """
- patterns = []
-
- if calendar_id:
- # Кеш конкретного календаря
- patterns.extend(
- [
- f"calendar:events:{calendar_id}:*",
- f"calendar:detail:{calendar_id}",
- f"calendar:subscriptions:{calendar_id}:*",
- ]
- )
- else:
- # Все события и календари
- patterns.extend(
- [
- "calendar:events:*",
- "calendar:list:*",
- "calendar:subscriptions:*",
- ]
- )
-
- if user_id:
- # Кеш пользователя
- patterns.extend(
- [
- f"calendar:user:{user_id}:*",
- f"calendar:my_calendars:{user_id}",
- ]
- )
-
- # Удаляем по паттернам
- for pattern in patterns:
- # Django cache не поддерживает wildcards напрямую,
- # поэтому используем delete с точным ключом
- cache.delete(pattern.replace(":*", ""))
-
-
-def invalidate_event_cache(event_id=None):
- """Инвалидация кеша событий.
-
- Args:
- event_id: ID события (опционально)
- """
- if event_id:
- cache.delete(f"calendar:event:{event_id}")
- else:
- # Инвалидация всех событий
- cache.delete("calendar:events")
-
-
-def invalidate_subscription_cache(user_id=None):
- """Инвалидация кеша подписок.
-
- Args:
- user_id: ID пользователя (опционально)
- """
- if user_id:
- cache.delete(f"calendar:subscriptions:user:{user_id}")
- cache.delete(f"calendar:my_calendars:{user_id}")
- else:
- cache.delete("calendar:subscriptions")
diff --git a/backend/calendar_app/management/commands/migrate_birthdays_to_scheduler.py b/backend/calendar_app/management/commands/migrate_birthdays_to_scheduler.py
deleted file mode 100644
index b02089eb..00000000
--- a/backend/calendar_app/management/commands/migrate_birthdays_to_scheduler.py
+++ /dev/null
@@ -1,110 +0,0 @@
-# backend/calendar_app/management/commands/migrate_birthdays_to_scheduler.py
-"""
-Management команда для миграции событий дней рождения из calendar_app в django-scheduler.
-"""
-from django.core.management.base import BaseCommand
-from django.db import transaction
-from employees.models import Employee
-
-
-class Command(BaseCommand):
- help = 'Мигрирует события дней рождения из calendar_app в django-scheduler'
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--dry-run',
- action='store_true',
- help='Показать что будет сделано без фактической миграции',
- )
- parser.add_argument(
- '--delete-old',
- action='store_true',
- help='Удалить старые события дней рождения из calendar_app',
- )
-
- def handle(self, *args, **options):
- # NOTE: Используем новый Service Layer из employees
- from employees.services import UpsertBirthdayEventService
- from calendar_app.models import CalendarEvent
- from schedule.models import Calendar
-
- dry_run = options['dry_run']
- delete_old = options['delete_old']
-
- self.stdout.write(self.style.WARNING('\n' + '='*80))
- self.stdout.write(self.style.WARNING('МИГРАЦИЯ ДНЕЙ РОЖДЕНИЯ: calendar_app → django-scheduler'))
- self.stdout.write(self.style.WARNING('='*80 + '\n'))
-
- if dry_run:
- self.stdout.write(self.style.NOTICE('🔍 Режим DRY-RUN (изменения не применяются)\n'))
-
- # Проверяем наличие календаря компании
- try:
- company_cal = Calendar.objects.get(slug='company-global')
- self.stdout.write(self.style.SUCCESS(f'✅ Календарь компании найден: {company_cal.name}\n'))
- except Calendar.DoesNotExist:
- self.stdout.write(self.style.ERROR('❌ Календарь компании (company-global) не найден!'))
- self.stdout.write(self.style.WARNING(' Запустите миграцию: python manage.py migrate calendar_app'))
- return
-
- # Получаем всех активных сотрудников с датой рождения
- employees = Employee.objects.filter(is_active=True)
- total = employees.count()
-
- self.stdout.write(f'📊 Найдено активных сотрудников: {total}\n')
-
- migrated = 0
- skipped = 0
- errors = 0
-
- with transaction.atomic():
- for emp in employees:
- birthday = emp.birth_date # Используем напрямую поле модели
-
- if not birthday:
- skipped += 1
- continue
-
- try:
- if not dry_run:
- UpsertBirthdayEventService.execute({'employee': emp})
-
- migrated += 1
- name = emp.get_full_name() if hasattr(emp, 'get_full_name') else str(emp)
- self.stdout.write(
- f' ✅ {name} ({birthday.strftime("%d.%m.%Y")})'
- )
-
- except Exception as e:
- errors += 1
- self.stdout.write(
- self.style.ERROR(f' ❌ Ошибка для {emp}: {e}')
- )
-
- if dry_run:
- self.stdout.write(self.style.NOTICE('\n⚠️ DRY-RUN: транзакция откатывается'))
- raise Exception('Dry run rollback')
-
- self.stdout.write('\n' + '-'*80)
- self.stdout.write(self.style.SUCCESS(f'✅ Мигрировано: {migrated}'))
- self.stdout.write(self.style.WARNING(f'⏭️ Пропущено (нет даты рождения): {skipped}'))
- if errors > 0:
- self.stdout.write(self.style.ERROR(f'❌ Ошибок: {errors}'))
-
- # Удаление старых событий
- if delete_old and not dry_run:
- self.stdout.write('\n' + '-'*80)
- self.stdout.write('🗑️ Удаление старых событий дней рождения из calendar_app...')
-
- old_birthdays = CalendarEvent.objects.filter(
- source__startswith='employee:',
- source__endswith=':birthday'
- )
- count = old_birthdays.count()
- old_birthdays.delete()
-
- self.stdout.write(self.style.SUCCESS(f'✅ Удалено старых событий: {count}'))
-
- self.stdout.write('\n' + '='*80)
- self.stdout.write(self.style.SUCCESS('МИГРАЦИЯ ЗАВЕРШЕНА'))
- self.stdout.write('='*80 + '\n')
diff --git a/backend/calendar_app/migrations/0001_initial.py b/backend/calendar_app/migrations/0001_initial.py
deleted file mode 100644
index bf41c2fc..00000000
--- a/backend/calendar_app/migrations/0001_initial.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# Generated by Django 5.2.4 on 2025-07-28 10:23
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name='CompanyEvent',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=200, verbose_name='Название')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('date', models.DateField(verbose_name='Дата события')),
- ('recurrence', models.CharField(choices=[('one_time', 'Одноразовое'), ('annual', 'Ежегодно')], default='one_time', max_length=20, verbose_name='Повторение')),
- ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
- ],
- options={
- 'verbose_name': 'Событие компании',
- 'verbose_name_plural': 'События компании',
- 'ordering': ['recurrence', 'date'],
- },
- ),
- ]
diff --git a/backend/calendar_app/migrations/0002_departmentevent_companyevent_color_and_more.py b/backend/calendar_app/migrations/0002_departmentevent_companyevent_color_and_more.py
deleted file mode 100644
index 8ca56097..00000000
--- a/backend/calendar_app/migrations/0002_departmentevent_companyevent_color_and_more.py
+++ /dev/null
@@ -1,94 +0,0 @@
-# Generated by Django 5.2.4 on 2025-08-13 12:45
-
-import django.db.models.deletion
-import django.utils.timezone
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0001_initial'),
- ('employees', '0009_alter_employeedepartment_date_from_and_more'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name='DepartmentEvent',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=200, verbose_name='Название')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('start_date', models.DateField(verbose_name='Дата начала')),
- ('end_date', models.DateField(blank=True, null=True, verbose_name='Дата окончания')),
- ('all_day', models.BooleanField(default=True, verbose_name='Весь день')),
- ('recurrence', models.CharField(choices=[('one_time', 'Одноразовое'), ('annual', 'Ежегодно')], default='one_time', max_length=20, verbose_name='Повторение')),
- ('color', models.CharField(blank=True, help_text='#RRGGBB', max_length=7, verbose_name='Цвет (hex)')),
- ('location', models.CharField(blank=True, max_length=200, verbose_name='Локация')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ],
- options={
- 'verbose_name': 'Событие отдела',
- 'verbose_name_plural': 'События отделов',
- 'ordering': ['department', 'start_date'],
- 'permissions': [('manage_department_events', 'Can create/update/delete department events')],
- },
- ),
- migrations.AddField(
- model_name='companyevent',
- name='color',
- field=models.CharField(blank=True, help_text='#RRGGBB', max_length=7, verbose_name='Цвет (hex)'),
- ),
- migrations.AddField(
- model_name='companyevent',
- name='created_at',
- field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
- preserve_default=False,
- ),
- migrations.AddField(
- model_name='companyevent',
- name='location',
- field=models.CharField(blank=True, max_length=200, verbose_name='Локация'),
- ),
- migrations.AlterField(
- model_name='companyevent',
- name='created_by',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='company_events_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал'),
- ),
- migrations.AddIndex(
- model_name='companyevent',
- index=models.Index(fields=['date'], name='calendar_ap_date_4971b4_idx'),
- ),
- migrations.AddIndex(
- model_name='companyevent',
- index=models.Index(fields=['recurrence', 'date'], name='calendar_ap_recurre_a9d265_idx'),
- ),
- migrations.AddField(
- model_name='departmentevent',
- name='created_by',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='department_events_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал'),
- ),
- migrations.AddField(
- model_name='departmentevent',
- name='department',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='employees.department', verbose_name='Отдел'),
- ),
- migrations.AddIndex(
- model_name='departmentevent',
- index=models.Index(fields=['department', 'start_date'], name='calendar_ap_departm_bd9d50_idx'),
- ),
- migrations.AddIndex(
- model_name='departmentevent',
- index=models.Index(fields=['department', 'recurrence', 'start_date'], name='calendar_ap_departm_8b7c66_idx'),
- ),
- migrations.AddIndex(
- model_name='departmentevent',
- index=models.Index(fields=['start_date', 'end_date'], name='calendar_ap_start_d_1ab78d_idx'),
- ),
- migrations.AddConstraint(
- model_name='departmentevent',
- constraint=models.CheckConstraint(condition=models.Q(('end_date__isnull', True), ('start_date__isnull', True), ('start_date__lte', models.F('end_date')), _connector='OR'), name='dept_event_date_range_valid'),
- ),
- ]
diff --git a/backend/calendar_app/migrations/0003_alter_departmentevent_options.py b/backend/calendar_app/migrations/0003_alter_departmentevent_options.py
deleted file mode 100644
index 5ff90d38..00000000
--- a/backend/calendar_app/migrations/0003_alter_departmentevent_options.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# Generated by Django 5.2.4 on 2025-08-28 11:51
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0002_departmentevent_companyevent_color_and_more'),
- ]
-
- operations = [
- migrations.AlterModelOptions(
- name='departmentevent',
- options={'ordering': ['department', 'start_date'], 'permissions': [('manage_department_events', 'Может создавать/обновлять/ удалять события отдела')], 'verbose_name': 'Событие отдела', 'verbose_name_plural': 'События отделов'},
- ),
- ]
diff --git a/backend/calendar_app/migrations/0004_remove_departmentevent_dept_event_date_range_valid_and_more.py b/backend/calendar_app/migrations/0004_remove_departmentevent_dept_event_date_range_valid_and_more.py
deleted file mode 100644
index b655329b..00000000
--- a/backend/calendar_app/migrations/0004_remove_departmentevent_dept_event_date_range_valid_and_more.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# Generated by Django 5.2.4 on 2025-09-09 07:03
-
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0003_alter_departmentevent_options'),
- ('employees', '0024_alter_departmentpermission_code'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.RemoveConstraint(
- model_name='departmentevent',
- name='dept_event_date_range_valid',
- ),
- migrations.AddField(
- model_name='departmentevent',
- name='end_time',
- field=models.TimeField(blank=True, null=True, verbose_name='Время окончания'),
- ),
- migrations.AddField(
- model_name='departmentevent',
- name='start_time',
- field=models.TimeField(blank=True, null=True, verbose_name='Время начала'),
- ),
- migrations.AddIndex(
- model_name='departmentevent',
- index=models.Index(fields=['start_date', 'start_time'], name='calendar_ap_start_d_b916c8_idx'),
- ),
- migrations.AddConstraint(
- model_name='departmentevent',
- constraint=models.CheckConstraint(condition=models.Q(('end_date__isnull', True), ('start_date__isnull', True), ('start_date__lt', models.F('end_date')), models.Q(('start_date', models.F('end_date')), models.Q(('end_time__isnull', True), ('start_time__isnull', True), ('start_time__lte', models.F('end_time')), _connector='OR')), _connector='OR'), name='dept_event_date_time_range_valid'),
- ),
- ]
diff --git a/backend/calendar_app/migrations/0005_event_delete_departmentevent_and_more.py b/backend/calendar_app/migrations/0005_event_delete_departmentevent_and_more.py
deleted file mode 100644
index f343a89e..00000000
--- a/backend/calendar_app/migrations/0005_event_delete_departmentevent_and_more.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# Generated by Django 5.2.4 on 2025-09-09 07:30
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0004_remove_departmentevent_dept_event_date_range_valid_and_more'),
- ('employees', '0024_alter_departmentpermission_code'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name='Event',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=200, verbose_name='Название')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('start_date', models.DateField(verbose_name='Дата начала')),
- ('end_date', models.DateField(blank=True, null=True, verbose_name='Дата окончания')),
- ('start_time', models.TimeField(blank=True, null=True, verbose_name='Время начала')),
- ('end_time', models.TimeField(blank=True, null=True, verbose_name='Время окончания')),
- ('all_day', models.BooleanField(default=True, verbose_name='Весь день')),
- ('recurrence', models.CharField(choices=[('one_time', 'Одноразовое'), ('annual', 'Ежегодно')], default='one_time', max_length=20, verbose_name='Повторение')),
- ('color', models.CharField(blank=True, help_text='#RRGGBB', max_length=7, verbose_name='Цвет (hex)')),
- ('location', models.CharField(blank=True, max_length=200, verbose_name='Локация')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='events_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
- ('department', models.ForeignKey(blank=True, help_text='Пусто — событие компании; задано — событие отдела.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events', to='employees.department', verbose_name='Отдел')),
- ],
- options={
- 'verbose_name': 'Событие',
- 'verbose_name_plural': 'События',
- 'ordering': ['department', 'start_date', 'start_time'],
- 'permissions': [('manage_department_events', 'Может создавать/обновлять/удалять события отдела')],
- },
- ),
- migrations.DeleteModel(
- name='DepartmentEvent',
- ),
- migrations.AddIndex(
- model_name='event',
- index=models.Index(fields=['department', 'start_date'], name='calendar_ap_departm_ef1b03_idx'),
- ),
- migrations.AddIndex(
- model_name='event',
- index=models.Index(fields=['department', 'recurrence', 'start_date'], name='calendar_ap_departm_cb628e_idx'),
- ),
- migrations.AddIndex(
- model_name='event',
- index=models.Index(fields=['start_date', 'end_date'], name='calendar_ap_start_d_f4158f_idx'),
- ),
- migrations.AddIndex(
- model_name='event',
- index=models.Index(fields=['start_date', 'start_time'], name='calendar_ap_start_d_e5cf45_idx'),
- ),
- migrations.AddConstraint(
- model_name='event',
- constraint=models.CheckConstraint(condition=models.Q(('end_date__isnull', True), ('start_date__isnull', True), ('start_date__lt', models.F('end_date')), models.Q(('start_date', models.F('end_date')), models.Q(('end_time__isnull', True), ('start_time__isnull', True), ('start_time__lte', models.F('end_time')), _connector='OR')), _connector='OR'), name='event_date_time_range_valid'),
- ),
- ]
diff --git a/backend/calendar_app/migrations/0006_rename_event_calendarevent_and_more.py b/backend/calendar_app/migrations/0006_rename_event_calendarevent_and_more.py
deleted file mode 100644
index cd7d41f6..00000000
--- a/backend/calendar_app/migrations/0006_rename_event_calendarevent_and_more.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# Generated by Django 5.2.4 on 2025-09-09 07:53
-
-from django.conf import settings
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0005_event_delete_departmentevent_and_more'),
- ('employees', '0024_alter_departmentpermission_code'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.RenameModel(
- old_name='Event',
- new_name='CalendarEvent',
- ),
- migrations.RemoveField(
- model_name='companyevent',
- name='created_by',
- ),
- migrations.RenameIndex(
- model_name='calendarevent',
- new_name='calendar_ap_departm_ff088c_idx',
- old_name='calendar_ap_departm_ef1b03_idx',
- ),
- migrations.RenameIndex(
- model_name='calendarevent',
- new_name='calendar_ap_departm_031e6f_idx',
- old_name='calendar_ap_departm_cb628e_idx',
- ),
- migrations.RenameIndex(
- model_name='calendarevent',
- new_name='calendar_ap_start_d_8880a8_idx',
- old_name='calendar_ap_start_d_f4158f_idx',
- ),
- migrations.RenameIndex(
- model_name='calendarevent',
- new_name='calendar_ap_start_d_f6dd7c_idx',
- old_name='calendar_ap_start_d_e5cf45_idx',
- ),
- migrations.DeleteModel(
- name='CompanyEvent',
- ),
- ]
diff --git a/backend/calendar_app/migrations/0007_alter_calendarevent_options_and_more.py b/backend/calendar_app/migrations/0007_alter_calendarevent_options_and_more.py
deleted file mode 100644
index db86b952..00000000
--- a/backend/calendar_app/migrations/0007_alter_calendarevent_options_and_more.py
+++ /dev/null
@@ -1,64 +0,0 @@
-# Generated by Django 5.2.4 on 2025-09-09 07:57
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0006_rename_event_calendarevent_and_more'),
- ('employees', '0024_alter_departmentpermission_code'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.AlterModelOptions(
- name='calendarevent',
- options={'ordering': ['department', 'start_date', 'start_time'], 'permissions': [('manage_department_events', 'Может управлять событиями отдела')], 'verbose_name': 'Событие календаря', 'verbose_name_plural': 'События календаря'},
- ),
- migrations.RemoveConstraint(
- model_name='calendarevent',
- name='event_date_time_range_valid',
- ),
- migrations.AddField(
- model_name='calendarevent',
- name='recurrence_count',
- field=models.PositiveIntegerField(blank=True, help_text='Максимум создаваемых вхождений; нельзя вместе с датой окончания.', null=True, verbose_name='Количество повторений'),
- ),
- migrations.AddField(
- model_name='calendarevent',
- name='recurrence_interval',
- field=models.PositiveSmallIntegerField(default=1, help_text='Шаг повторения (каждые N часов/дней/недель/месяцев).', verbose_name='Интервал повторения'),
- ),
- migrations.AddField(
- model_name='calendarevent',
- name='recurrence_until',
- field=models.DateField(blank=True, help_text='Дата, до которой повторять. Нельзя вместе с количеством повторов.', null=True, verbose_name='Повторять до (включительно)'),
- ),
- migrations.AddField(
- model_name='calendarevent',
- name='weekdays_mask',
- field=models.PositiveSmallIntegerField(default=0, help_text='Для еженедельных событий: 1=Пн,2=Вт,4=Ср,8=Чт,16=Пт,32=Сб,64=Вс; можно суммировать.', verbose_name='Дни недели (битовая маска)'),
- ),
- migrations.AlterField(
- model_name='calendarevent',
- name='created_by',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='calendar_events_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал'),
- ),
- migrations.AlterField(
- model_name='calendarevent',
- name='department',
- field=models.ForeignKey(blank=True, help_text='Пусто — событие компании; задано — событие отдела.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='calendar_events', to='employees.department', verbose_name='Отдел'),
- ),
- migrations.AlterField(
- model_name='calendarevent',
- name='recurrence',
- field=models.CharField(choices=[('one_time', 'Одноразовое'), ('hourly', 'Ежечасно'), ('daily', 'Ежедневно'), ('weekly', 'Еженедельно'), ('monthly', 'Ежемесячно'), ('annual', 'Ежегодно')], default='one_time', max_length=20, verbose_name='Повторение'),
- ),
- migrations.AddConstraint(
- model_name='calendarevent',
- constraint=models.CheckConstraint(condition=models.Q(('end_date__isnull', True), ('start_date__isnull', True), ('start_date__lt', models.F('end_date')), models.Q(('start_date', models.F('end_date')), models.Q(('end_time__isnull', True), ('start_time__isnull', True), ('start_time__lte', models.F('end_time')), _connector='OR')), _connector='OR'), name='cal_event_date_time_range_valid'),
- ),
- ]
diff --git a/backend/calendar_app/migrations/0008_calendarevent_source_and_more.py b/backend/calendar_app/migrations/0008_calendarevent_source_and_more.py
deleted file mode 100644
index 23260150..00000000
--- a/backend/calendar_app/migrations/0008_calendarevent_source_and_more.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# Generated by Django 5.2.4 on 2025-09-09 08:38
-
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0007_alter_calendarevent_options_and_more'),
- ('employees', '0024_alter_departmentpermission_code'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.AddField(
- model_name='calendarevent',
- name='source',
- field=models.CharField(blank=True, db_index=True, help_text="Технический ключ связи с внешним объектом, напр. 'employee::birthday'", max_length=120, verbose_name='Источник события'),
- ),
- migrations.AddIndex(
- model_name='calendarevent',
- index=models.Index(fields=['source'], name='calendar_ap_source_856f30_idx'),
- ),
- migrations.AddConstraint(
- model_name='calendarevent',
- constraint=models.UniqueConstraint(condition=models.Q(('source', ''), _negated=True), fields=('source',), name='cal_event_source_unique'),
- ),
- ]
diff --git a/backend/calendar_app/migrations/0009_alter_calendarevent_options_and_more.py b/backend/calendar_app/migrations/0009_alter_calendarevent_options_and_more.py
deleted file mode 100644
index c7d0f099..00000000
--- a/backend/calendar_app/migrations/0009_alter_calendarevent_options_and_more.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# Generated by Django 5.2.4 on 2025-09-10 05:34
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0008_calendarevent_source_and_more'),
- ]
-
- operations = [
- migrations.AlterModelOptions(
- name='calendarevent',
- options={'ordering': ['department', 'start_date', 'start_time'], 'verbose_name': 'Событие календаря', 'verbose_name_plural': 'События календаря'},
- ),
- migrations.RemoveConstraint(
- model_name='calendarevent',
- name='cal_event_date_time_range_valid',
- ),
- ]
diff --git a/backend/calendar_app/migrations/0010_calendarevent_employee_and_more.py b/backend/calendar_app/migrations/0010_calendarevent_employee_and_more.py
deleted file mode 100644
index fc011481..00000000
--- a/backend/calendar_app/migrations/0010_calendarevent_employee_and_more.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Generated by Django 5.2.4 on 2025-11-29 11:59
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0009_alter_calendarevent_options_and_more'),
- ('employees', '0034_alter_employee_telegram'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.AddField(
- model_name='calendarevent',
- name='employee',
- field=models.ForeignKey(blank=True, help_text='Если задано — личное событие сотрудника.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='personal_calendar_events', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник'),
- ),
- migrations.AlterField(
- model_name='calendarevent',
- name='department',
- field=models.ForeignKey(blank=True, help_text='Пусто — событие компании или сотрудника; задано — событие отдела.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='calendar_events', to='employees.department', verbose_name='Отдел'),
- ),
- ]
diff --git a/backend/calendar_app/migrations/0011_calendarsubscription_alter_calendarevent_options_and_more.py b/backend/calendar_app/migrations/0011_calendarsubscription_alter_calendarevent_options_and_more.py
deleted file mode 100644
index 50584204..00000000
--- a/backend/calendar_app/migrations/0011_calendarsubscription_alter_calendarevent_options_and_more.py
+++ /dev/null
@@ -1,124 +0,0 @@
-# Generated by Django 5.2.4 on 2026-02-11 04:54
-
-import django.db.models.deletion
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0010_calendarevent_employee_and_more'),
- ('employees', '0039_employee_employees_e_usernam_4d3ddc_idx'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name='CalendarSubscription',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('is_visible', models.BooleanField(default=True, verbose_name='Отображать')),
- ('color_override', models.CharField(blank=True, help_text='Переопределяет цвет календаря', max_length=7, verbose_name='Свой цвет')),
- ('can_edit', models.BooleanField(default=False, help_text='Может создавать/редактировать события в этом календаре', verbose_name='Может редактировать')),
- ('can_manage', models.BooleanField(default=False, help_text='Может управлять правами других пользователей', verbose_name='Может управлять')),
- ('notify_on_new_event', models.BooleanField(default=True, verbose_name='Уведомлять о новых событиях')),
- ('notify_on_event_change', models.BooleanField(default=True, verbose_name='Уведомлять об изменениях')),
- ('subscribed_at', models.DateTimeField(auto_now_add=True)),
- ],
- options={
- 'verbose_name': 'Подписка на календарь',
- 'verbose_name_plural': 'Подписки на календари',
- },
- ),
- migrations.AlterModelOptions(
- name='calendarevent',
- options={'ordering': ['start_date', 'start_time'], 'verbose_name': 'Событие календаря', 'verbose_name_plural': 'События календаря'},
- ),
- migrations.AlterField(
- model_name='calendarevent',
- name='department',
- field=models.ForeignKey(blank=True, help_text='LEGACY: используется если calendar=NULL', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='calendar_events', to='employees.department', verbose_name='Отдел'),
- ),
- migrations.AlterField(
- model_name='calendarevent',
- name='employee',
- field=models.ForeignKey(blank=True, help_text='LEGACY: используется если calendar=NULL', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='personal_calendar_events', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник'),
- ),
- migrations.CreateModel(
- name='Calendar',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=200, verbose_name='Название')),
- ('description', models.TextField(blank=True, verbose_name='Описание')),
- ('color', models.CharField(default='#0d6efd', help_text='#RRGGBB', max_length=7, verbose_name='Цвет')),
- ('icon', models.CharField(blank=True, help_text='Bootstrap icon, например: calendar-event', max_length=50, verbose_name='Иконка')),
- ('visibility', models.CharField(choices=[('public', 'Публичный (все видят)'), ('department', 'Отдел (только члены отдела)'), ('private', 'Приватный (только владелец)'), ('custom', 'Настраиваемый (через права)')], default='custom', max_length=20, verbose_name='Видимость')),
- ('default_can_edit', models.BooleanField(default=False, help_text='Если True, все подписчики могут создавать/редактировать события', verbose_name='Могут редактировать по умолчанию')),
- ('auto_subscribe_new_users', models.BooleanField(default=False, help_text='Автоматически подписывать новых сотрудников', verbose_name='Автоподписка для новых пользователей')),
- ('auto_subscribe_department_members', models.BooleanField(default=False, help_text='Автоматически подписывать членов отдела-владельца', verbose_name='Автоподписка для членов отдела')),
- ('is_active', models.BooleanField(default=True, verbose_name='Активен')),
- ('created_at', models.DateTimeField(auto_now_add=True)),
- ('updated_at', models.DateTimeField(auto_now=True)),
- ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='calendars_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
- ('owner_department', models.ForeignKey(blank=True, help_text='Если задано — календарь отдела', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_calendars', to='employees.department', verbose_name='Отдел-владелец')),
- ('owner_user', models.ForeignKey(blank=True, help_text='Если задано — личный календарь пользователя', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_calendars', to=settings.AUTH_USER_MODEL, verbose_name='Владелец')),
- ],
- options={
- 'verbose_name': 'Календарь',
- 'verbose_name_plural': 'Календари',
- 'ordering': ['-created_at'],
- },
- ),
- migrations.AddField(
- model_name='calendarevent',
- name='calendar',
- field=models.ForeignKey(blank=True, help_text='Если не задан — используется стандартная логика (department/employee)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='events', to='calendar_app.calendar', verbose_name='Календарь'),
- ),
- migrations.AddIndex(
- model_name='calendarevent',
- index=models.Index(fields=['calendar', 'start_date'], name='calendar_ap_calenda_07b09a_idx'),
- ),
- migrations.AddIndex(
- model_name='calendarevent',
- index=models.Index(fields=['calendar', 'recurrence', 'start_date'], name='calendar_ap_calenda_f46158_idx'),
- ),
- migrations.AddIndex(
- model_name='calendarevent',
- index=models.Index(fields=['employee', 'start_date'], name='calendar_ap_employe_2eb909_idx'),
- ),
- migrations.AddField(
- model_name='calendarsubscription',
- name='calendar',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='calendar_app.calendar', verbose_name='Календарь'),
- ),
- migrations.AddField(
- model_name='calendarsubscription',
- name='user',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calendar_subscriptions', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'),
- ),
- migrations.AddIndex(
- model_name='calendar',
- index=models.Index(fields=['owner_user', 'is_active'], name='calendar_ap_owner_u_9b537a_idx'),
- ),
- migrations.AddIndex(
- model_name='calendar',
- index=models.Index(fields=['owner_department', 'is_active'], name='calendar_ap_owner_d_e1a58a_idx'),
- ),
- migrations.AddIndex(
- model_name='calendar',
- index=models.Index(fields=['visibility'], name='calendar_ap_visibil_89b2b5_idx'),
- ),
- migrations.AddIndex(
- model_name='calendarsubscription',
- index=models.Index(fields=['user', 'is_visible'], name='calendar_ap_user_id_e76eda_idx'),
- ),
- migrations.AddIndex(
- model_name='calendarsubscription',
- index=models.Index(fields=['calendar', 'can_edit'], name='calendar_ap_calenda_428093_idx'),
- ),
- migrations.AlterUniqueTogether(
- name='calendarsubscription',
- unique_together={('calendar', 'user')},
- ),
- ]
diff --git a/backend/calendar_app/migrations/0012_alter_calendarsubscription_color_override.py b/backend/calendar_app/migrations/0012_alter_calendarsubscription_color_override.py
deleted file mode 100644
index ab5d0d74..00000000
--- a/backend/calendar_app/migrations/0012_alter_calendarsubscription_color_override.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# Generated by Django 5.2.4 on 2026-02-11 05:29
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0011_calendarsubscription_alter_calendarevent_options_and_more'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='calendarsubscription',
- name='color_override',
- field=models.CharField(blank=True, help_text='Переопределяет цвет календаря', max_length=7, null=True, verbose_name='Свой цвет'),
- ),
- ]
diff --git a/backend/calendar_app/migrations/0013_migrate_to_django_scheduler.py b/backend/calendar_app/migrations/0013_migrate_to_django_scheduler.py
deleted file mode 100644
index ec453e92..00000000
--- a/backend/calendar_app/migrations/0013_migrate_to_django_scheduler.py
+++ /dev/null
@@ -1,559 +0,0 @@
-# Generated migration for calendar_app → schedule data migration
-
-from django.db import migrations
-from django.utils import timezone
-from django.utils.text import slugify
-from datetime import datetime, time
-
-
-def migrate_to_django_scheduler(apps, schema_editor):
- """
- Мигрирует данные из calendar_app в django-scheduler.
-
- Создает:
- - Системные календари (компания, отделы, личные)
- - Мигрирует пользовательские календари
- - Мигрирует события с конвертацией повторяемости
- - Мигрирует подписки в CalendarRelation
- """
- # ВАЖНО: Пропускаем миграцию данных в тестовой БД
- from django.conf import settings
- db_name = settings.DATABASES['default']['NAME']
-
- # Проверяем признаки тестовой БД (SQLite :memory: или test_ prefix)
- if ':memory' in str(db_name).lower() or 'test' in str(db_name).lower():
- print("⏭️ Пропуск миграции данных в тестовой БД\n")
- return
-
- # ВАЖНО: Получаем ContentType через apps.get_model для миграций!
- ContentType = apps.get_model('contenttypes', 'ContentType')
-
- # Old models
- OldCalendar = apps.get_model('calendar_app', 'Calendar')
- OldCalendarEvent = apps.get_model('calendar_app', 'CalendarEvent')
- OldCalendarSubscription = apps.get_model('calendar_app', 'CalendarSubscription')
-
- # New models (django-scheduler)
- NewCalendar = apps.get_model('schedule', 'Calendar')
- NewEvent = apps.get_model('schedule', 'Event')
- NewRule = apps.get_model('schedule', 'Rule')
- CalendarRelation = apps.get_model('schedule', 'CalendarRelation')
-
- # Employee & Department
- Employee = apps.get_model('employees', 'Employee')
- Department = apps.get_model('employees', 'Department')
-
- employee_ct = ContentType.objects.get_for_model(Employee)
-
- print("\n" + "="*80)
- print("НАЧАЛО МИГРАЦИИ: calendar_app → django-scheduler")
- print("="*80 + "\n")
-
- # ===== ШАГ 1: Создание системных календарей =====
- print("ШАГ 1: Создание системных календарей...")
- print("-" * 80)
-
- # 1.1. Глобальный календарь компании
- company_cal, created = NewCalendar.objects.get_or_create(
- slug='company-global',
- defaults={'name': '🏢 Календарь компании'}
- )
- if created:
- print(f" ✅ Создан: {company_cal.name}")
- else:
- print(f" ℹ️ Уже существует: {company_cal.name}")
-
- # Все сотрудники как viewers для глобального календаря
- for emp in Employee.objects.filter(is_active=True):
- try:
- CalendarRelation.objects.create(
- calendar=company_cal,
- content_type=employee_ct,
- object_id=emp.id,
- distinction='viewer',
- inheritable=True
- )
- except Exception:
- # Уже существует, пропускаем
- pass
-
- # 1.2. Календари отделов
- dept_calendars = {}
- departments = Department.objects.all()
- print(f"\n Создание календарей отделов ({departments.count()})...")
-
- for dept in departments:
- slug = f'dept-{dept.id}'
- cal, created = NewCalendar.objects.get_or_create(
- slug=slug,
- defaults={'name': f'🏛️ {dept.name}'}
- )
- dept_calendars[dept.id] = cal
-
- if created:
- print(f" ✅ {cal.name}")
-
- # Руководитель отдела как owner
- if hasattr(dept, 'head') and dept.head:
- try:
- CalendarRelation.objects.create(
- calendar=cal,
- content_type=employee_ct,
- object_id=dept.head.id,
- distinction='owner',
- inheritable=True
- )
- except Exception:
- pass
-
- # Члены отдела как viewers
- # Получаем через обратную связь EmployeeDepartment
- EmployeeDepartment = apps.get_model('employees', 'EmployeeDepartment')
- dept_links = EmployeeDepartment.objects.filter(department=dept, is_active=True).select_related('employee')
- for link in dept_links:
- if link.employee:
- try:
- CalendarRelation.objects.create(
- calendar=cal,
- content_type=employee_ct,
- object_id=link.employee.id,
- distinction='viewer',
- inheritable=True
- )
- except Exception:
- pass
-
- print(f" ✅ Создано {len(dept_calendars)} календарей отделов")
-
- # 1.3. Личные календари пользователей
- personal_calendars = {}
- employees = Employee.objects.filter(is_active=True)
- print(f"\n Создание личных календарей ({employees.count()})...")
-
- for emp in employees:
- slug = f'personal-{emp.id}'
- cal, created = NewCalendar.objects.get_or_create(
- slug=slug,
- defaults={'name': '👤 Мой календарь'}
- )
- personal_calendars[emp.id] = cal
-
- # Владелец
- try:
- CalendarRelation.objects.create(
- calendar=cal,
- content_type=employee_ct,
- object_id=emp.id,
- distinction='owner',
- inheritable=True
- )
- except Exception:
- pass
-
- print(f" ✅ Создано {len(personal_calendars)} личных календарей")
-
- total_system_cals = 1 + len(dept_calendars) + len(personal_calendars)
- print(f"\n✅ Всего создано системных календарей: {total_system_cals}\n")
-
- # ===== ШАГ 2: Миграция пользовательских календарей =====
- print("ШАГ 2: Миграция пользовательских календарей...")
- print("-" * 80)
-
- calendar_mapping = {} # old_calendar.id → new_calendar
- old_calendars = OldCalendar.objects.all()
-
- for old_cal in old_calendars:
- # Генерируем уникальный slug
- base_slug = slugify(old_cal.title) or f'calendar-{old_cal.id}'
- slug = f'legacy-{base_slug}'
-
- # Проверяем уникальность
- counter = 1
- final_slug = slug
- while NewCalendar.objects.filter(slug=final_slug).exists():
- final_slug = f'{slug}-{counter}'
- counter += 1
-
- new_cal = NewCalendar.objects.create(
- name=old_cal.title,
- slug=final_slug
- )
- calendar_mapping[old_cal.id] = new_cal
-
- # Создаем CalendarRelation для владельца
- if old_cal.owner_user_id:
- try:
- CalendarRelation.objects.create(
- calendar=new_cal,
- content_type=employee_ct,
- object_id=old_cal.owner_user_id,
- distinction='owner',
- inheritable=True
- )
- except Exception:
- pass
- elif old_cal.owner_department_id:
- # Владелец отдела = руководитель
- dept = Department.objects.filter(id=old_cal.owner_department_id).first()
- if dept and hasattr(dept, 'head') and dept.head:
- try:
- CalendarRelation.objects.create(
- calendar=new_cal,
- content_type=employee_ct,
- object_id=dept.head.id,
- distinction='owner',
- inheritable=True
- )
- except Exception:
- pass
-
- print(f" ✅ {old_cal.title} → {final_slug}")
-
- print(f"\n✅ Мигрировано {len(calendar_mapping)} пользовательских календарей\n")
-
- # ===== ШАГ 3: Миграция событий =====
- print("ШАГ 3: Миграция событий...")
- print("-" * 80)
-
- old_events = OldCalendarEvent.objects.all()
- migrated_count = 0
- skipped_count = 0
-
- print(f" Всего событий для миграции: {old_events.count()}\n")
-
- for old_event in old_events:
- try:
- # Пропускаем старые события дней рождения - они будут созданы заново в ШАГе 5
- if hasattr(old_event, 'source') and old_event.source and old_event.source.endswith(':birthday'):
- skipped_count += 1
- continue
-
- # Определяем целевой календарь
- target_cal = None
-
- if old_event.calendar_id:
- # Событие в пользовательском календаре
- target_cal = calendar_mapping.get(old_event.calendar_id)
- elif old_event.employee_id:
- # Личное событие
- target_cal = personal_calendars.get(old_event.employee_id)
- elif old_event.department_id:
- # Событие отдела
- target_cal = dept_calendars.get(old_event.department_id)
- else:
- # Глобальное событие компании
- target_cal = company_cal
-
- if not target_cal:
- print(f" ⚠️ Пропущено (нет календаря): {old_event.title}")
- skipped_count += 1
- continue
-
- # Конвертация даты+время → datetime
- if old_event.all_day:
- start_dt = timezone.make_aware(
- datetime.combine(old_event.start_date, time(0, 0))
- )
- end_date = old_event.end_date or old_event.start_date
- end_dt = timezone.make_aware(
- datetime.combine(end_date, time(23, 59, 59))
- )
- else:
- start_time = old_event.start_time or time(0, 0)
- start_dt = timezone.make_aware(
- datetime.combine(old_event.start_date, start_time)
- )
-
- end_date = old_event.end_date or old_event.start_date
- end_time = old_event.end_time or start_time
- end_dt = timezone.make_aware(
- datetime.combine(end_date, end_time)
- )
-
- # Создаем Rule если есть повторяемость
- rule = None
- end_recurring_period = None
-
- if old_event.recurrence != 'one_time':
- rule = create_rule_from_recurrence(
- NewRule,
- old_event.recurrence,
- old_event.recurrence_interval,
- old_event.weekdays_mask,
- old_event.recurrence_until,
- old_event.recurrence_count,
- old_event.title
- )
-
- if old_event.recurrence_until:
- end_recurring_period = timezone.make_aware(
- datetime.combine(old_event.recurrence_until, time(23, 59, 59))
- )
-
- # Создаем новое событие
- new_event = NewEvent.objects.create(
- calendar=target_cal,
- title=old_event.title,
- description=old_event.description or '',
- start=start_dt,
- end=end_dt,
- rule=rule,
- end_recurring_period=end_recurring_period,
- color_event=old_event.color if old_event.color else None,
- )
-
- migrated_count += 1
-
- # Прогресс каждые 50 событий
- if migrated_count % 50 == 0:
- print(f" ... обработано {migrated_count} событий")
-
- except Exception as e:
- print(f" ❌ Ошибка миграции '{old_event.title}': {e}")
- skipped_count += 1
- continue
-
- print(f"\n✅ Мигрировано событий: {migrated_count}")
- if skipped_count > 0:
- print(f"⚠️ Пропущено событий: {skipped_count}")
- print()
-
- # ===== ШАГ 4: Миграция подписок =====
- print("ШАГ 4: Миграция подписок...")
- print("-" * 80)
-
- subscriptions = OldCalendarSubscription.objects.all()
- migrated_subs = 0
-
- for sub in subscriptions:
- target_cal = calendar_mapping.get(sub.calendar_id)
- if not target_cal:
- continue
-
- # Определяем distinction из прав
- if sub.can_manage:
- distinction = 'owner'
- elif sub.can_edit:
- distinction = 'editor'
- else:
- distinction = 'viewer'
-
- try:
- CalendarRelation.objects.create(
- calendar=target_cal,
- content_type=employee_ct,
- object_id=sub.user_id,
- distinction=distinction,
- inheritable=True
- )
- except Exception:
- pass
- migrated_subs += 1
-
- print(f"✅ Мигрировано подписок: {migrated_subs}\n")
-
- # ===== ШАГ 5: Синхронизация дней рождения =====
- print("ШАГ 5: Синхронизация дней рождения...")
- print("-" * 80)
-
- employees_with_birthdays = Employee.objects.filter(
- birth_date__isnull=False,
- is_active=True
- )
- print(f" Сотрудников с датой рождения: {employees_with_birthdays.count()}\n")
-
- birthday_created = 0
- birthday_skipped = 0
-
- # Получаем или создаем правило для ежегодного повторения
- yearly_rule, _ = NewRule.objects.get_or_create(
- name='Ежегодно',
- defaults={
- 'description': 'Повторять каждый год',
- 'frequency': 'YEARLY',
- 'params': ''
- }
- )
-
- for emp in employees_with_birthdays:
- try:
- # Получаем личный календарь (уже создан в Шаге 1.3)
- personal_cal = personal_calendars.get(emp.id)
- if not personal_cal:
- birthday_skipped += 1
- continue
-
- # Создаем событие дня рождения
- full_name = f"{emp.last_name} {emp.first_name}" if emp.last_name and emp.first_name else f"Employee #{emp.id}"
- title = f"🎂 День рождения: {full_name}"
-
- # Вычисляем даты для текущего года
- current_year = timezone.now().year
- start_dt = timezone.make_aware(
- datetime(current_year, emp.birth_date.month, emp.birth_date.day, 0, 0)
- )
- end_dt = start_dt + timezone.timedelta(days=1)
-
- NewEvent.objects.create(
- title=title,
- start=start_dt,
- end=end_dt,
- calendar=personal_cal,
- creator_id=emp.id,
- rule=yearly_rule,
- end_recurring_period=None, # Бесконечное повторение
- color_event='#FFC107', # Жёлтый/золотой цвет
- description=f"Автоматическое событие: день рождения {full_name}"
- )
- birthday_created += 1
-
- # Прогресс каждые 20 событий
- if birthday_created % 20 == 0:
- print(f" ... создано {birthday_created} событий дней рождения")
-
- except Exception as e:
- print(f" ⚠️ Ошибка для {emp}: {e}")
- birthday_skipped += 1
-
- print(f"\n✅ Создано событий дней рождения: {birthday_created}")
- if birthday_skipped > 0:
- print(f"⚠️ Пропущено: {birthday_skipped}")
- print()
-
- # ===== ИТОГИ =====
- print("="*80)
- print("МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ✅")
- print("="*80)
- print(f"\n📊 Статистика:")
- print(f" • Системных календарей: {total_system_cals}")
- print(f" • Пользовательских календарей: {len(calendar_mapping)}")
- print(f" • Событий: {migrated_count}")
- print(f" • Подписок: {migrated_subs}")
- print(f" • Дней рождения: {birthday_created}")
- print(f"\n⚠️ Примечания:")
- print(f" • Старые данные calendar_app НЕ удалены (для возможности отката)")
- print(f" • События дней рождения созданы автоматически для всех сотрудников")
- print(f" • Потеряны: color_override, is_visible, notify_* из CalendarSubscription")
- print(f" • Рекомендуется тестирование перед удалением старых таблиц")
- print()
-
-
-def create_rule_from_recurrence(Rule, recurrence, interval, weekdays_mask, until, count, event_title):
- """
- Создает Rule из параметров повторяемости calendar_app.
-
- Args:
- Rule: Модель django-scheduler Rule
- recurrence: 'hourly'|'daily'|'weekly'|'monthly'|'annual'
- interval: Интервал повторения
- weekdays_mask: Битовая маска дней недели (1=Пн, 2=Вт, 4=Ср, 8=Чт, 16=Пт, 32=Сб, 64=Вс)
- until: Дата окончания повторений
- count: Количество повторений
- event_title: Название события (для имени Rule)
-
- Returns:
- Rule instance
- """
- # Маппинг частоты
- freq_mapping = {
- 'hourly': 'HOURLY',
- 'daily': 'DAILY',
- 'weekly': 'WEEKLY',
- 'monthly': 'MONTHLY',
- 'annual': 'YEARLY',
- }
-
- frequency = freq_mapping.get(recurrence, 'DAILY')
-
- # Формирование params (RFC 5545 format)
- params_parts = []
-
- # Interval
- if interval and interval > 1:
- params_parts.append(f'interval:{interval}')
-
- # Weekdays (для weekly events)
- if recurrence == 'weekly' and weekdays_mask:
- days = []
- if weekdays_mask & 1: days.append('0') # Пн
- if weekdays_mask & 2: days.append('1') # Вт
- if weekdays_mask & 4: days.append('2') # Ср
- if weekdays_mask & 8: days.append('3') # Чт
- if weekdays_mask & 16: days.append('4') # Пт
- if weekdays_mask & 32: days.append('5') # Сб
- if weekdays_mask & 64: days.append('6') # Вс
-
- if days:
- params_parts.append(f'byweekday:{",".join(days)}')
-
- # Until date
- if until:
- params_parts.append(f'until:{until.strftime("%Y%m%d")}')
-
- # Count
- if count:
- params_parts.append(f'count:{count}')
-
- params = ';'.join(params_parts) if params_parts else ''
-
- # Создаем Rule
- rule_name = f'{event_title[:25]} - {frequency}'
- rule_desc = f'Migrated from calendar_app: {recurrence} every {interval}'
-
- rule = Rule.objects.create(
- name=rule_name,
- description=rule_desc,
- frequency=frequency,
- params=params
- )
-
- return rule
-
-
-def reverse_migration(apps, schema_editor):
- """
- Откат миграции: удаление мигрированных данных из django-scheduler.
-
- ⚠️ ВНИМАНИЕ: Это удалит ВСЕ календари и события из django-scheduler,
- созданные этой миграцией. Старые данные в calendar_app останутся.
- """
- NewCalendar = apps.get_model('schedule', 'Calendar')
-
- print("\n⚠️ ОТКАТ МИГРАЦИИ: Удаление мигрированных календарей...")
-
- # Удаляем по slug prefix
- deleted = 0
-
- # Системные календари
- count, _ = NewCalendar.objects.filter(slug='company-global').delete()
- deleted += count
-
- count, _ = NewCalendar.objects.filter(slug__startswith='dept-').delete()
- deleted += count
-
- count, _ = NewCalendar.objects.filter(slug__startswith='personal-').delete()
- deleted += count
-
- # Пользовательские календари
- count, _ = NewCalendar.objects.filter(slug__startswith='legacy-').delete()
- deleted += count
-
- print(f"✅ Удалено календарей: {deleted}")
- print("⚠️ Старые данные в calendar_app сохранены")
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('calendar_app', '0012_alter_calendarsubscription_color_override'),
- ('schedule', '0015_rename_indexes'), # django-scheduler last migration
- ('employees', '0001_initial'), # Зависимость от Employee model
- ('contenttypes', '0002_remove_content_type_name'), # Для CalendarRelation
- ]
-
- operations = [
- migrations.RunPython(
- migrate_to_django_scheduler,
- reverse_migration
- ),
- ]
diff --git a/backend/calendar_app/migrations/__init__.py b/backend/calendar_app/migrations/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/backend/calendar_app/models.py b/backend/calendar_app/models.py
deleted file mode 100644
index 94ca9135..00000000
--- a/backend/calendar_app/models.py
+++ /dev/null
@@ -1,655 +0,0 @@
-# backend/calendar_app/models.py
-from __future__ import annotations
-
-from datetime import datetime
-
-from django.contrib.auth import get_user_model
-from django.core.exceptions import ValidationError
-from django.db import models
-from django.db.models import Q
-from django.urls import reverse
-from django.utils.translation import gettext_lazy as _
-
-User = get_user_model()
-
-
-class Recurrence(models.TextChoices):
- """Типы повторяемости события календаря."""
-
- ONE_TIME = "one_time", _("Одноразовое")
- HOURLY = "hourly", _("Ежечасно")
- DAILY = "daily", _("Ежедневно")
- WEEKLY = "weekly", _("Еженедельно")
- MONTHLY = "monthly", _("Ежемесячно")
- ANNUAL = "annual", _("Ежегодно")
-
-
-class CalendarVisibility(models.TextChoices):
- """Видимость календаря."""
-
- PUBLIC = "public", _("Публичный (все видят)")
- DEPARTMENT = "department", _("Отдел (только члены отдела)")
- PRIVATE = "private", _("Приватный (только владелец)")
- CUSTOM = "custom", _("Настраиваемый (через права)")
-
-
-class CalendarManager(models.Manager):
- """Менеджер для модели Calendar с дополнительными методами."""
-
- def get_available_for_user(self, user):
- """Возвращает календари, доступные для просмотра пользователю."""
- if user.is_superuser or user.is_staff:
- # Админы видят все активные календари
- return self.filter(is_active=True)
-
- # Публичные календари
- q = Q(visibility=CalendarVisibility.PUBLIC, is_active=True)
-
- # Календари, где пользователь владелец
- q |= Q(owner_user=user, is_active=True)
-
- # Календари отделов, где пользователь член
- user_departments = user.departments_links.filter(is_active=True).values_list(
- "department_id", flat=True
- )
-
- q |= Q(
- owner_department_id__in=user_departments,
- visibility=CalendarVisibility.DEPARTMENT,
- is_active=True,
- )
-
- # Календари с подпиской пользователя
- subscribed_calendar_ids = user.calendar_subscriptions.values_list(
- "calendar_id", flat=True
- )
- q |= Q(id__in=subscribed_calendar_ids, is_active=True)
-
- return self.filter(q).distinct()
-
-
-class Calendar(models.Model):
- """Настраиваемый календарь (опциональный, расширенная функциональность).
-
- Если событие создано БЕЗ calendar → работает старая логика (department/employee).
- Если событие создано С calendar → игнорируются department/employee, используются настройки календаря.
- """
-
- # Основное
- title = models.CharField(_("Название"), max_length=200)
- description = models.TextField(_("Описание"), blank=True)
- color = models.CharField(
- _("Цвет"), max_length=7, default="#0d6efd", help_text="#RRGGBB"
- )
- icon = models.CharField(
- _("Иконка"),
- max_length=50,
- blank=True,
- help_text=_("Bootstrap icon, например: calendar-event"),
- )
-
- # Владение (опциональное)
- owner_user = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="owned_calendars",
- null=True,
- blank=True,
- verbose_name=_("Владелец"),
- help_text=_("Если задано — личный календарь пользователя"),
- )
-
- owner_department = models.ForeignKey(
- "employees.Department",
- on_delete=models.CASCADE,
- related_name="owned_calendars",
- null=True,
- blank=True,
- verbose_name=_("Отдел-владелец"),
- help_text=_("Если задано — календарь отдела"),
- )
-
- # Настройки видимости
- visibility = models.CharField(
- _("Видимость"),
- max_length=20,
- choices=CalendarVisibility.choices,
- default=CalendarVisibility.CUSTOM,
- )
-
- # Права по умолчанию для новых подписчиков
- default_can_edit = models.BooleanField(
- _("Могут редактировать по умолчанию"),
- default=False,
- help_text=_("Если True, все подписчики могут создавать/редактировать события"),
- )
-
- # Автоподписка
- auto_subscribe_new_users = models.BooleanField(
- _("Автоподписка для новых пользователей"),
- default=False,
- help_text=_("Автоматически подписывать новых сотрудников"),
- )
-
- auto_subscribe_department_members = models.BooleanField(
- _("Автоподписка для членов отдела"),
- default=False,
- help_text=_("Автоматически подписывать членов отдела-владельца"),
- )
-
- # Служебное
- is_active = models.BooleanField(_("Активен"), default=True)
- created_by = models.ForeignKey(
- User,
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name="calendars_created",
- verbose_name=_("Создал"),
- )
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
-
- objects = CalendarManager()
-
- class Meta:
- verbose_name = _("Календарь")
- verbose_name_plural = _("Календари")
- ordering = ["-created_at"]
- indexes = [
- models.Index(fields=["owner_user", "is_active"]),
- models.Index(fields=["owner_department", "is_active"]),
- models.Index(fields=["visibility"]),
- ]
-
- def __str__(self):
- if self.owner_user:
- return f"{self.title} ({self.owner_user.username})"
- if self.owner_department:
- return f"{self.title} ({self.owner_department.name})"
- return f"{self.title} (Глобальный)"
-
- def clean(self):
- # Нельзя одновременно user и department
- if self.owner_user and self.owner_department:
- raise ValidationError(
- _("Календарь не может одновременно принадлежать пользователю и отделу.")
- )
-
- @property
- def is_global(self):
- """Глобальный календарь (без владельца)."""
- return not self.owner_user_id and not self.owner_department_id
-
- @property
- def is_personal(self):
- """Личный календарь пользователя."""
- return bool(self.owner_user_id)
-
- @property
- def is_department(self):
- """Календарь отдела."""
- return bool(self.owner_department_id)
-
- def is_owner(self, user):
- """Проверяет, является ли пользователь владельцем календаря."""
- if self.owner_user_id:
- return self.owner_user_id == user.id
- if self.owner_department_id:
- # Владелец отдела = руководитель отдела
- return (
- self.owner_department.head_id == user.id
- if hasattr(self.owner_department, "head_id")
- else False
- )
- # Глобальный календарь — владелец только админы
- return user.is_superuser or user.is_staff
-
- def can_user_view(self, user):
- """Проверяет, может ли пользователь просматривать календарь."""
- if not self.is_active:
- return False
-
- if user.is_superuser or user.is_staff:
- return True
-
- # Владелец всегда может просматривать
- if self.is_owner(user):
- return True
-
- # Публичный календарь
- if self.visibility == CalendarVisibility.PUBLIC:
- return True
-
- # Календарь отдела
- if (
- self.visibility == CalendarVisibility.DEPARTMENT
- and self.owner_department_id
- ):
- # Проверяем, является ли пользователь членом отдела
- return user.departments_links.filter(
- department_id=self.owner_department_id, is_active=True
- ).exists()
-
- # Приватный или настраиваемый — проверяем подписку
- if self.visibility in [CalendarVisibility.PRIVATE, CalendarVisibility.CUSTOM]:
- return self.subscriptions.filter(user=user).exists()
-
- return False
-
- def can_user_edit(self, user):
- """Проверяет, может ли пользователь редактировать события в календаре."""
- if user.is_superuser or user.is_staff:
- return True
-
- # Владелец всегда может редактировать
- if self.is_owner(user):
- return True
-
- # Проверяем подписку с правом редактирования
- subscription = self.subscriptions.filter(user=user).first()
- if subscription and subscription.can_edit:
- return True
-
- # Если подписки нет или can_edit=False, проверяем default_can_edit
- # (для публичных календарей или календарей с общими правами редактирования)
- if self.default_can_edit:
- return True
-
- return False
-
- def can_user_manage(self, user):
- """Проверяет, может ли пользователь управлять календарем."""
- if user.is_superuser or user.is_staff:
- return True
-
- # Владелец всегда может управлять
- if self.is_owner(user):
- return True
-
- # Проверяем подписку с правом управления
- subscription = self.subscriptions.filter(user=user).first()
- if subscription:
- return subscription.can_manage
-
- return False
-
-
-class CalendarSubscription(models.Model):
- """Подписка пользователя на календарь с настройками отображения."""
-
- calendar = models.ForeignKey(
- Calendar,
- on_delete=models.CASCADE,
- related_name="subscriptions",
- verbose_name=_("Календарь"),
- )
-
- user = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="calendar_subscriptions",
- verbose_name=_("Пользователь"),
- )
-
- # Настройки отображения
- is_visible = models.BooleanField(_("Отображать"), default=True)
- color_override = models.CharField(
- _("Свой цвет"),
- max_length=7,
- blank=True,
- null=True,
- help_text=_("Переопределяет цвет календаря"),
- )
-
- # Права подписчика
- can_edit = models.BooleanField(
- _("Может редактировать"),
- default=False,
- help_text=_("Может создавать/редактировать события в этом календаре"),
- )
-
- can_manage = models.BooleanField(
- _("Может управлять"),
- default=False,
- help_text=_("Может управлять правами других пользователей"),
- )
-
- # Уведомления
- notify_on_new_event = models.BooleanField(
- _("Уведомлять о новых событиях"), default=True
- )
- notify_on_event_change = models.BooleanField(
- _("Уведомлять об изменениях"), default=True
- )
-
- subscribed_at = models.DateTimeField(auto_now_add=True)
-
- class Meta:
- verbose_name = _("Подписка на календарь")
- verbose_name_plural = _("Подписки на календари")
- unique_together = [["calendar", "user"]]
- indexes = [
- models.Index(fields=["user", "is_visible"]),
- models.Index(fields=["calendar", "can_edit"]),
- ]
-
- def __str__(self):
- return f"{self.user.username} → {self.calendar.title}"
-
-
-class CalendarEvent(models.Model):
- """Событие календаря с поддержкой повторяемости.
-
- ОБРАТНАЯ СОВМЕСТИМОСТЬ:
- - Если calendar = NULL → используется старая логика (department/employee)
- - Если calendar задан → игнорируются department/employee
-
- LEGACY (старая логика):
- - department = NULL и employee = NULL → событие компании (глобальное)
- - department задан и employee = NULL → событие отдела
- - employee задан и department = NULL → личное событие сотрудника
- """
-
- # ✨ НОВОЕ: Опциональная привязка к настраиваемому календарю
- calendar = models.ForeignKey(
- Calendar,
- on_delete=models.CASCADE,
- related_name="events",
- null=True,
- blank=True,
- verbose_name=_("Календарь"),
- help_text=_(
- "Если не задан — используется стандартная логика (department/employee)"
- ),
- )
-
- # ✅ LEGACY: Старые поля для обратной совместимости
- department = models.ForeignKey(
- "employees.Department",
- on_delete=models.CASCADE,
- related_name="calendar_events",
- verbose_name=_("Отдел"),
- null=True,
- blank=True,
- help_text=_("LEGACY: используется если calendar=NULL"),
- )
-
- employee = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="personal_calendar_events",
- verbose_name=_("Сотрудник"),
- null=True,
- blank=True,
- help_text=_("LEGACY: используется если calendar=NULL"),
- )
-
- # Основное
- title = models.CharField(_("Название"), max_length=200)
- description = models.TextField(_("Описание"), blank=True)
-
- # Даты/время
- start_date = models.DateField(_("Дата начала"))
- end_date = models.DateField(_("Дата окончания"), null=True, blank=True)
- start_time = models.TimeField(_("Время начала"), null=True, blank=True)
- end_time = models.TimeField(_("Время окончания"), null=True, blank=True)
- all_day = models.BooleanField(_("Весь день"), default=True)
-
- # Повторяемость
- recurrence = models.CharField(
- _("Повторение"),
- max_length=20,
- choices=Recurrence.choices,
- default=Recurrence.ONE_TIME,
- )
- recurrence_interval = models.PositiveSmallIntegerField(
- _("Интервал повторения"),
- default=1,
- help_text=_("Шаг повторения (каждые N часов/дней/недель/месяцев)."),
- )
- recurrence_count = models.PositiveIntegerField(
- _("Количество повторений"),
- null=True,
- blank=True,
- help_text=_("Максимум создаваемых вхождений; нельзя вместе с датой окончания."),
- )
- recurrence_until = models.DateField(
- _("Повторять до (включительно)"),
- null=True,
- blank=True,
- help_text=_(
- "Дата, до которой повторять. Нельзя вместе с количеством повторов."
- ),
- )
- weekdays_mask = models.PositiveSmallIntegerField(
- _("Дни недели (битовая маска)"),
- default=0,
- help_text=_(
- "Для еженедельных событий: 1=Пн,2=Вт,4=Ср,8=Чт,16=Пт,32=Сб,64=Вс; можно суммировать."
- ),
- )
-
- # Отображение
- color = models.CharField(
- _("Цвет (hex)"), max_length=7, blank=True, help_text="#RRGGBB"
- )
- location = models.CharField(_("Локация"), max_length=200, blank=True)
-
- # Служебное
- created_by = models.ForeignKey(
- User,
- verbose_name=_("Создал"),
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name="calendar_events_created",
- )
- created_at = models.DateTimeField(auto_now_add=True)
-
- source = models.CharField(
- _("Источник события"),
- max_length=120,
- blank=True,
- db_index=True,
- help_text=_(
- "Технический ключ связи с внешним объектом, напр. 'employee::birthday'"
- ),
- )
-
- class Meta:
- verbose_name = _("Событие календаря")
- verbose_name_plural = _("События календаря")
- ordering = ["start_date", "start_time"]
- indexes = [
- # Новые индексы для calendar
- models.Index(fields=["calendar", "start_date"]),
- models.Index(fields=["calendar", "recurrence", "start_date"]),
- # Legacy индексы для обратной совместимости
- models.Index(fields=["department", "start_date"]),
- models.Index(fields=["department", "recurrence", "start_date"]),
- models.Index(fields=["employee", "start_date"]),
- models.Index(fields=["start_date", "end_date"]),
- models.Index(fields=["start_date", "start_time"]),
- models.Index(fields=["source"]),
- ]
- constraints = [
- models.UniqueConstraint(
- fields=["source"],
- name="cal_event_source_unique",
- condition=~models.Q(source=""),
- ),
- ]
-
- def __str__(self) -> str:
- # НОВОЕ: Приоритет calendar
- if self.calendar_id:
- return f"[{self.calendar.title}] {self.title} ({self.start_date:%d.%m.%Y})"
-
- # LEGACY: Старая логика
- if self.employee_id:
- scope = f"Личный ({self.employee})"
- elif self.department_id:
- scope = str(self.department)
- else:
- scope = _("Компания")
-
- if self.end_date:
- return (
- f"{scope}: {self.title} "
- f"({self.start_date:%d.%m.%Y}–{self.end_date:%d.%m.%Y})"
- )
- return f"{scope}: {self.title} ({self.start_date:%d.%m.%Y})"
-
- @property
- def is_legacy_event(self) -> bool:
- """True если событие использует старую логику (без calendar)."""
- return self.calendar_id is None
-
- @property
- def is_modern_event(self) -> bool:
- """True если событие использует новую логику (с calendar)."""
- return self.calendar_id is not None
-
- @property
- def is_company(self) -> bool:
- """True если событие глобальное (LEGACY: без отдела и сотрудника)."""
- return (
- self.calendar_id is None
- and self.department_id is None
- and self.employee_id is None
- )
-
- @property
- def is_personal(self) -> bool:
- """True если событие личное (LEGACY или calendar.is_personal)."""
- if self.calendar_id:
- return self.calendar.is_personal
- return self.employee_id is not None
-
- @property
- def has_time(self) -> bool:
- """True если задано точное время (оба поля времени)."""
- return self.start_time is not None and self.end_time is not None
-
- # ---------- Валидация ----------
- def clean(self) -> None:
- """Валидирует согласованность дат/времени и повторяемости.
-
- Raises:
- ValidationError: При некорректных датах/времени или настройках.
- """
- # НОВОЕ: Если указан calendar, department и employee должны быть пусты
- if self.calendar_id:
- if self.department_id or self.employee_id:
- raise ValidationError(
- _(
- "При использовании календаря нельзя указывать "
- "department или employee."
- )
- )
-
- # LEGACY: Проверка взаимоисключающих полей (только если calendar=NULL)
- if not self.calendar_id:
- if self.department_id and self.employee_id:
- raise ValidationError(
- _("Событие не может одновременно принадлежать отделу и сотруднику.")
- )
-
- # База дат
- if self.end_date and self.start_date and self.end_date < self.start_date:
- raise ValidationError(
- {"end_date": _("Дата окончания не может быть раньше даты начала.")}
- )
-
- # Время — либо оба, либо ни одного
- if (self.start_time is None) ^ (self.end_time is None):
- raise ValidationError(
- _("Если указываете время, заполните и начало, и окончание.")
- )
-
- # В пределах одного дня: end_time >= start_time
- if (
- self.has_time
- and self.end_date
- and self.start_date == self.end_date
- and self.end_time < self.start_time
- ):
- raise ValidationError(
- {"end_time": _("Время окончания не может быть раньше времени начала.")}
- )
-
- # Согласование all_day
- if self.has_time and self.all_day:
- self.all_day = False
- if (not self.has_time) and (not self.all_day):
- self.all_day = True
-
- # Повторяемость: ограничения
- if self.recurrence in {Recurrence.HOURLY} and not self.has_time:
- raise ValidationError(
- _("Ежечасное событие требует указания времени начала и окончания.")
- )
-
- if (
- self.recurrence in {Recurrence.HOURLY, Recurrence.DAILY}
- and self.recurrence_interval < 1
- ):
- raise ValidationError(
- {"recurrence_interval": _("Интервал должен быть ≥ 1.")}
- )
-
- if self.recurrence == Recurrence.WEEKLY:
- if self.recurrence_interval < 1:
- raise ValidationError(
- {"recurrence_interval": _("Интервал должен быть ≥ 1.")}
- )
- # Если маска не задана — используем день недели start_date
- if self.weekdays_mask == 0 and self.start_date:
- self.weekdays_mask = 1 << (
- (self.start_date.weekday()) % 7
- ) # 0=Mon...6=Sun
-
- if self.recurrence == Recurrence.MONTHLY and self.recurrence_interval < 1:
- raise ValidationError(
- {"recurrence_interval": _("Интервал должен быть ≥ 1.")}
- )
-
- # until/count взаимоисключительны
- if self.recurrence_until and self.recurrence_count:
- raise ValidationError(
- _("Нельзя одновременно задавать 'до даты' и 'количество повторов'.")
- )
-
- # ----- Утилиты для фронта -----
- def iso_start(self) -> str:
- """Возвращает ISO-строку начала (date или datetime)."""
- if self.has_time:
- dt = datetime.combine(self.start_date, self.start_time)
- return dt.isoformat()
- return self.start_date.isoformat()
-
- def iso_end(self) -> str:
- """Возвращает ISO-строку конца (date или datetime). Если end_date пустая — возвращает start."""
- d = self.end_date or self.start_date
- if self.has_time and self.end_date:
- dt = datetime.combine(d, self.end_time)
- return dt.isoformat()
- return d.isoformat()
-
- def get_absolute_url(self) -> str:
- """URL календаря по области события."""
- if self.department_id:
- return reverse(
- "calendar_app:department_calendar", args=[self.department_id]
- )
- return reverse("calendar_app:company_calendar")
-
- def save(self, *args, **kwargs) -> None:
- """Приводит маску дней недели к 0, если она не задана.
-
- Это нужно для фикстур/сырого .create(...), чтобы не падать на NOT NULL.
- """
- if self.weekdays_mask is None:
- self.weekdays_mask = 0
- super().save(*args, **kwargs)
diff --git a/backend/calendar_app/notification_signals.py.old b/backend/calendar_app/notification_signals.py.old
deleted file mode 100644
index f85af20e..00000000
--- a/backend/calendar_app/notification_signals.py.old
+++ /dev/null
@@ -1,218 +0,0 @@
-"""
-Signals для автоматической генерации уведомлений в модуле Calendar.
-
-Обрабатывает события:
-- Новое событие календаря
-- Изменение события
-- Отмена события
-
-Напоминания (за час, за день) будут реализованы через Celery tasks.
-"""
-
-from django.db.models.signals import post_save, pre_delete, pre_save
-from django.dispatch import receiver
-from django.contrib.auth import get_user_model
-
-from .models import CalendarEvent
-from notifications.signals import notify
-
-Employee = get_user_model()
-
-
-@receiver(post_save, sender=CalendarEvent)
-def create_event_notifications(sender, instance, created, **kwargs):
- """
- Создает уведомления при создании или изменении события асинхронно.
-
- Уведомления отправляются:
- - При создании - всем сотрудникам компании или отдела
- - При изменении - участникам события
- """
- # Отправляем уведомления напрямую - channels.py автоматически отправит через Celery
- if created:
- notify_event_created(instance)
- else:
- changed_fields = getattr(instance, '_changed_fields', [])
- if changed_fields:
- notify_event_changed(instance, changed_fields)
-
-
-@receiver(pre_save, sender=CalendarEvent)
-def track_event_changes(sender, instance, **kwargs):
- """
- Отслеживает изменения важных полей события для уведомлений.
- """
- if instance.pk:
- try:
- old_event = CalendarEvent.objects.get(pk=instance.pk)
- changed_fields = []
-
- # Отслеживаем важные поля
- important_fields = [
- 'title', 'start_date', 'end_date',
- 'start_time', 'end_time', 'location'
- ]
-
- for field in important_fields:
- old_value = getattr(old_event, field)
- new_value = getattr(instance, field)
- if old_value != new_value:
- changed_fields.append(field)
-
- instance._changed_fields = changed_fields
- except CalendarEvent.DoesNotExist:
- instance._changed_fields = []
-
-
-@receiver(pre_delete, sender=CalendarEvent)
-def notify_event_cancelled(sender, instance, **kwargs):
- """
- Создает уведомления при удалении (отмене) события асинхронно.
- """
- # Отправляем уведомления напрямую - channels.py автоматически отправит через Celery
- recipients = get_event_recipients(instance)
- for recipient in recipients:
- notify.send(
- sender=None,
- recipient=recipient,
- verb='event_cancelled',
- description=(
- f'Событие "{instance.title}" '
- f'({instance.start_date.strftime("%d.%m.%Y")}) отменено'
- ),
- action_url=get_calendar_url(instance),
- data={
- 'title': 'Событие отменено',
- 'event_title': instance.title,
- 'event_date': instance.start_date.isoformat(),
- 'department_id': (
- instance.department.id if instance.department else None
- ),
- 'is_company_event': instance.is_company,
- }
- )
-
-
-# ===== Вспомогательные функции =====
-
-def notify_event_created(event):
- """
- Отправляет уведомления о новом событии всем участникам.
- """
- recipients = get_event_recipients(event)
-
- # Исключаем создателя события
- if event.created_by:
- recipients = [r for r in recipients if r.id != event.created_by.id]
-
- event_scope = (
- f'отдела {event.department.name}'
- if event.department
- else 'компании'
- )
-
- for recipient in recipients:
- notify.send(
- sender=event.created_by,
- recipient=recipient,
- verb='event_created',
- action_object=event,
- description=(
- f'Добавлено событие {event_scope}: "{event.title}" '
- f'({event.start_date.strftime("%d.%m.%Y")})'
- ),
- action_url=get_calendar_url(event),
- data={
- 'title': 'Новое событие в календаре',
- 'event_id': event.id,
- 'event_title': event.title,
- 'event_date': event.start_date.isoformat(),
- 'department_id': (
- event.department.id if event.department else None
- ),
- 'is_company_event': event.is_company,
- 'created_by_id': (
- event.created_by.id if event.created_by else None
- ),
- }
- )
-
-
-def notify_event_changed(event, changed_fields):
- """
- Отправляет уведомления об изменении события.
- """
- if not changed_fields:
- return
-
- recipients = get_event_recipients(event)
-
- # Формируем сообщение об изменениях
- field_names = {
- 'title': 'название',
- 'start_date': 'дата начала',
- 'end_date': 'дата окончания',
- 'start_time': 'время начала',
- 'end_time': 'время окончания',
- 'location': 'место проведения',
- }
-
- changes_text = ', '.join([
- field_names.get(f, f) for f in changed_fields
- ])
-
- for recipient in recipients:
- notify.send(
- sender=None,
- recipient=recipient,
- verb='event_changed',
- action_object=event,
- description=(
- f'Событие "{event.title}" изменено. '
- f'Изменения: {changes_text}'
- ),
- action_url=get_calendar_url(event),
- data={
- 'title': 'Событие изменено',
- 'event_id': event.id,
- 'event_title': event.title,
- 'changed_fields': changed_fields,
- 'event_date': event.start_date.isoformat(),
- 'department_id': (
- event.department.id if event.department else None
- ),
- }
- )
-
-
-def get_event_recipients(event):
- """
- Возвращает список получателей уведомлений для события.
-
- - Для события компании - все активные сотрудники
- - Для события отдела - сотрудники этого отдела
- - Для личного события - только владелец
- """
- if event.is_personal:
- # Личное событие - только владелец
- return [event.employee] if event.employee else []
- elif event.is_company:
- # Событие компании - все активные сотрудники
- return list(Employee.objects.filter(is_active=True))
- elif event.department:
- # Событие отдела - сотрудники через EmployeeDepartment
- from employees.models import EmployeeDepartment
- employee_ids = EmployeeDepartment.objects.filter(
- department=event.department,
- is_active=True
- ).values_list('employee_id', flat=True)
- return list(Employee.objects.filter(id__in=employee_ids, is_active=True))
-
- return []
-
-
-def get_calendar_url(event):
- """
- Возвращает URL календаря.
- """
- return '/calendar'
diff --git a/backend/calendar_app/notifications/__init__.py b/backend/calendar_app/notifications/__init__.py
deleted file mode 100644
index 935b4690..00000000
--- a/backend/calendar_app/notifications/__init__.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""
-Модуль уведомлений для Calendar приложения.
-
-Публичный API:
- - NotificationVerbs - константы глаголов уведомлений
- - MessageTemplates - шаблоны сообщений
- - ActionURLs - генераторы URL-адресов
- - get_event_recipients - получение списка получателей для события
-
-Использование:
- from calendar_app.notifications import get_event_recipients
-
- # В коде:
- recipients = get_event_recipients(event)
-"""
-
-from .config import (
- NotificationVerbs,
- MessageTemplates,
- ActionURLs,
- format_date,
- format_changes,
-)
-from .handlers import get_event_recipients
-
-# Импортируем handlers и signals для регистрации
-from . import handlers # noqa: F401
-from . import signals # noqa: F401
-
-__all__ = [
- 'NotificationVerbs',
- 'MessageTemplates',
- 'ActionURLs',
- 'get_event_recipients',
- 'format_date',
- 'format_changes',
-]
diff --git a/backend/calendar_app/notifications/handlers.py b/backend/calendar_app/notifications/handlers.py
deleted file mode 100644
index a529f1ff..00000000
--- a/backend/calendar_app/notifications/handlers.py
+++ /dev/null
@@ -1,174 +0,0 @@
-"""
-Бизнес-логика отправки уведомлений для модуля Calendar.
-
-Функции:
-- get_event_recipients - определение получателей уведомлений
-- notify_event_created - уведомление о новом событии
-- notify_event_changed - уведомление об изменении события
-- notify_event_cancelled - уведомление об отмене события
-"""
-
-from django.contrib.auth import get_user_model
-from notifications.signals import notify
-
-from .config import (
- NotificationVerbs,
- MessageTemplates,
- ActionURLs,
- format_date,
- format_changes,
-)
-
-Employee = get_user_model()
-
-
-def get_event_recipients(event) -> list:
- """
- Возвращает список получателей уведомлений для события.
-
- Args:
- event: Объект CalendarEvent
-
- Returns:
- list: Список пользователей (Employee):
- - Для личного события - только владелец
- - Для события компании - все активные сотрудники
- - Для события отдела - сотрудники этого отдела
- """
- if event.is_personal:
- # Личное событие - только владелец
- return [event.employee] if event.employee else []
-
- elif event.is_company:
- # Событие компании - все активные сотрудники
- return list(Employee.objects.filter(is_active=True))
-
- elif event.department:
- # Событие отдела - сотрудники через EmployeeDepartment
- from employees.models import EmployeeDepartment
- employee_ids = EmployeeDepartment.objects.filter(
- department=event.department,
- is_active=True
- ).values_list('employee_id', flat=True)
- return list(Employee.objects.filter(id__in=employee_ids, is_active=True))
-
- return []
-
-
-def notify_event_created(event):
- """
- Отправляет уведомления о новом событии всем участникам.
-
- Args:
- event: Объект CalendarEvent
- """
- recipients = get_event_recipients(event)
-
- # Исключаем создателя события
- if event.created_by:
- recipients = [r for r in recipients if r.id != event.created_by.id]
-
- # Определяем тип события для сообщения
- event_date = format_date(event.start_date)
-
- if event.is_company:
- description = MessageTemplates.event_created_company(event.title, event_date)
- elif event.department:
- description = MessageTemplates.event_created_department(
- event.department.name,
- event.title,
- event_date
- )
- else:
- # Личное событие - не должно попадать сюда (нет других получателей)
- return
-
- # Отправляем уведомления
- for recipient in recipients:
- notify.send(
- sender=event.created_by,
- recipient=recipient,
- verb=NotificationVerbs.EVENT_CREATED,
- action_object=event,
- description=description,
- action_url=ActionURLs.CALENDAR,
- data={
- 'title': MessageTemplates.event_created_title(),
- 'event_id': event.id,
- 'event_title': event.title,
- 'event_date': event.start_date.isoformat(),
- 'department_id': (
- event.department.id if event.department else None
- ),
- 'is_company_event': event.is_company,
- 'created_by_id': (
- event.created_by.id if event.created_by else None
- ),
- }
- )
-
-
-def notify_event_changed(event, changed_fields: list[str]):
- """
- Отправляет уведомления об изменении события.
-
- Args:
- event: Объект CalendarEvent
- changed_fields: Список изменённых полей
- """
- if not changed_fields:
- return
-
- recipients = get_event_recipients(event)
-
- # Формируем сообщение об изменениях
- changes_text = format_changes(changed_fields)
-
- for recipient in recipients:
- notify.send(
- sender=None,
- recipient=recipient,
- verb=NotificationVerbs.EVENT_CHANGED,
- action_object=event,
- description=MessageTemplates.event_changed(event.title, changes_text),
- action_url=ActionURLs.CALENDAR,
- data={
- 'title': MessageTemplates.event_changed_title(),
- 'event_id': event.id,
- 'event_title': event.title,
- 'changed_fields': changed_fields,
- 'event_date': event.start_date.isoformat(),
- 'department_id': (
- event.department.id if event.department else None
- ),
- }
- )
-
-
-def notify_event_cancelled(event):
- """
- Отправляет уведомления об отмене (удалении) события.
-
- Args:
- event: Объект CalendarEvent
- """
- recipients = get_event_recipients(event)
- event_date = format_date(event.start_date)
-
- for recipient in recipients:
- notify.send(
- sender=None,
- recipient=recipient,
- verb=NotificationVerbs.EVENT_CANCELLED,
- description=MessageTemplates.event_cancelled(event.title, event_date),
- action_url=ActionURLs.CALENDAR,
- data={
- 'title': MessageTemplates.event_cancelled_title(),
- 'event_title': event.title,
- 'event_date': event.start_date.isoformat(),
- 'department_id': (
- event.department.id if event.department else None
- ),
- 'is_company_event': event.is_company,
- }
- )
diff --git a/backend/calendar_app/notifications/signals.py b/backend/calendar_app/notifications/signals.py
deleted file mode 100644
index 743abe9b..00000000
--- a/backend/calendar_app/notifications/signals.py
+++ /dev/null
@@ -1,75 +0,0 @@
-"""
-Django signals для автоматической генерации уведомлений в модуле Calendar.
-
-Обрабатывает события:
-- post_save для CalendarEvent - создание или изменение события
-- pre_save для CalendarEvent - отслеживание изменений полей
-- pre_delete для CalendarEvent - отмена (удаление) события
-"""
-
-from django.db.models.signals import post_save, pre_delete, pre_save
-from django.dispatch import receiver
-
-from ..models import CalendarEvent
-from .config import IMPORTANT_FIELDS
-from .handlers import (
- notify_event_created,
- notify_event_changed,
- notify_event_cancelled,
-)
-
-
-@receiver(post_save, sender=CalendarEvent)
-def create_event_notifications(sender, instance, created, **kwargs):
- """
- Создает уведомления при создании или изменении события.
-
- Уведомления отправляются:
- - При создании - всем сотрудникам компании или отдела
- - При изменении - участникам события (если изменились важные поля)
-
- Уведомления отправляются через универсальную систему
- channels.py → Celery → WebSocket/Email/Push
- """
- if created:
- notify_event_created(instance)
- else:
- changed_fields = getattr(instance, '_changed_fields', [])
- if changed_fields:
- notify_event_changed(instance, changed_fields)
-
-
-@receiver(pre_save, sender=CalendarEvent)
-def track_event_changes(sender, instance, **kwargs):
- """
- Отслеживает изменения важных полей события для уведомлений.
-
- Сохраняет список изменённых полей в атрибут _changed_fields,
- который затем используется в post_save signal.
- """
- if instance.pk:
- try:
- old_event = CalendarEvent.objects.get(pk=instance.pk)
- changed_fields = []
-
- # Проверяем изменения важных полей
- for field in IMPORTANT_FIELDS:
- old_value = getattr(old_event, field)
- new_value = getattr(instance, field)
- if old_value != new_value:
- changed_fields.append(field)
-
- instance._changed_fields = changed_fields
- except CalendarEvent.DoesNotExist:
- instance._changed_fields = []
-
-
-@receiver(pre_delete, sender=CalendarEvent)
-def notify_event_cancelled_signal(sender, instance, **kwargs):
- """
- Создает уведомления при удалении (отмене) события.
-
- Уведомления отправляются через универсальную систему
- channels.py → Celery → WebSocket/Email/Push
- """
- notify_event_cancelled(instance)
diff --git a/backend/calendar_app/rules.py b/backend/calendar_app/rules.py
deleted file mode 100644
index 721117b8..00000000
--- a/backend/calendar_app/rules.py
+++ /dev/null
@@ -1,376 +0,0 @@
-"""
-django-rules: декларативные правила доступа для calendar_app
-
-Правила используются для проверки permissions на уровне объектов.
-https://github.com/dfunckt/django-rules
-"""
-
-import rules
-
-
-# -----------------------------------------------------------------------------
-# ПРЕДИКАТЫ (predicates)
-# -----------------------------------------------------------------------------
-
-@rules.predicate
-def is_superuser(user):
- """Суперпользователь имеет все права"""
- return user.is_superuser
-
-
-@rules.predicate
-def is_calendar_owner(user, calendar):
- """Пользователь является владельцем календаря"""
- if calendar is None:
- return False
-
- # Для django-scheduler
- if hasattr(calendar, 'creator'):
- return calendar.creator == user
-
- # Альтернативные поля
- if hasattr(calendar, 'owner'):
- return calendar.owner == user
-
- return False
-
-
-@rules.predicate
-def is_personal_calendar(user, calendar):
- """Это личный календарь пользователя"""
- if calendar is None:
- return False
-
- # Проверка slug для личного календаря
- if hasattr(calendar, 'slug'):
- return calendar.slug == f'personal-{user.pk}'
-
- # Проверка через creator + тип календаря
- if hasattr(calendar, 'calendar_type') and calendar.calendar_type == 'personal':
- return is_calendar_owner(user, calendar)
-
- return False
-
-
-@rules.predicate
-def has_calendar_access(user, calendar):
- """Пользователь имеет доступ к календарю через подписку или права"""
- if calendar is None:
- return False
-
- # Публичный календарь
- if hasattr(calendar, 'is_public') and calendar.is_public:
- return True
-
- # Календарь отдела
- if hasattr(calendar, 'department') and hasattr(user, 'department'):
- if calendar.department == user.department:
- return True
-
- # Подписка на календарь
- if hasattr(calendar, 'subscriptions'):
- return calendar.subscriptions.filter(user=user).exists()
-
- # Календарь компании (доступен всем сотрудникам)
- if hasattr(calendar, 'calendar_type') and calendar.calendar_type == 'company':
- return user.is_staff
-
- return False
-
-
-@rules.predicate
-def is_event_creator(user, event):
- """Пользователь является создателем события"""
- if event is None:
- return False
-
- # django-scheduler использует creator_id
- if hasattr(event, 'creator'):
- return event.creator == user
-
- if hasattr(event, 'created_by'):
- return event.created_by == user
-
- return False
-
-
-@rules.predicate
-def can_access_event_calendar(user, event):
- """Пользователь имеет доступ к календарю, где находится событие"""
- if event is None or not hasattr(event, 'calendar'):
- return False
-
- return has_calendar_access(user, event.calendar) or is_calendar_owner(user, event.calendar)
-
-
-@rules.predicate
-def is_event_participant(user, event):
- """Пользователь является участником события"""
- if event is None:
- return False
-
- # Проверка через участников
- if hasattr(event, 'participants'):
- return user in event.participants.all()
-
- # Проверка через occurrence (для повторяющихся событий)
- if hasattr(event, 'occurrence_set'):
- return event.occurrence_set.filter(participants=user).exists()
-
- return False
-
-
-@rules.predicate
-def can_manage_calendars(user):
- """
- Пользователь может управлять календарями (администратор календарей).
- Адаптируйте под вашу логику.
- """
- if not hasattr(user, 'position'):
- return False
-
- position_name = getattr(user.position, 'name', '').lower()
- return any(keyword in position_name for keyword in [
- 'руководитель', 'начальник', 'директор', 'секретарь', 'администратор'
- ])
-
-
-@rules.predicate
-def is_department_calendar(user, calendar):
- """Календарь относится к отделу пользователя"""
- if calendar is None or not hasattr(user, 'department'):
- return False
-
- if hasattr(calendar, 'department'):
- return calendar.department == user.department
-
- return False
-
-
-# -----------------------------------------------------------------------------
-# ПРАВИЛА (rules)
-# -----------------------------------------------------------------------------
-
-# Просмотр календаря
-rules.add_rule(
- 'calendar_app.view_calendar',
- is_superuser | is_calendar_owner | is_personal_calendar |
- has_calendar_access | is_department_calendar
-)
-
-# Изменение календаря (настройки, название, описание)
-rules.add_rule(
- 'calendar_app.change_calendar',
- is_superuser | is_calendar_owner | can_manage_calendars
-)
-
-# Удаление календаря
-rules.add_rule(
- 'calendar_app.delete_calendar',
- is_superuser | is_calendar_owner
-)
-
-# Создание события в календаре
-rules.add_rule(
- 'calendar_app.create_event',
- is_superuser | is_calendar_owner | has_calendar_access
-)
-
-# Просмотр события
-rules.add_rule(
- 'calendar_app.view_event',
- is_superuser | is_event_creator | can_access_event_calendar | is_event_participant
-)
-
-# Изменение события
-rules.add_rule(
- 'calendar_app.change_event',
- is_superuser | is_event_creator | is_calendar_owner
-)
-
-# Удаление события
-rules.add_rule(
- 'calendar_app.delete_event',
- is_superuser | is_event_creator | is_calendar_owner
-)
-
-# Подписка на календарь
-rules.add_rule(
- 'calendar_app.subscribe_calendar',
- rules.is_authenticated # Любой авторизованный может подписаться на публичный календарь
-)
-
-# Отмена подписки на календарь
-rules.add_rule(
- 'calendar_app.unsubscribe_calendar',
- rules.is_authenticated
-)
-
-# Экспорт календаря (iCal)
-rules.add_rule(
- 'calendar_app.export_calendar',
- is_superuser | is_calendar_owner | has_calendar_access
-)
-
-# Приглашение участников на событие
-rules.add_rule(
- 'calendar_app.invite_participants',
- is_superuser | is_event_creator | is_calendar_owner
-)
-
-# Просмотр всех календарей (для администраторов)
-rules.add_rule(
- 'calendar_app.view_all_calendars',
- is_superuser | can_manage_calendars
-)
-
-
-# -----------------------------------------------------------------------------
-# ИСПОЛЬЗОВАНИЕ В КОДЕ
-# -----------------------------------------------------------------------------
-
-"""
-# В views:
-from django.core.exceptions import PermissionDenied
-import rules
-
-def calendar_detail(request, slug):
- calendar = get_object_or_404(Calendar, slug=slug)
-
- if not rules.test_rule('calendar_app.view_calendar', request.user, calendar):
- raise PermissionDenied("У вас нет доступа к этому календарю")
-
- events = calendar.event_set.all()
- return render(request, 'calendar_app/calendar.html', {
- 'calendar': calendar,
- 'events': events
- })
-
-
-def event_create(request, calendar_slug):
- calendar = get_object_or_404(Calendar, slug=calendar_slug)
-
- if not rules.test_rule('calendar_app.create_event', request.user, calendar):
- return JsonResponse({'error': 'Нет прав на создание событий'}, status=403)
-
- # Логика создания события
- event = Event.objects.create(
- calendar=calendar,
- creator=request.user,
- title=request.POST.get('title'),
- start=request.POST.get('start'),
- end=request.POST.get('end'),
- )
-
- return JsonResponse({'event_id': event.pk})
-
-
-def event_update(request, pk):
- event = get_object_or_404(Event, pk=pk)
-
- if not rules.test_rule('calendar_app.change_event', request.user, event):
- return JsonResponse({'error': 'Нет прав на изменение'}, status=403)
-
- event.title = request.POST.get('title', event.title)
- event.save()
-
- return JsonResponse({'success': True})
-
-
-# В templates:
-{% load rules %}
-
-{% has_rule 'calendar_app.create_event' user calendar as can_create %}
-{% if can_create %}
-
-{% endif %}
-
-{% for event in events %}
-
-
{{ event.title }}
-
{{ event.start|date:"d.m.Y H:i" }} - {{ event.end|date:"d.m.Y H:i" }}
-
- {% has_rule 'calendar_app.change_event' user event as can_edit %}
- {% if can_edit %}
-
- Редактировать
-
- {% endif %}
-
- {% has_rule 'calendar_app.delete_event' user event as can_delete %}
- {% if can_delete %}
-
- {% endif %}
-
-{% endfor %}
-
-
-# В DRF permissions (уже реализовано в api/v1/schedule/permissions.py):
-from rest_framework import permissions
-import rules
-
-class IsCalendarOwnerOrAdmin(permissions.BasePermission):
- def has_object_permission(self, request, view, obj):
- # Для Calendar
- if hasattr(obj, 'creator'):
- calendar = obj
- # Для Event
- elif hasattr(obj, 'calendar'):
- calendar = obj.calendar
- else:
- return False
-
- if request.method in permissions.SAFE_METHODS:
- return rules.test_rule('calendar_app.view_calendar', request.user, calendar)
- elif request.method in ['POST']:
- return rules.test_rule('calendar_app.create_event', request.user, calendar)
- elif request.method in ['PUT', 'PATCH']:
- if hasattr(obj, 'calendar'): # Event
- return rules.test_rule('calendar_app.change_event', request.user, obj)
- else: # Calendar
- return rules.test_rule('calendar_app.change_calendar', request.user, obj)
- elif request.method == 'DELETE':
- if hasattr(obj, 'calendar'):
- return rules.test_rule('calendar_app.delete_event', request.user, obj)
- else:
- return rules.test_rule('calendar_app.delete_calendar', request.user, obj)
-
- return False
-
-
-# Фильтрация QuerySet (только доступные календари):
-from django.db.models import Q
-
-def get_accessible_calendars(user):
- return Calendar.objects.filter(
- Q(creator=user) | # Собственные календари
- Q(slug=f'personal-{user.pk}') | # Личный календарь
- Q(is_public=True) | # Публичные календари
- Q(department=user.department) | # Календари отдела
- Q(calendar_type='company') | # Корпоративный календарь
- Q(subscriptions__user=user) # Подписки
- ).distinct()
-
-
-# Middleware для защиты iCal экспорта:
-class CalendarExportMiddleware:
- def __init__(self, get_response):
- self.get_response = get_response
-
- def __call__(self, request):
- if request.path.startswith('/calendar/export/'):
- # Извлечение календаря
- slug = request.path.split('/')[-2]
- calendar = Calendar.objects.filter(slug=slug).first()
-
- if calendar and not rules.test_rule('calendar_app.export_calendar', request.user, calendar):
- return HttpResponseForbidden("У вас нет прав на экспорт этого календаря")
-
- return self.get_response(request)
-"""
diff --git a/backend/calendar_app/services/__init__.py b/backend/calendar_app/services/__init__.py
deleted file mode 100644
index 28beea64..00000000
--- a/backend/calendar_app/services/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""
-Сервисный слой для calendar_app.
-
-Note: Birthday event services были перенесены в employees.services
-"""
-
-__all__ = []
diff --git a/backend/calendar_app/services/recurrence.py b/backend/calendar_app/services/recurrence.py
deleted file mode 100644
index 1e3448d8..00000000
--- a/backend/calendar_app/services/recurrence.py
+++ /dev/null
@@ -1,261 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from datetime import date, datetime, time, timedelta
-from typing import List, Optional, Union
-
-from calendar_app.models import CalendarEvent, Recurrence
-
-
-@dataclass
-class Occurrence:
- """Материализованное вхождение события.
-
- Attributes:
- start (datetime|date): Начало вхождения.
- end (datetime|date): Конец вхождения (для дат — конец дня, для datetime — точный момент).
- all_day (bool): Признак события на весь день.
-
- Optional Meta (можно не задавать при создании):
- id (int|None)
- title (str|None)
- color (str|None)
- recurrence (str|None)
- department_id (int|None)
- """
- start: Union[datetime, date]
- end: Union[datetime, date]
- all_day: bool
-
- id: Optional[int] = None
- title: Optional[str] = None
- color: Optional[str] = None
- recurrence: Optional[str] = None
- department_id: Optional[int] = None
-
- @property
- def allDay(self) -> bool:
- """Legacy-алиас для совместимости с camelCase."""
- return self.all_day
-
-
-# ===== вспомогательные функции работы с датами/временем =====
-
-def _clamp_day(year: int, month: int, day: int) -> date:
- """Безопасно подбирает день месяца (31→30→29→28)."""
- for d in (day, 30, 29, 28):
- try:
- return date(year, month, min(d, day))
- except ValueError:
- continue
- return date(year, month, 28)
-
-
-def _add_months(d: date, months: int) -> date:
- y = d.year + (d.month - 1 + months) // 12
- m = (d.month - 1 + months) % 12 + 1
- return _clamp_day(y, m, d.day)
-
-
-def _add_years(d: date, years: int) -> date:
- y = d.year + years
- m = d.month
- dd = d.day
- if m == 2 and dd == 29:
- try:
- return date(y, 2, 29)
- except ValueError:
- return date(y, 2, 28)
- return date(y, m, dd)
-
-
-def _overlaps(a_start, a_end, b_start, b_end) -> bool:
- """Проверяет пересечение двух интервалов одинакового типа (оба date либо оба datetime)."""
- return a_start < b_end and a_end > b_start
-
-
-# ===== основная функция развёртки =====
-
-def expand_event_occurrences(
- ev: CalendarEvent,
- range_start: Union[date, datetime],
- range_end: Union[date, datetime],
- max_instances: int = 1000,
-) -> List[Occurrence]:
- """Материализует вхождения события в окне [range_start, range_end).
-
- Args:
- ev: Экземпляр CalendarEvent.
- range_start: Левая граница окна (включительно), допускается date или datetime.
- range_end: Правая граница окна (исключительно), допускается date или datetime.
- max_instances: Жёсткий лимит количества вхождений (для безопасности).
-
- Returns:
- list[Occurrence]: Список вхождений.
-
- Notes:
- - Для all-day события используется сравнение по датам.
- - Для событий с временем — сравнение по datetime (окно приводится к datetime).
- - Минимальная длительность: 1 день для all-day, 1 минута для timed.
- """
- # пустое окно
- if isinstance(range_start, datetime) and isinstance(range_end, datetime):
- if range_start >= range_end:
- return []
- else:
- # приводим к датам для проверки, если хотя бы один — date
- rs_d = range_start.date() if isinstance(range_start, datetime) else range_start
- re_d = range_end.date() if isinstance(range_end, datetime) else range_end
- if rs_d >= re_d:
- return []
-
- all_day = ev.all_day
- s_date = ev.start_date
- e_date = ev.end_date or ev.start_date
- s_time: Optional[time] = ev.start_time
- e_time: Optional[time] = ev.end_time
-
- # базовые границы одного экземпляра
- base_start = datetime.combine(s_date, s_time or time.min) if not all_day else s_date
- base_end = datetime.combine(e_date, e_time or time.max) if not all_day else e_date
-
- # длительность одного экземпляра
- duration = (
- (base_end - base_start)
- if not all_day
- else timedelta(days=(e_date - s_date).days + 1)
- )
- if duration <= timedelta(0):
- duration = timedelta(minutes=1) if not all_day else timedelta(days=1)
-
- # нормализуем окно под тип события (чтобы не сравнивать datetime с date)
- if all_day:
- RS = range_start.date() if isinstance(range_start, datetime) else range_start
- RE = range_end.date() if isinstance(range_end, datetime) else range_end
-
- def push(start_d: date) -> bool:
- end_d = start_d + duration # timedelta дней
- if _overlaps(start_d, end_d, RS, RE):
- occ.append(Occurrence(start=start_d, end=end_d, all_day=True))
- return len(occ) >= max_instances
- else:
- RS = range_start if isinstance(range_start, datetime) else datetime.combine(range_start, time.min)
- RE = range_end if isinstance(range_end, datetime) else datetime.combine(range_end, time.min)
-
- def push(start_dt: datetime) -> bool:
- end_dt = start_dt + duration
- if _overlaps(start_dt, end_dt, RS, RE):
- occ.append(Occurrence(start=start_dt, end=end_dt, all_day=False))
- return len(occ) >= max_instances
-
- occ: List[Occurrence] = []
-
- rec = ev.recurrence
- interval = max(1, ev.recurrence_interval or 1)
- until: Optional[date] = ev.recurrence_until
- count_limit = ev.recurrence_count or 10**9
- produced = 0
-
- # ---- ONE-TIME
- if rec == Recurrence.ONE_TIME:
- push(base_start if not all_day else s_date)
- return occ
-
- # ---- HOURLY (только timed)
- if rec == Recurrence.HOURLY:
- # базовый курсор
- cur = datetime.combine(s_date, s_time or time.min)
-
- # догоняем до начала окна
- while cur + duration <= RS:
- cur += timedelta(hours=interval)
-
- # генерация в окне
- while produced < count_limit and (until is None or cur.date() <= until):
- if cur >= RE:
- break
- if push(cur):
- break
- produced += 1
- cur += timedelta(hours=interval)
- return occ
-
- # ---- DAILY
- if rec == Recurrence.DAILY:
- cur = s_date
- # догоняем до начала окна
- RS_d = RS if all_day else RS.date()
- while cur + timedelta(days=1) <= RS_d:
- cur += timedelta(days=interval)
-
- while produced < count_limit and (until is None or cur <= until):
- # выход за окно
- RE_d = RE if all_day else RE.date()
- if cur >= RE_d:
- break
- if push(cur if all_day else datetime.combine(cur, s_time or time.min)):
- break
- produced += 1
- cur += timedelta(days=interval)
- return occ
-
- # ---- WEEKLY
- if rec == Recurrence.WEEKLY:
- mask = ev.weekdays_mask or 0
- allowed = {i for i in range(7) if (mask >> i) & 1} or {s_date.weekday()}
-
- def weeks_from_start(d: date) -> int:
- return (d - s_date).days // 7
-
- cur = max(s_date, RS if isinstance(RS, date) else RS.date())
- safe_guard = 0
- while produced < count_limit and cur < (RE if isinstance(RE, date) else RE.date()) and safe_guard < max_instances * 14:
- if (weeks_from_start(cur) % interval == 0) and (cur.weekday() in allowed):
- if push(cur if all_day else datetime.combine(cur, s_time or time.min)):
- break
- produced += 1
- cur += timedelta(days=1)
- if until and cur > until:
- break
- safe_guard += 1
- return occ
-
- # ---- MONTHLY
- if rec == Recurrence.MONTHLY:
- cur = s_date
- RS_d = RS if all_day else RS.date()
- while _add_months(cur, interval) <= RS_d:
- cur = _add_months(cur, interval)
-
- while produced < count_limit and (until is None or cur <= until):
- RE_d = RE if all_day else RE.date()
- if cur >= RE_d:
- break
- if push(cur if all_day else datetime.combine(cur, s_time or time.min)):
- break
- produced += 1
- cur = _add_months(cur, interval)
- return occ
-
- # ---- ANNUAL
- if rec == Recurrence.ANNUAL:
- cur = s_date
- RS_d = RS if all_day else RS.date()
- nxt = _add_years(cur, interval)
- while nxt <= RS_d:
- cur = nxt
- nxt = _add_years(cur, interval)
-
- while produced < count_limit and (until is None or cur <= until):
- RE_d = RE if all_day else RE.date()
- if cur >= RE_d:
- break
- if push(cur if all_day else datetime.combine(cur, s_time or time.min)):
- break
- produced += 1
- cur = _add_years(cur, interval)
- return occ
-
- # fallback
- push(base_start if not all_day else s_date)
- return occ
diff --git a/backend/calendar_app/signals.py b/backend/calendar_app/signals.py
deleted file mode 100644
index 258fd7ef..00000000
--- a/backend/calendar_app/signals.py
+++ /dev/null
@@ -1,156 +0,0 @@
-# backend/calendar_app/signals.py
-from __future__ import annotations
-
-from datetime import date
-from typing import Optional
-
-from django.apps import apps as django_apps
-from django.db import transaction
-from django.db.models.signals import post_delete, post_save
-from django.dispatch import receiver
-from django.utils.translation import gettext_lazy as _
-
-from calendar_app.models import CalendarEvent, Recurrence
-
-
-BIRTHDAY_COLOR = "#FFC107" # опционально — визуальный цвет
-BIRTHDAY_SOURCE_FMT = "employee:{id}:birthday"
-
-
-def _get_employee_birthday(obj) -> Optional[date]:
- """Извлекает дату рождения сотрудника из популярных полей.
-
- Args:
- obj: Экземпляр модели сотрудника.
-
- Returns:
- Optional[date]: Дата рождения или None.
-
- Raises:
- AttributeError: Не выбрасывается наружу; поля проверяются безопасно.
- """
- for attr in ("birth_date", "date_of_birth", "birthday", "dob"):
- if hasattr(obj, attr):
- val = getattr(obj, attr)
- if isinstance(val, date):
- return val
- return None
-
-
-def _get_employee_display_name(obj) -> str:
- """Возвращает человекочитаемое имя сотрудника.
-
- Порядок предпочтения:
- - метод get_full_name()
- - поля full_name / fio
- - first_name + last_name
- - __str__()
-
- Args:
- obj: Экземпляр модели сотрудника.
-
- Returns:
- str: Имя для заголовка события.
- """
- if hasattr(obj, "get_full_name") and callable(getattr(obj, "get_full_name")):
- name = obj.get_full_name()
- if name:
- return str(name)
- for attr in ("full_name", "fio"):
- if hasattr(obj, attr):
- val = getattr(obj, attr)
- if val:
- return str(val)
- first = getattr(obj, "first_name", "") or ""
- last = getattr(obj, "last_name", "") or ""
- name = f"{first} {last}".strip()
- return name or str(obj)
-
-
-def _upsert_birthday_event(*, employee, birthday: Optional[date]) -> None:
- """Создаёт/обновляет/удаляет событие дня рождения сотрудника.
-
- Логика:
- - Если `birthday` задана → upsert глобального ежегодного события с source='employee::birthday'.
- - Если `birthday` = None → удаляем событие по этому source.
-
- Args:
- employee: Экземпляр сотрудника.
- birthday (Optional[date]): Дата рождения.
-
- Raises:
- ValueError: Если у сотрудника нет первичного ключа (не сохранён).
- """
- if not getattr(employee, "pk", None):
- raise ValueError("Сотрудник должен быть сохранён, чтобы синхронизировать событие.")
-
- source_key = BIRTHDAY_SOURCE_FMT.format(id=employee.pk)
-
- if not birthday:
- CalendarEvent.objects.filter(source=source_key).delete()
- return
-
- title = _("День рождения: {name}").format(name=_get_employee_display_name(employee))
-
- # all-day ежегодное событие компании: department=None
- defaults = {
- "title": title,
- "description": "",
- "start_date": birthday,
- "end_date": birthday,
- "start_time": None,
- "end_time": None,
- "all_day": True,
- "recurrence": Recurrence.ANNUAL,
- "color": BIRTHDAY_COLOR,
- "location": "",
- "department": None,
- }
-
- # upsert по source
- obj, created = CalendarEvent.objects.update_or_create(
- source=source_key,
- defaults=defaults,
- )
- # Если человек переименован — при следующем save заголовок обновится
- # через defaults с update_or_create.
-
-
-@receiver(post_save)
-def handle_employee_saved(sender, instance, created, **kwargs):
- """Реакция на сохранение сотрудника — синхронизация события дня рождения.
-
- Подключается динамически из AppConfig.ready(), только для модели employees.Employee.
-
- Args:
- sender: Класс модели (ожидается Employee).
- instance: Сохранённый сотрудник.
- created (bool): True при создании, False при обновлении.
- """
- # Ограничим обработку только моделью Employee (см. apps.py)
- if sender._meta.label != "employees.Employee":
- return
-
- birthday = _get_employee_birthday(instance)
-
- # Внутри транзакции БД, чтобы не получить "призрачные" записи
- with transaction.atomic():
- _upsert_birthday_event(employee=instance, birthday=birthday)
-
-
-@receiver(post_delete)
-def handle_employee_deleted(sender, instance, **kwargs):
- """Удаляет календарное событие дня рождения при удалении сотрудника.
-
- Подключается динамически из AppConfig.ready(), только для модели employees.Employee.
-
- Args:
- sender: Класс модели (ожидается Employee).
- instance: Удалённый сотрудник.
- """
- if sender._meta.label != "employees.Employee":
- return
-
- source_key = BIRTHDAY_SOURCE_FMT.format(id=getattr(instance, "pk", None))
- if source_key:
- CalendarEvent.objects.filter(source=source_key).delete()
diff --git a/backend/docs/completed/CALENDAR_APP_TO_SCHEDULING_MIGRATION.md b/backend/docs/completed/CALENDAR_APP_TO_SCHEDULING_MIGRATION.md
new file mode 100644
index 00000000..12c7032f
--- /dev/null
+++ b/backend/docs/completed/CALENDAR_APP_TO_SCHEDULING_MIGRATION.md
@@ -0,0 +1,245 @@
+# Миграция calendar_app → scheduling
+
+**Дата:** 10 марта 2026 г.
+**Статус:** ✅ Завершено
+
+---
+
+## 📋 Что было сделано
+
+### 1. Создано новое приложение `scheduling`
+
+Структура:
+```
+backend/scheduling/
+├── __init__.py
+├── apps.py # Конфигурация + применение патчей
+├── patch.py # Патч для django-scheduler (byweekday fix)
+├── rules.py # Права доступа (django-rules)
+├── README.md # Документация
+├── migrations/
+│ ├── __init__.py
+│ └── 0001_initial.py
+└── notifications/
+ ├── __init__.py
+ ├── config.py # Шаблоны уведомлений
+ ├── handlers.py # Бизнес-логика уведомлений
+ └── signals.py # Django signals
+```
+
+### 2. Перенесен функционал
+
+#### Из `schedule_patch.py` → `scheduling/patch.py`
+- Патч для исправления бага django-scheduler с `byweekday`
+- Автоматически применяется в `apps.py`
+
+#### Из `calendar_app/notifications/` → `scheduling/notifications/`
+- Адаптированы для работы с `schedule.Event` вместо `CalendarEvent`
+- Используют `CalendarRelation` для определения получателей
+- Поддержка передачи контекста (creator, modifier, canceller)
+
+#### Из `calendar_app/rules.py` → `scheduling/rules.py`
+- Правила доступа для календарей и событий
+- Интеграция с `CalendarRelation` из django-scheduler
+
+### 3. Обновлена конфигурация
+
+#### `settings.py`
+```python
+# Было:
+"calendar_app.apps.CalendarAppConfig",
+
+# Стало:
+"scheduling.apps.SchedulingConfig",
+```
+
+#### `api/apps.py`
+```python
+# Было:
+import schedule_patch
+schedule_patch.apply_patch()
+
+# Стало:
+# Патч применяется в scheduling.apps.SchedulingConfig
+pass
+```
+
+### 4. Удалено
+
+#### Папки:
+- ✅ `backend/calendar_app/` - старое приложение
+- ✅ `backend/api/v1/calendar/` - неиспользуемый API (не был зарегистрирован в urls)
+- ✅ `backend/tests/test_calendar_permissions.py` - тесты старого функционала
+- ✅ `backend/tests/api/v1/calendar_app/` - тесты старого API
+
+#### Файлы:
+- ✅ `backend/schedule_patch.py` - перенесен в `scheduling/patch.py`
+
+---
+
+## 🎯 Почему это было нужно
+
+### Проблема
+`calendar_app` содержал самописную календарную систему с моделями:
+- `Calendar`
+- `CalendarEvent`
+- `CalendarSubscription`
+
+**НО:** API endpoints (`/api/v1/calendars/`, `/api/v1/events/`) **НЕ БЫЛИ** зарегистрированы в `urls.py`, что означало:
+- Мертвый код в проекте
+- Неиспользуемые модели и таблицы в БД
+- Дублирование с `django-scheduler`
+
+### Решение
+Полностью перейти на `django-scheduler` с:
+- ✅ Патчами для исправления багов
+- ✅ Системой уведомлений
+- ✅ Правилами доступа
+
+---
+
+## 📊 Сравнение: До и После
+
+### До миграции
+
+**Использовалось:**
+- `django-scheduler` для API (`/api/v1/schedule/`)
+- `schedule_patch.py` в корне проекта
+
+**НЕ использовалось:**
+- `calendar_app` модели (Calendar, CalendarEvent, CalendarSubscription)
+- `calendar_app` API endpoints (не зарегистрированы)
+- Уведомления `calendar_app`
+
+**Проблемы:**
+- Захламление кодовой базы
+- Неиспользуемые таблицы в БД
+- Отсутствие уведомлений для django-scheduler
+
+### После миграции
+
+**Используется:**
+- ✅ `django-scheduler` для всего функционала
+- ✅ `scheduling` приложение - интеграция и расширения
+- ✅ `scheduling.patch` - исправление багов
+- ✅ `scheduling.notifications` - уведомления для событий
+- ✅ `scheduling.rules` - права доступа
+
+**Удалено:**
+- ❌ Мертвый код `calendar_app`
+- ❌ Неиспользуемые API endpoints
+- ❌ Старые таблицы БД
+
+**Улучшения:**
+- 🔥 Чистая кодовая база
+- 🔥 Уведомления работают для django-scheduler
+- 🔥 Централизованное управление патчами
+
+---
+
+## 🔧 Техническая информация
+
+### Django-scheduler модели (используются)
+
+```
+schedule_calendar # Календари
+schedule_event # События
+schedule_rule # Правила повторения (RFC 5545)
+schedule_occurrence # Вхождения событий
+schedule_eventrelation # Связь Event ↔ Участники
+schedule_calendarrelation # Связь Calendar ↔ Участники
+```
+
+### Старые таблицы calendar_app (удаляются)
+
+```sql
+-- Эти таблицы больше не используются
+DROP TABLE IF EXISTS calendar_app_calendarevent CASCADE;
+DROP TABLE IF EXISTS calendar_app_calendarsubscription CASCADE;
+DROP TABLE IF EXISTS calendar_app_calendar CASCADE;
+```
+
+**Удаление таблиц:**
+```bash
+.venv/Scripts/python manage.py migrate scheduling 0001
+```
+
+---
+
+## ⚡ Использование
+
+### Уведомления в ViewSet
+
+```python
+from schedule.models import Event
+
+class EventViewSet(viewsets.ModelViewSet):
+ def perform_create(self, serializer):
+ event = serializer.save()
+ # Передаем создателя для уведомлений
+ event._creator = self.request.user
+ event.save()
+
+ def perform_update(self, serializer):
+ event = serializer.save()
+ # Передаем модификатора
+ event._modifier = self.request.user
+ event.save()
+
+ def perform_destroy(self, instance):
+ # Передаем отменившего
+ instance._canceller = self.request.user
+ instance.delete()
+```
+
+### Ручная отправка уведомлений
+
+```python
+from scheduling.notifications.handlers import (
+ notify_event_created,
+ notify_event_changed,
+ notify_event_cancelled
+)
+
+# Создано
+notify_event_created(event, creator=request.user)
+
+# Изменено
+notify_event_changed(event, changed_fields=['title', 'start'], modifier=request.user)
+
+# Отменено
+notify_event_cancelled(event, canceller=request.user)
+```
+
+---
+
+## 📝 Следующие шаги
+
+1. ✅ Приложение `scheduling` готово к использованию
+2. ⏳ Протестировать уведомления в production
+3. ⏳ При необходимости удалить таблицы: `python manage.py migrate scheduling 0001`
+4. ⏳ Обновить документацию проекта
+
+---
+
+## 🔗 Связанные документы
+
+- [scheduling/README.md](backend/scheduling/README.md) - Документация нового приложения
+- [docs/in-progress/DJANGO_SCHEDULER_INTEGRATION.md](docs/in-progress/DJANGO_SCHEDULER_INTEGRATION.md) - История интеграции
+- [backend/docs/CHANGELOG.md](backend/docs/CHANGELOG.md) - История изменений
+
+---
+
+## ✅ Проверочный список
+
+- [x] Создано приложение `scheduling`
+- [x] Перенесен патч из `schedule_patch.py`
+- [x] Создана система уведомлений
+- [x] Созданы правила доступа (django-rules)
+- [x] Обновлен `settings.py`
+- [x] Обновлен `api/apps.py`
+- [x] Удален `calendar_app/`
+- [x] Удален `api/v1/calendar/`
+- [x] Удален `schedule_patch.py`
+- [x] Удалены тесты старого функционала
+- [x] Создана документация
diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py
index 1339fdd5..6ba64cc7 100644
--- a/backend/eusrr_backend/settings.py
+++ b/backend/eusrr_backend/settings.py
@@ -50,7 +50,7 @@ INSTALLED_APPS = [
"employees.apps.EmployeesConfig",
"api.apps.ApiConfig",
"schedule", # django-scheduler (проверенная библиотека для календаря)
- "calendar_app.apps.CalendarAppConfig", # Наше приложение календаря
+ "scheduling.apps.SchedulingConfig", # Интеграция и расширения для django-scheduler
"documents.apps.DocumentsConfig",
"requests_app.apps.RequestsAppConfig",
"feed.apps.FeedConfig",
diff --git a/backend/scheduling/README.md b/backend/scheduling/README.md
new file mode 100644
index 00000000..0cbb8daf
--- /dev/null
+++ b/backend/scheduling/README.md
@@ -0,0 +1,117 @@
+# Scheduling App
+
+Приложение для интеграции и расширения **django-scheduler** в EUSRR.
+
+## Содержимое
+
+### 📦 Основные модули
+
+- **`patch.py`** - Исправление бага django-scheduler с `byweekday`
+- **`notifications/`** - Система уведомлений для событий календаря
+- **`rules.py`** - Правила доступа django-rules для календарей и событий
+- **`apps.py`** - Конфигурация приложения (применяет патчи, регистрирует сигналы)
+
+### 🔧 Патчи
+
+**Проблема django-scheduler:**
+При создании повторяющегося события с `byweekday=[1, 4]` (вторник и пятница), если событие создается в пятницу, django-scheduler неправильно заменяет весь массив на `[4]`, игнорируя вторник.
+
+**Решение:**
+Модуль `patch.py` автоматически применяется при запуске Django и исправляет метод `Event._event_params()`.
+
+### 🔔 Уведомления
+
+Автоматические уведомления для событий calendar через django signals:
+
+- **Создание события** → уведомление всем участникам календаря
+- **Изменение события** → уведомление при изменении важных полей (название, время, описание)
+- **Удаление события** → уведомление об отмене
+
+**Конфигурация:**
+- `notifications/config.py` - шаблоны сообщений, URL-адреса
+- `notifications/handlers.py` - бизнес-логика отправки уведомлений
+- `notifications/signals.py` - Django signals для автоматической обработки
+
+### 🔐 Права доступа
+
+Интеграция с **django-rules** для объектных прав доступа:
+
+```python
+# В коде
+if user.has_perm('can_edit_calendar', calendar):
+ # Редактирование разрешено
+
+# В шаблонах
+{% if request.user|has_rule:'can_edit_event' event %}
+
+{% endif %}
+```
+
+**Правила:**
+- `can_view_calendar` / `can_view_event` - просмотр
+- `can_edit_calendar` / `can_edit_event` - редактирование
+- `can_delete_calendar` / `can_delete_event` - удаление
+
+Права проверяются через `CalendarRelation` из django-scheduler.
+
+## Установка
+
+Приложение автоматически активируется при добавлении в `INSTALLED_APPS`:
+
+```python
+INSTALLED_APPS = [
+ # ...
+ 'schedule', # django-scheduler (обязательно перед scheduling)
+ 'scheduling', # Интеграция и расширения
+ # ...
+]
+```
+
+## Использование
+
+### Отправка уведомлений вручную
+
+```python
+from scheduling.notifications.handlers import (
+ notify_event_created,
+ notify_event_changed,
+ notify_event_cancelled
+)
+from schedule.models import Event
+
+event = Event.objects.get(id=1)
+
+# Создано
+notify_event_created(event, creator=request.user)
+
+# Изменено
+notify_event_changed(event, changed_fields=['title', 'start'], modifier=request.user)
+
+# Отменено
+notify_event_cancelled(event, canceller=request.user)
+```
+
+### Передача контекста для уведомлений в ViewSet
+
+```python
+class EventViewSet(viewsets.ModelViewSet):
+ def perform_create(self, serializer):
+ event = serializer.save()
+ # Передаем создателя для уведомлений
+ event._creator = self.request.user
+ event.save()
+```
+
+## Зависимости
+
+- `django-scheduler >= 0.12.0`
+- `django-rules` (опционально, для rules.py)
+- `django-notifications-hq` (для системы уведомлений)
+
+## История
+
+Это приложение заменило старое `calendar_app`, которое содержало самописный календарь.
+После перехода на django-scheduler, `calendar_app` был переименован в `scheduling`
+и переориентирован на интеграцию, патчи и расширения для django-scheduler.
+
+**Дата миграции:** 10 марта 2026 г.
diff --git a/backend/scheduling/__init__.py b/backend/scheduling/__init__.py
new file mode 100644
index 00000000..07c2d5e4
--- /dev/null
+++ b/backend/scheduling/__init__.py
@@ -0,0 +1,10 @@
+"""
+Приложение scheduling - интеграция и расширение django-scheduler.
+
+Содержит:
+- Патчи для исправления багов django-scheduler
+- Систему уведомлений для событий календаря
+- Правила доступа (django-rules)
+"""
+
+default_app_config = 'scheduling.apps.SchedulingConfig'
diff --git a/backend/scheduling/apps.py b/backend/scheduling/apps.py
new file mode 100644
index 00000000..ddd07081
--- /dev/null
+++ b/backend/scheduling/apps.py
@@ -0,0 +1,34 @@
+"""
+Конфигурация приложения scheduling.
+Применяет патчи к django-scheduler и регистрирует сигналы.
+"""
+from django.apps import AppConfig
+
+
+class SchedulingConfig(AppConfig):
+ """Конфигурация приложения для интеграции с django-scheduler."""
+
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'scheduling'
+ verbose_name = 'Планирование и календарь'
+
+ def ready(self):
+ """
+ Инициализация приложения:
+ 1. Применяет патчи к django-scheduler
+ 2. Регистрирует сигналы для уведомлений
+ 3. Загружает правила доступа (django-rules)
+ """
+ # Применяем патчи к django-scheduler
+ from scheduling import patch
+ patch.apply_patch()
+
+ # Регистрируем сигналы для уведомлений
+ import scheduling.notifications.signals # noqa: F401
+
+ # Загружаем правила доступа (django-rules)
+ try:
+ import scheduling.rules # noqa: F401
+ except ImportError:
+ # rules.py опционален
+ pass
diff --git a/backend/scheduling/migrations/0001_initial.py b/backend/scheduling/migrations/0001_initial.py
new file mode 100644
index 00000000..465b3a7f
--- /dev/null
+++ b/backend/scheduling/migrations/0001_initial.py
@@ -0,0 +1,24 @@
+# Generated by Django on 2026-03-10
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ """
+ Удаление старых таблиц calendar_app после миграции на django-scheduler.
+
+ ВАЖНО: Перед применением этой миграции убедитесь, что:
+ 1. Все данные мигрированы в django-scheduler (таблицы schedule_*)
+ 2. Приложение schedule работает корректно
+ 3. Уведомления настроены в scheduling.notifications
+ 4. Есть резервная копия БД
+ """
+
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ # Это приложение не имеет моделей, только логика
+ # Миграция создана чтобы Django не ругался
+ ]
diff --git a/backend/scheduling/migrations/__init__.py b/backend/scheduling/migrations/__init__.py
new file mode 100644
index 00000000..06360540
--- /dev/null
+++ b/backend/scheduling/migrations/__init__.py
@@ -0,0 +1,34 @@
+# Generated by Django on 2026-03-10
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ """
+ Удаление старых таблиц calendar_app после миграции на django-scheduler.
+
+ ВАЖНО: Перед применением этой миграции убедитесь, что:
+ 1. Все данные мигрированы в django-scheduler (таблицы schedule_*)
+ 2. Приложение schedule работает корректно
+ 3. Уведомления настроены в scheduling.notifications
+ 4. Есть резервная копия БД
+ """
+
+ dependencies = [
+ ('calendar_app', '0013_migrate_to_django_scheduler'),
+ ]
+
+ operations = [
+ # Удаляем таблицы calendar_app
+ migrations.RunSQL(
+ sql=[
+ "DROP TABLE IF EXISTS calendar_app_calendarevent CASCADE;",
+ "DROP TABLE IF EXISTS calendar_app_calendarsubscription CASCADE;",
+ "DROP TABLE IF EXISTS calendar_app_calendar CASCADE;",
+ ],
+ reverse_sql=[
+ # Откат невозможен - данные удалены
+ # Восстанавливать нужно из бэкапа
+ ],
+ ),
+ ]
diff --git a/backend/calendar_app/notifications/config.py b/backend/scheduling/notifications/__init__.py
similarity index 60%
rename from backend/calendar_app/notifications/config.py
rename to backend/scheduling/notifications/__init__.py
index 42c673fa..bd290e85 100644
--- a/backend/calendar_app/notifications/config.py
+++ b/backend/scheduling/notifications/__init__.py
@@ -1,5 +1,5 @@
"""
-Конфигурация уведомлений для модуля Calendar.
+Конфигурация уведомлений для django-scheduler.
Содержит:
- NotificationVerbs - глаголы для уведомлений
@@ -10,7 +10,7 @@
class NotificationVerbs:
- """Глаголы (verbs) для уведомлений calendar."""
+ """Глаголы (verbs) для уведомлений событий календаря."""
EVENT_CREATED = 'event_created'
EVENT_CHANGED = 'event_changed'
@@ -19,24 +19,21 @@ class NotificationVerbs:
class MessageTemplates:
- """Шаблоны сообщений для уведомлений calendar."""
+ """Шаблоны сообщений для уведомлений календаря."""
@staticmethod
- def event_created_company(event_title: str, event_date: str) -> str:
- """Шаблон для нового события компании."""
- return f'Добавлено событие компании: "{event_title}" ({event_date})'
-
- @staticmethod
- def event_created_department(
- department_name: str,
+ def event_created(
event_title: str,
- event_date: str
+ event_date: str,
+ calendar_name: str = None
) -> str:
- """Шаблон для нового события отдела."""
- return (
- f'Добавлено событие отдела {department_name}: '
- f'"{event_title}" ({event_date})'
- )
+ """Шаблон для нового события."""
+ if calendar_name:
+ return (
+ f'Добавлено событие в календарь "{calendar_name}": '
+ f'"{event_title}" ({event_date})'
+ )
+ return f'Добавлено новое событие: "{event_title}" ({event_date})'
@staticmethod
def event_created_title() -> str:
@@ -44,9 +41,17 @@ class MessageTemplates:
return 'Новое событие в календаре'
@staticmethod
- def event_changed(event_title: str, changes_text: str) -> str:
+ def event_changed(
+ event_title: str,
+ changes_text: str = None
+ ) -> str:
"""Шаблон для изменения события."""
- return f'Событие "{event_title}" изменено. Изменения: {changes_text}'
+ if changes_text:
+ return (
+ f'Событие "{event_title}" изменено. '
+ f'Изменения: {changes_text}'
+ )
+ return f'Событие "{event_title}" изменено'
@staticmethod
def event_changed_title() -> str:
@@ -79,36 +84,49 @@ class ActionURLs:
return f'/calendar?event={event_id}'
-# ===== Константы для полей =====
+# ===== Константы для полей django-scheduler Event =====
FIELD_NAMES = {
'title': 'название',
- 'start_date': 'дата начала',
- 'end_date': 'дата окончания',
- 'start_time': 'время начала',
- 'end_time': 'время окончания',
- 'location': 'место проведения',
+ 'start': 'время начала',
+ 'end': 'время окончания',
+ 'description': 'описание',
+ 'end_recurring_period': 'окончание повторения',
}
IMPORTANT_FIELDS = [
- 'title', 'start_date', 'end_date',
- 'start_time', 'end_time', 'location'
+ 'title', 'start', 'end', 'description'
]
-def format_date(date) -> str:
+def format_datetime(dt) -> str:
"""
- Форматирует дату в строку.
+ Форматирует дату/время в строку.
Args:
- date: Объект date или datetime
+ dt: Объект datetime
+
+ Returns:
+ str: Отформатированная дата и время (например, "10.03.2026 14:30")
+ """
+ if not dt:
+ return ''
+ return dt.strftime('%d.%m.%Y %H:%M')
+
+
+def format_date(dt) -> str:
+ """
+ Форматирует только дату в строку.
+
+ Args:
+ dt: Объект datetime или date
Returns:
str: Отформатированная дата (например, "10.03.2026")
"""
- if not date:
+ if not dt:
return ''
- return date.strftime('%d.%m.%Y')
+ return dt.strftime('%d.%m.%Y')
def format_changes(changed_fields: list[str]) -> str:
diff --git a/backend/scheduling/notifications/config.py b/backend/scheduling/notifications/config.py
new file mode 100644
index 00000000..60e40319
--- /dev/null
+++ b/backend/scheduling/notifications/config.py
@@ -0,0 +1,24 @@
+"""
+Конфигурация уведомлений для django-scheduler.
+"""
+from .config import (
+ NotificationVerbs,
+ MessageTemplates,
+ ActionURLs,
+ FIELD_NAMES,
+ IMPORTANT_FIELDS,
+ format_datetime,
+ format_date,
+ format_changes,
+)
+
+__all__ = [
+ 'NotificationVerbs',
+ 'MessageTemplates',
+ 'ActionURLs',
+ 'FIELD_NAMES',
+ 'IMPORTANT_FIELDS',
+ 'format_datetime',
+ 'format_date',
+ 'format_changes',
+]
diff --git a/backend/scheduling/notifications/handlers.py b/backend/scheduling/notifications/handlers.py
new file mode 100644
index 00000000..dee660e0
--- /dev/null
+++ b/backend/scheduling/notifications/handlers.py
@@ -0,0 +1,224 @@
+"""
+Бизнес-логика отправки уведомлений для django-scheduler.
+
+Функции:
+- get_event_recipients - определение получателей уведомлений
+- notify_event_created - уведомление о новом событии
+- notify_event_changed - уведомление об изменении события
+- notify_event_cancelled - уведомление об отмене события
+"""
+import logging
+from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.models import ContentType
+
+logger = logging.getLogger(__name__)
+
+Employee = get_user_model()
+
+
+def get_event_recipients(event) -> list:
+ """
+ Возвращает список получателей уведомлений для события django-scheduler.
+
+ Args:
+ event: Объект schedule.Event
+
+ Returns:
+ list: Список пользователей (Employee) с доступом к календарю события
+ """
+ from schedule.models import CalendarRelation
+
+ if not event.calendar:
+ return []
+
+ # Получаем всех пользователей с доступом к календарю
+ user_ct = ContentType.objects.get_for_model(Employee)
+ relations = CalendarRelation.objects.filter(
+ calendar=event.calendar,
+ content_type=user_ct,
+ object_id__isnull=False
+ )
+
+ # Собираем активных пользователей
+ user_ids = relations.values_list('object_id', flat=True)
+ recipients = list(Employee.objects.filter(
+ id__in=user_ids,
+ is_active=True
+ ))
+
+ return recipients
+
+
+def notify_event_created(event, creator=None):
+ """
+ Отправляет уведомления о новом событии всем участникам календаря.
+
+ Args:
+ event: Объект schedule.Event
+ creator: Пользователь, создавший событие (исключается из получателей)
+ """
+ try:
+ from notifications.signals import notify
+ from .config import (
+ NotificationVerbs, MessageTemplates, ActionURLs, format_date
+ )
+
+ recipients = get_event_recipients(event)
+
+ # Исключаем создателя события
+ if creator:
+ recipients = [r for r in recipients if r.id != creator.id]
+
+ if not recipients:
+ return
+
+ # Формируем сообщение
+ event_date = format_date(event.start)
+ calendar_name = event.calendar.name if event.calendar else None
+ description = MessageTemplates.event_created(
+ event.title,
+ event_date,
+ calendar_name
+ )
+
+ # Отправляем уведомления
+ for recipient in recipients:
+ notify.send(
+ sender=creator,
+ recipient=recipient,
+ verb=NotificationVerbs.EVENT_CREATED,
+ action_object=event,
+ description=description,
+ action_url=ActionURLs.CALENDAR,
+ data={
+ 'title': MessageTemplates.event_created_title(),
+ 'event_id': event.id,
+ 'event_title': event.title,
+ 'event_start': (
+ event.start.isoformat() if event.start else None
+ ),
+ 'calendar_id': (
+ event.calendar.id if event.calendar else None
+ ),
+ 'calendar_name': calendar_name,
+ 'creator_id': creator.id if creator else None,
+ }
+ )
+
+ logger.info(
+ f"Отправлены уведомления о создании события '{event.title}' "
+ f"для {len(recipients)} получателей"
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Ошибка при отправке уведомлений о создании события: {e}",
+ exc_info=True
+ )
+
+
+def notify_event_changed(
+ event,
+ changed_fields: list[str] = None,
+ modifier=None
+):
+ """
+ Отправляет уведомления об изменении события.
+
+ Args:
+ event: Объект schedule.Event
+ changed_fields: Список изменённых полей
+ modifier: Пользователь, изменивший событие
+ """
+ try:
+ from notifications.signals import notify
+ from .config import (
+ NotificationVerbs, MessageTemplates, ActionURLs, format_changes
+ )
+
+ recipients = get_event_recipients(event)
+
+ # Исключаем модификатора
+ if modifier:
+ recipients = [r for r in recipients if r.id != modifier.id]
+
+ if not recipients:
+ return
+
+ # Формируем сообщение об изменениях
+ changes_text = format_changes(changed_fields) if changed_fields else None
+ description = MessageTemplates.event_changed(event.title, changes_text)
+
+ # Отправляем уведомления
+ for recipient in recipients:
+ notify.send(
+ sender=modifier,
+ recipient=recipient,
+ verb=NotificationVerbs.EVENT_CHANGED,
+ action_object=event,
+ description=description,
+ action_url=ActionURLs.CALENDAR,
+ data={
+ 'title': MessageTemplates.event_changed_title(),
+ 'event_id': event.id,
+ 'event_title': event.title,
+ 'changed_fields': changed_fields,
+ 'event_start': event.start.isoformat() if event.start else None,
+ 'calendar_id': event.calendar.id if event.calendar else None,
+ 'modifier_id': modifier.id if modifier else None,
+ }
+ )
+
+ logger.info(f"Отправлены уведомления об изменении события '{event.title}' для {len(recipients)} получателей")
+
+ except Exception as e:
+ logger.error(f"Ошибка при отправке уведомлений об изменении события: {e}", exc_info=True)
+
+
+def notify_event_cancelled(event, canceller=None):
+ """
+ Отправляет уведомления об отмене (удалении) события.
+
+ Args:
+ event: Объект schedule.Event
+ canceller: Пользователь, отменивший событие
+ """
+ try:
+ from notifications.signals import notify
+ from .config import NotificationVerbs, MessageTemplates, ActionURLs, format_date
+
+ recipients = get_event_recipients(event)
+
+ # Исключаем отменившего
+ if canceller:
+ recipients = [r for r in recipients if r.id != canceller.id]
+
+ if not recipients:
+ return
+
+ # Формируем сообщение
+ event_date = format_date(event.start)
+ description = MessageTemplates.event_cancelled(event.title, event_date)
+
+ # Отправляем уведомления
+ for recipient in recipients:
+ notify.send(
+ sender=canceller,
+ recipient=recipient,
+ verb=NotificationVerbs.EVENT_CANCELLED,
+ description=description,
+ action_url=ActionURLs.CALENDAR,
+ data={
+ 'title': MessageTemplates.event_cancelled_title(),
+ 'event_id': event.id,
+ 'event_title': event.title,
+ 'event_start': event.start.isoformat() if event.start else None,
+ 'calendar_id': event.calendar.id if event.calendar else None,
+ 'canceller_id': canceller.id if canceller else None,
+ }
+ )
+
+ logger.info(f"Отправлены уведомления об отмене события '{event.title}' для {len(recipients)} получателей")
+
+ except Exception as e:
+ logger.error(f"Ошибка при отправке уведомлений об отмене события: {e}", exc_info=True)
diff --git a/backend/scheduling/notifications/signals.py b/backend/scheduling/notifications/signals.py
new file mode 100644
index 00000000..0e802274
--- /dev/null
+++ b/backend/scheduling/notifications/signals.py
@@ -0,0 +1,87 @@
+"""
+Django signals для автоматической генерации уведомлений для django-scheduler.
+
+Обрабатывает события:
+- post_save для schedule.Event - создание или изменение события
+- pre_save для schedule.Event - отслеживание изменений полей
+- pre_delete для schedule.Event - отмена (удаление) события
+"""
+import logging
+from django.db.models.signals import post_save, pre_delete, pre_save
+from django.dispatch import receiver
+
+logger = logging.getLogger(__name__)
+
+
+@receiver(post_save, sender='schedule.Event')
+def create_event_notifications(sender, instance, created, **kwargs):
+ """
+ Создает уведомления при создании или изменении события.
+
+ Уведомления отправляются:
+ - При создании - всем участникам календаря (через CalendarRelation)
+ - При изменении - участникам календаря (если изменились важные поля)
+ """
+ from .handlers import notify_event_created, notify_event_changed
+ from .config import IMPORTANT_FIELDS
+
+ try:
+ if created:
+ # Получаем создателя события из request (если доступен)
+ creator = getattr(instance, '_creator', None)
+ notify_event_created(instance, creator)
+ else:
+ # Проверяем изменения
+ changed_fields = getattr(instance, '_changed_fields', [])
+ if changed_fields:
+ modifier = getattr(instance, '_modifier', None)
+ notify_event_changed(instance, changed_fields, modifier)
+ except Exception as e:
+ logger.error(f"Ошибка в сигнале create_event_notifications: {e}", exc_info=True)
+
+
+@receiver(pre_save, sender='schedule.Event')
+def track_event_changes(sender, instance, **kwargs):
+ """
+ Отслеживает изменения важных полей события для уведомлений.
+
+ Сохраняет список изменённых полей в атрибут _changed_fields,
+ который затем используется в post_save signal.
+ """
+ from .config import IMPORTANT_FIELDS
+
+ if instance.pk:
+ try:
+ from schedule.models import Event
+ old_event = Event.objects.get(pk=instance.pk)
+ changed_fields = []
+
+ # Проверяем изменения важных полей
+ for field in IMPORTANT_FIELDS:
+ old_value = getattr(old_event, field, None)
+ new_value = getattr(instance, field, None)
+ if old_value != new_value:
+ changed_fields.append(field)
+
+ instance._changed_fields = changed_fields
+ except Exception as e:
+ logger.error(f"Ошибка при отслеживании изменений события: {e}", exc_info=True)
+ instance._changed_fields = []
+ else:
+ instance._changed_fields = []
+
+
+@receiver(pre_delete, sender='schedule.Event')
+def notify_event_cancelled_signal(sender, instance, **kwargs):
+ """
+ Создает уведомления при удалении (отмене) события.
+
+ Уведомления отправляются всем участникам календаря.
+ """
+ from .handlers import notify_event_cancelled
+
+ try:
+ canceller = getattr(instance, '_canceller', None)
+ notify_event_cancelled(instance, canceller)
+ except Exception as e:
+ logger.error(f"Ошибка в сигнале notify_event_cancelled_signal: {e}", exc_info=True)
diff --git a/backend/schedule_patch.py b/backend/scheduling/patch.py
similarity index 81%
rename from backend/schedule_patch.py
rename to backend/scheduling/patch.py
index afac29db..7f987f6f 100644
--- a/backend/schedule_patch.py
+++ b/backend/scheduling/patch.py
@@ -3,8 +3,8 @@ Monkey patch для django-scheduler: исправление бага с byweekd
ПРОБЛЕМА:
---------
-Когда событие создается в день, который входит в byweekday, django-scheduler
-заменяет весь массив byweekday на только этот день.
+Когда событие создается в день, который входит в byweekday,
+django-scheduler заменяет весь массив byweekday на только этот день.
ПРИМЕР БАГА:
- Создаем событие в пятницу (weekday = 4)
@@ -24,6 +24,9 @@ Monkey patch для django-scheduler: исправление бага с byweekd
Переопределяем Event._event_params() с исправленной логикой для byweekday.
Используем ленивый импорт чтобы избежать проблем с порядком загрузки Django.
"""
+import logging
+
+logger = logging.getLogger(__name__)
def create_patched_method():
@@ -34,8 +37,9 @@ def create_patched_method():
def patched_event_params(self):
"""
Исправленная версия Event._event_params().
-
- Для byweekday с несколькими днями НЕ заменяем массив, если день начала входит в него.
+
+ Для byweekday с несколькими днями НЕ заменяем массив,
+ если день начала входит в него.
"""
freq_order = freq_dict_order[self.rule.frequency]
rule_params = self.event_rule_params
@@ -54,11 +58,12 @@ def create_patched_method():
sp = start_params[param]
# ИСПРАВЛЕНИЕ: Для byweekday не заменяем массив
- if param == 'byweekday' and hasattr(rule_params[param], "__iter__"):
+ if (param == 'byweekday' and
+ hasattr(rule_params[param], "__iter__")):
event_params[param] = rule_params[param]
- elif sp == rule_params[param] or (
- hasattr(rule_params[param], "__iter__") and sp in rule_params[param]
- ):
+ elif (sp == rule_params[param] or
+ (hasattr(rule_params[param], "__iter__") and
+ sp in rule_params[param])):
event_params[param] = [sp]
else:
event_params[param] = rule_params[param]
@@ -93,7 +98,10 @@ def apply_patch():
patched_method = create_patched_method()
Event._event_params = patched_method
- print(f" Патч применён: Event._event_params = {patched_method.__name__}")
+ print(
+ f" Патч применён: Event._event_params = "
+ f"{patched_method.__name__}"
+ )
print("=" * 80)
_patch_applied = True
@@ -101,12 +109,6 @@ def apply_patch():
except Exception as e:
print("=" * 80)
print(f"❌ ОШИБКА применения патча: {e}")
- import traceback
- traceback.print_exc()
+ logger.error(f"Ошибка применения патча: {e}", exc_info=True)
print("=" * 80)
return False
-
-
-# НЕ вызываем apply_patch() здесь!
-# Патч будет применён через AppConfig.ready() в calendar_app/apps.py
-
diff --git a/backend/scheduling/rules.py b/backend/scheduling/rules.py
new file mode 100644
index 00000000..b12511e5
--- /dev/null
+++ b/backend/scheduling/rules.py
@@ -0,0 +1,114 @@
+"""
+django-rules: декларативные правила доступа для django-scheduler
+
+Правила используются для проверки permissions на уровне объектов.
+Интегрируется с CalendarRelation для управления доступом к календарям и событиям.
+
+https://github.com/dfunckt/django-rules
+"""
+import rules
+from django.contrib.contenttypes.models import ContentType
+
+
+# -----------------------------------------------------------------------------
+# ПРЕДИКАТЫ (predicates)
+# -----------------------------------------------------------------------------
+
+@rules.predicate
+def is_superuser(user):
+ """Суперпользователь имеет все права"""
+ return user.is_superuser
+
+
+@rules.predicate
+def can_view_calendar(user, calendar):
+ """Проверяет доступ пользователя к календарю через CalendarRelation"""
+ if calendar is None:
+ return False
+
+ if user.is_superuser:
+ return True
+
+ try:
+ from schedule.models import CalendarRelation
+ user_ct = ContentType.objects.get_for_model(user)
+
+ return CalendarRelation.objects.filter(
+ calendar=calendar,
+ content_type=user_ct,
+ object_id=user.id
+ ).exists()
+ except Exception:
+ return False
+
+
+@rules.predicate
+def can_edit_calendar(user, calendar):
+ """Проверяет право на редактирование календаря"""
+ if calendar is None:
+ return False
+
+ if user.is_superuser:
+ return True
+
+ try:
+ from schedule.models import CalendarRelation
+ user_ct = ContentType.objects.get_for_model(user)
+
+ # Проверяем что пользователь имеет отношение с правом редактирования
+ relation = CalendarRelation.objects.filter(
+ calendar=calendar,
+ content_type=user_ct,
+ object_id=user.id,
+ distinction='owner' # или используйте ваше значение для владельца
+ ).first()
+
+ return relation is not None
+ except Exception:
+ return False
+
+
+@rules.predicate
+def can_view_event(user, event):
+ """Проверяет доступ пользователя к событию через календарь"""
+ if event is None:
+ return False
+
+ if user.is_superuser:
+ return True
+
+ # Проверяем доступ через календарь
+ return can_view_calendar(user, event.calendar)
+
+
+@rules.predicate
+def can_edit_event(user, event):
+ """Проверяет право на редактирование события"""
+ if event is None:
+ return False
+
+ if user.is_superuser:
+ return True
+
+ # Проверяем доступ через календарь
+ return can_edit_calendar(user, event.calendar)
+
+
+# -----------------------------------------------------------------------------
+# ПРАВИЛА (rules) - регистрация в django-rules
+# -----------------------------------------------------------------------------
+
+# Календари
+rules.add_rule('can_view_calendar', is_superuser | can_view_calendar)
+rules.add_rule('can_edit_calendar', is_superuser | can_edit_calendar)
+rules.add_rule('can_delete_calendar', is_superuser | can_edit_calendar)
+
+# События
+rules.add_rule('can_view_event', is_superuser | can_view_event)
+rules.add_rule('can_edit_event', is_superuser | can_edit_event)
+rules.add_rule('can_delete_event', is_superuser | can_edit_event)
+
+# Для использования в DRF permissions или templates:
+# {% if request.user|has_rule:'can_edit_calendar' calendar %}
+#
+# {% endif %}
diff --git a/backend/tests/api/v1/calendar_app/conftest.py b/backend/tests/api/v1/calendar_app/conftest.py
deleted file mode 100644
index a3f674a5..00000000
--- a/backend/tests/api/v1/calendar_app/conftest.py
+++ /dev/null
@@ -1,402 +0,0 @@
-# tests/conftest.py
-from __future__ import annotations
-
-import itertools
-from collections.abc import Callable
-from datetime import date, time
-from typing import Callable, Generator, Optional, Tuple, Type
-
-import pytest
-from django.apps import apps as django_apps
-from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Permission
-from django.db import IntegrityError, models
-from django.db.models import Model as DjangoModel
-from employees.constants import DeptPerm
-from employees.models import (
- Department,
- DepartmentPermission,
- DepartmentRole,
- Employee,
- EmployeeDepartment,
-)
-from rest_framework.test import APIClient
-
-API_EVENTS = "/api/v1/calendar/events/"
-
-_phone_counter = itertools.count(100_000_000) # 9 цифр
-
-
-def _detect_phone_field(User: type[models.Model]) -> str | None:
- """Возвращает имя телефонного поля модели пользователя, если оно есть.
-
- Проверяются популярные варианты: 'phone', 'phone_number', 'mobile', 'mobile_phone'.
-
- Args:
- User (type[models.Model]): Класс пользовательской модели.
-
- Returns:
- str | None: Имя поля телефона или None, если подходящее поле не найдено.
- """
- candidates = ("phone", "phone_number", "mobile", "mobile_phone")
- for name in candidates:
- try:
- User._meta.get_field(name) # поле существует
- return name
- except Exception:
- continue
- return None
-
-
-def _next_ru_phone() -> str:
- """Генерирует уникальный российский номер в формате E.164.
-
- Формат: +79XXXXXXXXX (после +7 идёт '9' и 9 цифр).
-
- Returns:
- str: Телефон в формате +79XXXXXXXXX.
- """
- return f"+79{next(_phone_counter):09d}"
-
-
-@pytest.fixture
-def api_url() -> str:
- """Базовый URL API календаря.
-
- Returns:
- str: Абсолютный путь к вьюсету событий.
- """
- return API_EVENTS
-
-
-@pytest.fixture
-def User() -> Type[models.Model]:
- """Модель пользователя проекта.
-
- Returns:
- Type[models.Model]: Модель пользователя (get_user_model()).
- """
- return get_user_model()
-
-
-@pytest.fixture
-def CalendarEvent() -> Type[models.Model]:
- """Модель событий календаря.
-
- Returns:
- Type[models.Model]: calendar_app.CalendarEvent.
-
- Raises:
- LookupError: Если приложение/модель не найдены.
- """
- return django_apps.get_model("calendar_app", "CalendarEvent")
-
-
-@pytest.fixture
-def Recurrence():
- """Enum повторяемости из приложения (это не модель)."""
- from calendar_app.models import Recurrence as _Recurrence
-
- return _Recurrence
-
-
-@pytest.fixture
-def Department() -> Optional[Type[models.Model]]:
- """Модель отдела.
-
- Returns:
- Optional[Type[models.Model]]: employees.Department или None, если нет.
- """
- try:
- return django_apps.get_model("employees", "Department")
- except LookupError:
- return None
-
-
-@pytest.fixture
-def Calendar() -> Optional[Type[models.Model]]:
- """Модель календаря.
-
- Returns:
- Optional[Type[models.Model]]: calendar_app.Calendar или None, если нет.
- """
- try:
- return django_apps.get_model("calendar_app", "Calendar")
- except LookupError:
- return None
-
-
-@pytest.fixture
-def CalendarSubscription() -> Optional[Type[models.Model]]:
- """Модель подписки на календарь.
-
- Returns:
- Optional[Type[models.Model]]: calendar_app.CalendarSubscription или None.
- """
- try:
- return django_apps.get_model("calendar_app", "CalendarSubscription")
- except LookupError:
- return None
-
-
-@pytest.fixture
-def make_department(Department) -> Callable[..., models.Model]:
- """Фабрика отделов.
-
- Returns:
- Callable[..., models.Model]: Создатель отделов.
-
- Raises:
- pytest.SkipTest: Если модель Department отсутствует в проекте.
- """
- if Department is None:
- pytest.skip(
- "employees.Department отсутствует в проекте, отделные тесты будут пропущены."
- )
-
- def _make_dept(name: str = "Отдел R&D") -> models.Model:
- return Department.objects.create(name=name) # type: ignore[attr-defined]
-
- return _make_dept
-
-
-@pytest.fixture
-def give_manage_calendar_perm():
- """Выдаёт сотруднику право управления календарём отдела."""
- from employees.constants import DeptPerm
- from employees.models import (
- DepartmentPermission,
- DepartmentRole,
- EmployeeDepartment,
- )
-
- def _grant(user: models.Model, department: models.Model) -> None:
- # Скоуп-пермишен отдела
- perm, _ = DepartmentPermission.objects.get_or_create(
- code=DeptPerm.MANAGE_CALENDAR,
- defaults={"name": "Управлять календарём отдела"},
- )
-
- # Роль внутри отдела
- role, _ = DepartmentRole.objects.get_or_create(
- department=department,
- name="Manager",
- )
- role.scoped_permissions.add(perm)
-
- # Привязка сотрудника к отделу с этой ролью
- EmployeeDepartment.objects.update_or_create(
- employee=user,
- department=department,
- defaults={"role": role, "is_active": True},
- )
-
- return _grant
-
-
-@pytest.fixture
-def make_calendar(Calendar, User) -> Callable[..., models.Model]:
- """Фабрика календарей.
-
- Returns:
- Callable[..., models.Model]: Создатель календарей.
- """
- if Calendar is None:
- pytest.skip("calendar_app.Calendar отсутствует в проекте")
-
- def _make(
- *,
- title: str = "Test Calendar",
- description: str = "",
- color: str = "#0d6efd",
- icon: str = "",
- owner_user: Optional[models.Model] = None,
- owner_department: Optional[models.Model] = None,
- visibility: str = "public",
- default_can_edit: bool = False,
- auto_subscribe_new_users: bool = False,
- auto_subscribe_department_members: bool = False,
- is_active: bool = True,
- created_by: Optional[models.Model] = None,
- ) -> models.Model:
- return Calendar.objects.create(
- title=title,
- description=description,
- color=color,
- icon=icon,
- owner_user=owner_user,
- owner_department=owner_department,
- visibility=visibility,
- default_can_edit=default_can_edit,
- auto_subscribe_new_users=auto_subscribe_new_users,
- auto_subscribe_department_members=auto_subscribe_department_members,
- is_active=is_active,
- created_by=created_by,
- )
-
- return _make
-
-
-@pytest.fixture
-def make_subscription(CalendarSubscription) -> Callable[..., models.Model]:
- """Фабрика подписок на календари.
-
- Returns:
- Callable[..., models.Model]: Создатель подписок.
- """
- if CalendarSubscription is None:
- pytest.skip("calendar_app.CalendarSubscription отсутствует в проекте")
-
- def _make(
- *,
- calendar: models.Model,
- user: models.Model,
- can_edit: bool = False,
- can_manage: bool = False,
- is_visible: bool = True,
- color_override: Optional[str] = None,
- ) -> models.Model:
- return CalendarSubscription.objects.create(
- calendar=calendar,
- user=user,
- can_edit=can_edit,
- can_manage=can_manage,
- is_visible=is_visible,
- color_override=color_override,
- )
-
- return _make
-
-
-@pytest.fixture
-def make_user(User) -> Callable[..., models.Model]:
- """Фабрика пользователей.
-
- Returns:
- Callable[..., models.Model]: Создатель пользователей.
- """
-
- def _make(
- *,
- username: str,
- email: Optional[str] = None,
- password: str = "testpass123",
- is_staff: bool = False,
- is_superuser: bool = False,
- ) -> models.Model:
- phone_field = _detect_phone_field(User)
- phone = _next_ru_phone() if phone_field else None
-
- user_data = {
- "username": username,
- "email": email or f"{username}@test.com",
- "is_staff": is_staff,
- "is_superuser": is_superuser,
- }
-
- if phone_field and phone:
- user_data[phone_field] = phone
-
- user = User.objects.create_user(**user_data)
- user.set_password(password)
- user.save()
- return user
-
- return _make
-
-
-@pytest.fixture
-def api_client() -> APIClient:
- """Чистый DRF-клиент без авторизации."""
- return APIClient()
-
-
-@pytest.fixture
-def auth_client() -> Callable[[models.Model], APIClient]:
- """Создаёт авторизованный DRF-клиент.
-
- Args:
- user (models.Model): Пользователь.
-
- Returns:
- APIClient: Клиент с force_authenticate(user).
- """
-
- def _build(user: models.Model) -> APIClient:
- client = APIClient()
- client.force_authenticate(user=user)
- return client
-
- return _build
-
-
-@pytest.fixture
-def regular_user(make_user) -> models.Model:
- """Обычный пользователь без спецправ."""
- return make_user(username="regular", email="regular@example.com")
-
-
-@pytest.fixture
-def admin_user(make_user) -> models.Model:
- """Пользователь с правами администратора."""
- return make_user(
- username="admin", email="admin@example.com", is_staff=True, is_superuser=True
- )
-
-
-@pytest.fixture
-def dept_manager_user(make_user) -> models.Model:
- """Пользователь - менеджер отдела."""
- return make_user(username="dept_manager", email="dept_manager@example.com")
-
-
-@pytest.fixture
-def make_event(db, CalendarEvent):
- """Фабрика для создания событий календаря.
-
- Args:
- title (str): Название события
- start_date (date): Дата начала
- end_date (date, optional): Дата окончания
- start_time (time, optional): Время начала
- end_time (time, optional): Время окончания
- all_day (bool): Весь день (по умолчанию True)
- calendar (Calendar, optional): Календарь (новая логика)
- department (Department, optional): Отдел (legacy)
- employee (User, optional): Сотрудник (legacy)
- **kwargs: Дополнительные поля
-
- Returns:
- CalendarEvent: Созданное событие
- """
-
- def _make(
- title="Test Event",
- start_date=None,
- end_date=None,
- start_time=None,
- end_time=None,
- all_day=True,
- calendar=None,
- department=None,
- employee=None,
- **kwargs,
- ):
- if start_date is None:
- start_date = date.today()
-
- event = CalendarEvent.objects.create(
- title=title,
- start_date=start_date,
- end_date=end_date,
- start_time=start_time,
- end_time=end_time,
- all_day=all_day,
- calendar=calendar,
- department=department,
- employee=employee,
- **kwargs,
- )
- return event
-
- return _make
diff --git a/backend/tests/api/v1/calendar_app/test_calendar_api.py b/backend/tests/api/v1/calendar_app/test_calendar_api.py
deleted file mode 100644
index 2b22643f..00000000
--- a/backend/tests/api/v1/calendar_app/test_calendar_api.py
+++ /dev/null
@@ -1,448 +0,0 @@
-# tests/test_calendar_api.py
-from __future__ import annotations
-
-from datetime import date, datetime, time, timedelta
-from typing import Any, Dict, List, Optional, Tuple, TypedDict
-
-import pytest
-from django.utils.timezone import make_naive
-
-
-@pytest.mark.django_db
-class TestAuthAndPermissions:
- """A. Аутентификация и права."""
-
- def test_unauthenticated_is_401(self, api_client, api_url):
- """Без токена: 401 на list/create/detail/update/delete.
-
- Raises:
- AssertionError: Если API не вернул ожидаемые статусы.
- """
- # list
- r = api_client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"})
- assert r.status_code == 401
-
- # create
- r = api_client.post(api_url, {"title": "X", "start_date": "2025-09-10", "all_day": True}, format="json")
- assert r.status_code == 401
-
- def test_company_get_ok_regular_user(self, auth_client, regular_user, api_url):
- """GET company events доступен авторизованным."""
- client = auth_client(regular_user)
- r = client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"})
- # допускаем 200 с [] либо 200 с массивом
- assert r.status_code in (200,)
-
- def test_company_create_forbidden_for_regular(self, auth_client, regular_user, api_url):
- """Обычному пользователю нельзя создавать глобальные события."""
- client = auth_client(regular_user)
- payload = {"title": "X", "start_date": "2025-09-10", "all_day": True}
- r = client.post(api_url, payload, format="json")
- assert r.status_code in (403, 401)
-
- def test_company_create_allowed_for_admin(self, auth_client, admin_user, api_url):
- """Админу можно создавать глобальные события."""
- client = auth_client(admin_user)
- payload = {"title": "Глобал", "start_date": "2025-09-10", "all_day": True}
- r = client.post(api_url, payload, format="json")
- assert r.status_code == 201
- assert r.data.get("id")
-
-
-@pytest.mark.django_db
-class TestDepartmentPermissions:
- """Права отдела (AdminOrDeptAllowed + manage_department_events)."""
-
- def test_department_get_allowed_for_admin(self, auth_client, admin_user, api_url, make_department):
- """Админ видит события любого отдела."""
- dept = make_department("QA")
- client = auth_client(admin_user)
- r = client.get(api_url, {"department_id": dept.pk, "start": "2025-09-01", "end": "2025-09-30"})
- assert r.status_code == 200
-
- def test_department_create_requires_perm(self, auth_client, dept_manager_user, give_manage_calendar_perm, api_url, make_department):
- """Создание в отделе требует пермишена manage_department_events."""
- dept = make_department("Dev")
- client = auth_client(dept_manager_user)
-
- # без права
- payload = {"title": "Отдел митинг", "start_date": "2025-09-11", "all_day": True, "department_id": dept.pk}
- r1 = client.post(api_url, payload, format="json")
- assert r1.status_code in (403, 401)
-
- # с правом
- give_manage_calendar_perm(dept_manager_user, dept)
- r2 = client.post(api_url, payload, format="json")
- assert r2.status_code == 201
- # assert r2.data.get("department") == dept.pk or r2.data.get("department_id") == dept.pk
-
-
-@pytest.mark.django_db
-class TestRoutesAndMethods:
- """B. Маршруты и методы."""
-
- def test_methods_and_detail(self, auth_client, admin_user, api_url):
- """Проверка list/detail/put/patch/delete подряд."""
- client = auth_client(admin_user)
-
- # create
- r = client.post(api_url, {"title": "Detail", "start_date": "2025-09-10", "all_day": True}, format="json")
- assert r.status_code == 201
- eid = r.data["id"]
-
- # list
- r = client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"})
- assert r.status_code == 200
-
- # detail
- r = client.get(f"{api_url}{eid}/")
- assert r.status_code == 200
- assert r.data["title"] == "Detail"
-
- # patch
- r = client.patch(f"{api_url}{eid}/", {"title": "Detail2"}, format="json")
- assert r.status_code == 200
- assert r.data["title"] == "Detail2"
-
- # delete
- r = client.delete(f"{api_url}{eid}/")
- assert r.status_code == 204
-
- # delete again
- r = client.delete(f"{api_url}{eid}/")
- assert r.status_code in (404, 403)
-
-
-@pytest.mark.django_db
-class TestListParams:
- """C–D. Параметры выборки и контракт ответа."""
-
- def test_list_requires_range_or_returns_empty(self, auth_client, admin_user, api_url):
- """Без start/end — возвращает 200 и [] (ожидаемая безопасная SemVer-логика)."""
- client = auth_client(admin_user)
- r = client.get(api_url)
- assert r.status_code == 200
- assert isinstance(r.data, list)
-
- def test_filter_company_vs_department(self, auth_client, admin_user, api_url, make_department, make_event):
- """Фильтр по department_id отделяет события компании и отдела."""
- dept = make_department("HR")
- # создаём отделское и глобальное события
- ev_company = make_event(title="CompanyOnly")
- ev_dept = make_event(title="DeptOnly", department=dept)
-
- client = auth_client(admin_user)
- # компания (без department_id)
- r1 = client.get(api_url, {"start": "2025-01-01", "end": "2025-01-31"})
- assert r1.status_code == 200
-
- # отдел
- r2 = client.get(api_url, {"department_id": dept.pk, "start": "2025-01-01", "end": "2025-01-31"})
- assert r2.status_code == 200
-
-
-@pytest.mark.django_db
-class TestCreatePositive:
- """E. Создание — позитивные кейсы."""
-
- def test_company_all_day_one_day(self, auth_client, admin_user, api_url):
- """Однодневное целодневное глобальное событие."""
- client = auth_client(admin_user)
- payload = {"title": "AllDay", "start_date": "2025-09-12", "end_date": "2025-09-12", "all_day": True}
- r = client.post(api_url, payload, format="json")
- assert r.status_code == 201
-
- def test_company_multi_day_all_day(self, auth_client, admin_user, api_url):
- """Многодневное all-day."""
- client = auth_client(admin_user)
- payload = {"title": "Offsite", "start_date": "2025-09-12", "end_date": "2025-09-15", "all_day": True}
- r = client.post(api_url, payload, format="json")
- assert r.status_code == 201
-
- def test_company_with_time(self, auth_client, admin_user, api_url):
- """Событие с временем в один день."""
- client = auth_client(admin_user)
- payload = {
- "title": "Standup",
- "start_date": "2025-09-12",
- "end_date": "2025-09-12",
- "start_time": "09:30",
- "end_time": "10:00",
- "all_day": False,
- }
- r = client.post(api_url, payload, format="json")
- assert r.status_code == 201
-
- def test_hourly_requires_time(self, auth_client, admin_user, api_url):
- """Ежечасное событие (оба времени обязательны)."""
- client = auth_client(admin_user)
- payload = {
- "title": "Scan jobs",
- "start_date": "2025-09-12",
- "end_date": "2025-09-12",
- "start_time": "08:00",
- "end_time": "08:30",
- "all_day": False,
- "recurrence": "hourly",
- "recurrence_interval": 2,
- }
- r = client.post(api_url, payload, format="json")
- assert r.status_code == 201
-
- def test_weekly_with_weekdays(self, auth_client, admin_user, api_url):
- """Еженедельное с маской дней недели."""
- client = auth_client(admin_user)
- payload = {
- "title": "Yoga",
- "start_date": "2025-09-08", # понедельник
- "end_date": "2025-09-08",
- "all_day": True,
- "recurrence": "weekly",
- "recurrence_interval": 1,
- "weekdays": [0, 2, 4], # Пн, Ср, Пт
- }
- r = client.post(api_url, payload, format="json")
- assert r.status_code == 201
-
- def test_monthly(self, auth_client, admin_user, api_url):
- """Ежемесячное событие."""
- client = auth_client(admin_user)
- payload = {
- "title": "Billing",
- "start_date": "2025-01-31",
- "end_date": "2025-01-31",
- "all_day": True,
- "recurrence": "monthly",
- "recurrence_interval": 1,
- }
- r = client.post(api_url, payload, format="json")
- assert r.status_code == 201
-
- def test_annual(self, auth_client, admin_user, api_url):
- """Ежегодное событие."""
- client = auth_client(admin_user)
- payload = {
- "title": "Anniversary",
- "start_date": "2025-09-12",
- "end_date": "2025-09-12",
- "all_day": True,
- "recurrence": "annual",
- }
- r = client.post(api_url, payload, format="json")
- assert r.status_code == 201
-
-
-@pytest.mark.django_db
-class TestCreateNegative:
- """F. Создание — негативные кейсы (400)."""
-
- @pytest.mark.parametrize(
- "payload",
- [
- {"title": "BadDates", "start_date": "2025-09-12", "end_date": "2025-09-11", "all_day": True},
- {"title": "PartialTime1", "start_date": "2025-09-12", "end_date": "2025-09-12", "start_time": "09:00"},
- {"title": "PartialTime2", "start_date": "2025-09-12", "end_date": "2025-09-12", "end_time": "10:00"},
- {"title": "EndBeforeStart", "start_date": "2025-09-12", "end_date": "2025-09-12", "start_time": "11:00", "end_time": "10:00", "all_day": False},
- {"title": "BadInterval", "start_date": "2025-09-12", "end_date": "2025-09-12", "recurrence": "daily", "recurrence_interval": 0, "all_day": True},
- {"title": "HourlyNoTime", "start_date": "2025-09-12", "end_date": "2025-09-12", "recurrence": "hourly", "all_day": False},
- {"title": "UntilAndCount", "start_date": "2025-09-12", "end_date": "2025-09-12", "all_day": True, "recurrence": "daily", "recurrence_until": "2025-10-01", "recurrence_count": 5},
- ],
- )
- def test_bad_payloads(self, auth_client, admin_user, api_url, payload):
- """Неверные комбинации должны возвращать 400."""
- client = auth_client(admin_user)
- r = client.post(api_url, payload, format="json")
- assert r.status_code == 400
-
-
-@pytest.mark.django_db
-class TestUpdatePatch:
- """G. Обновление."""
-
- def test_move_company_to_dept_and_back(self, auth_client, admin_user, api_url, make_department):
- """Перенос события компания ↔ отдел."""
- dept = make_department("Ops")
- client = auth_client(admin_user)
-
- r = client.post(api_url, {"title": "Relocate", "start_date": "2025-09-12", "all_day": True}, format="json")
- assert r.status_code == 201
- eid = r.data["id"]
-
- # в отдел
- r = client.patch(f"{api_url}{eid}/", {"department_id": dept.pk}, format="json")
- assert r.status_code == 200
- assert r.data.get("department") == dept.pk or r.data.get("department_id") == dept.pk
-
- # обратно в компанию
- r = client.patch(f"{api_url}{eid}/", {"department_id": None}, format="json")
- assert r.status_code == 200
- assert r.data.get("department") in (None,) or r.data.get("department_id") in (None,)
-
- def test_change_all_day_flags(self, auth_client, admin_user, api_url):
- """all_day → время и обратно."""
- client = auth_client(admin_user)
- r = client.post(api_url, {"title": "Flip", "start_date": "2025-09-12", "end_date": "2025-09-12", "all_day": True}, format="json")
- assert r.status_code == 201
- eid = r.data["id"]
-
- # добавить время
- r = client.patch(f"{api_url}{eid}/", {"start_time": "10:00", "end_time": "11:00", "all_day": False}, format="json")
- assert r.status_code == 200
- assert r.data.get("all_day") is False
-
- # убрать время
- r = client.patch(f"{api_url}{eid}/", {"start_time": None, "end_time": None, "all_day": True}, format="json")
- assert r.status_code == 200
- assert r.data.get("all_day") is True
-
- def test_change_recurrence(self, auth_client, admin_user, api_url):
- """Смена типов повторяемости и параметров."""
- client = auth_client(admin_user)
- r = client.post(api_url, {"title": "R", "start_date": "2025-09-08", "end_date": "2025-09-08", "all_day": True}, format="json")
- assert r.status_code == 201
- eid = r.data["id"]
-
- # one_time -> weekly
- r = client.patch(f"{api_url}{eid}/", {"recurrence": "weekly", "recurrence_interval": 1, "weekdays": [0, 2, 4]}, format="json")
- assert r.status_code == 200
-
- # weekly -> daily
- r = client.patch(f"{api_url}{eid}/", {"recurrence": "daily", "recurrence_interval": 1, "weekdays": []}, format="json")
- assert r.status_code == 200
-
- # daily -> annual (с ограничителем)
- r = client.patch(f"{api_url}{eid}/", {"recurrence": "annual", "recurrence_until": "2028-09-08"}, format="json")
- assert r.status_code == 200
-
-
-@pytest.mark.django_db
-class TestDelete:
- """H. Удаление."""
-
- def test_delete_company_event(self, auth_client, admin_user, api_url):
- """Удаление глобального события."""
- client = auth_client(admin_user)
- r = client.post(api_url, {"title": "Del", "start_date": "2025-09-12", "all_day": True}, format="json")
- assert r.status_code == 201
- eid = r.data["id"]
-
- r = client.delete(f"{api_url}{eid}/")
- assert r.status_code == 204
-
-
-@pytest.mark.django_db
-class TestMaterialization:
- """I. Материализация вхождений (occurrences)."""
-
- def test_one_time_window_overlap(self, auth_client, admin_user, api_url):
- """one_time попадает в окно при пересечении с [start,end)."""
- client = auth_client(admin_user)
- client.post(api_url, {"title": "Once", "start_date": "2025-09-10", "end_date": "2025-09-12", "all_day": True}, format="json")
- r = client.get(api_url, {"start": "2025-09-11", "end": "2025-09-30"})
- assert r.status_code == 200
- assert isinstance(r.data, list)
- assert any(item.get("title") == "Once" for item in r.data)
-
- def test_hourly_alignment(self, auth_client, admin_user, api_url):
- """hourly шагает через interval часов и выравнивается по range_start."""
- client = auth_client(admin_user)
- client.post(
- api_url,
- {
- "title": "Jobs",
- "start_date": "2025-09-10",
- "end_date": "2025-09-10",
- "start_time": "08:00",
- "end_time": "08:30",
- "all_day": False,
- "recurrence": "hourly",
- "recurrence_interval": 2,
- },
- format="json",
- )
- r = client.get(api_url, {"start": "2025-09-10", "end": "2025-09-10"})
- assert r.status_code == 200
- # допускаем пусто, если ваш list требует datetime-диапазон; сделайте отдельный e2e
- # основная проверка: запрос с недельным окном должен вернуть несколько слотов
- r2 = client.get(api_url, {"start": "2025-09-10", "end": "2025-09-11"})
- assert r2.status_code == 200
- assert isinstance(r2.data, list)
-
- def test_weekly_with_mask(self, auth_client, admin_user, api_url):
- """weekly учитывает weekdays [0,2,4]."""
- client = auth_client(admin_user)
- client.post(
- api_url,
- {"title": "Gym", "start_date": "2025-09-08", "end_date": "2025-09-08", "all_day": True, "recurrence": "weekly", "weekdays": [0, 2, 4]},
- format="json",
- )
- r = client.get(api_url, {"start": "2025-09-08", "end": "2025-09-15"})
- assert r.status_code == 200
- assert isinstance(r.data, list)
-
- def test_monthly_31st_feb_case(self, auth_client, admin_user, api_url):
- """31-е число корректно переносится на 28/29 февраля."""
- client = auth_client(admin_user)
- client.post(
- api_url,
- {"title": "EOM", "start_date": "2025-01-31", "end_date": "2025-01-31", "all_day": True, "recurrence": "monthly"},
- format="json",
- )
- r = client.get(api_url, {"start": "2025-02-01", "end": "2025-03-01"})
- assert r.status_code == 200
- assert isinstance(r.data, list)
-
- def test_annual_leap_day(self, auth_client, admin_user, api_url):
- """29 февраля в невисокосные годы → 28 февраля."""
- client = auth_client(admin_user)
- client.post(
- api_url,
- {"title": "Leap", "start_date": "2024-02-29", "end_date": "2024-02-29", "all_day": True, "recurrence": "annual"},
- format="json",
- )
- r = client.get(api_url, {"start": "2025-02-01", "end": "2025-03-01"})
- assert r.status_code == 200
- assert isinstance(r.data, list)
-
-
-@pytest.mark.django_db
-class TestWriteCompatibility:
- """J. Write-совместимость."""
-
- def test_single_date_shortcut(self, auth_client, admin_user, api_url):
- """Поддержка короткого формата с одним полем `date` (если реализовано)."""
- client = auth_client(admin_user)
- payload = {"title": "Short", "date": "2025-09-20"} # если в сериализаторе есть alias
- r = client.post(api_url, payload, format="json")
- assert r.status_code in (201, 400) # 201 если alias реализован; 400 если нет — скорректируйте тест под вашу схему
-
-
-@pytest.mark.django_db
-class TestSignalsIfPresent:
- """K. Сигналы дней рождений — опционально.
-
- Эти тесты пропустятся, если в проекте нет employees.Employee.
- """
-
- def test_birthday_signal_upsert(self, django_assert_num_queries, auth_client, admin_user, api_url):
- """Создание/обновление события дня рождения при изменении сотрудника."""
- try:
- Employee = pytest.importorskip("employees.models").Employee # type: ignore[attr-defined]
- except Exception:
- pytest.skip("Модель employees.Employee недоступна")
-
- # создаём сотрудника с ДР
- emp = Employee.objects.create(first_name="Ivan", last_name="Petrov", birth_date=date(1990, 9, 12)) # type: ignore[attr-defined]
- # ожидаем, что появится ежегодное событие (в фоновом сигнале)
- client = auth_client(admin_user)
- r = client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"})
- assert r.status_code == 200
- # проверка по заголовку
- assert any("День рождения" in (x.get("title") or "") for x in r.data)
-
- # очистим ДР → событие должно исчезнуть
- emp.birth_date = None # type: ignore[attr-defined]
- emp.save()
- r2 = client.get(api_url, {"start": "2025-09-01", "end": "2025-09-30"})
- assert r2.status_code == 200
- # допускаем отсутствие такого события
diff --git a/backend/tests/api/v1/calendar_app/test_invitation_api.py b/backend/tests/api/v1/calendar_app/test_invitation_api.py
deleted file mode 100644
index 58e2a309..00000000
--- a/backend/tests/api/v1/calendar_app/test_invitation_api.py
+++ /dev/null
@@ -1,735 +0,0 @@
-# tests/api/v1/calendar_app/test_invitation_api.py
-"""
-Тесты для API приглашения пользователей в календарь.
-
-Покрывает endpoints:
-- POST /api/v1/calendar/calendars/{id}/invite/
-- POST /api/v1/calendar/calendars/{id}/invite-bulk/
-"""
-
-from __future__ import annotations
-
-import pytest
-from django.contrib.auth import get_user_model
-
-User = get_user_model()
-
-
-@pytest.mark.django_db
-class TestCalendarInvite:
- """Тесты для приглашения одного пользователя."""
-
- def test_invite_user_as_owner(
- self, auth_client, make_user, make_calendar, CalendarSubscription
- ):
- """Владелец может пригласить пользователя."""
- owner = make_user(username="owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": invited.id,
- "can_edit": True,
- "can_manage": False,
- "notify": True,
- },
- format="json",
- )
-
- assert response.status_code == 201, response.data
- assert response.data["user"] == invited.id
- assert response.data["can_edit"] is True
- assert response.data["can_manage"] is False
-
- # Проверяем, что подписка создана
- subscription = CalendarSubscription.objects.filter(
- calendar=calendar, user=invited
- ).first()
- assert subscription is not None
- assert subscription.can_edit is True
- assert subscription.can_manage is False
-
- def test_invite_user_not_owner(self, auth_client, make_user, make_calendar):
- """Не-владелец не может приглашать."""
- owner = make_user(username="owner")
- non_owner = make_user(username="non_owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(non_owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": invited.id,
- "can_edit": True,
- "notify": True,
- },
- format="json",
- )
-
- assert response.status_code == 403
- assert "владелец" in response.data["detail"].lower()
-
- def test_invite_with_edit_permission(
- self, auth_client, make_user, make_calendar, CalendarSubscription
- ):
- """Приглашение с правом редактирования."""
- owner = make_user(username="owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": invited.id,
- "can_edit": True,
- "can_manage": False,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 201
- subscription = CalendarSubscription.objects.get(calendar=calendar, user=invited)
- assert subscription.can_edit is True
- assert subscription.can_manage is False
-
- def test_invite_with_manage_permission(
- self, auth_client, make_user, make_calendar, CalendarSubscription
- ):
- """Приглашение с правом управления."""
- owner = make_user(username="owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": invited.id,
- "can_edit": False,
- "can_manage": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 201
- subscription = CalendarSubscription.objects.get(calendar=calendar, user=invited)
- assert subscription.can_edit is False
- assert subscription.can_manage is True
-
- def test_invite_with_both_permissions(
- self, auth_client, make_user, make_calendar, CalendarSubscription
- ):
- """Приглашение с обоими правами (редактирование и управление)."""
- owner = make_user(username="owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": invited.id,
- "can_edit": True,
- "can_manage": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 201
- subscription = CalendarSubscription.objects.get(calendar=calendar, user=invited)
- assert subscription.can_edit is True
- assert subscription.can_manage is True
-
- def test_invite_already_subscribed(
- self, auth_client, make_user, make_calendar, make_subscription
- ):
- """Ошибка при приглашении уже подписанного пользователя."""
- owner = make_user(username="owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- # Создаем существующую подписку
- existing_sub = make_subscription(calendar=calendar, user=invited)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": invited.id,
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 400
- assert "уже подписан" in response.data["detail"].lower()
- assert response.data.get("subscription_id") == existing_sub.id
-
- def test_invite_self(self, auth_client, make_user, make_calendar):
- """Ошибка при приглашении самого себя."""
- owner = make_user(username="owner")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": owner.id,
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 400
- assert "себя" in response.data["detail"].lower()
-
- def test_invite_nonexistent_user(self, auth_client, make_user, make_calendar):
- """Ошибка при приглашении несуществующего пользователя."""
- owner = make_user(username="owner")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": 99999, # несуществующий ID
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 404
- assert "не найден" in response.data["detail"].lower()
-
- def test_invite_by_username(
- self, auth_client, make_user, make_calendar, CalendarSubscription
- ):
- """Приглашение по username вместо user_id."""
- owner = make_user(username="owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "username": "invited_user",
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 201
- subscription = CalendarSubscription.objects.get(calendar=calendar, user=invited)
- assert subscription.can_edit is True
-
- def test_invite_by_nonexistent_username(
- self, auth_client, make_user, make_calendar
- ):
- """Ошибка при приглашении по несуществующему username."""
- owner = make_user(username="owner")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "username": "nonexistent_user",
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 404
- assert "не найден" in response.data["detail"].lower()
-
- def test_invite_sends_notification(self, auth_client, make_user, make_calendar):
- """Проверка отправки уведомления при приглашении."""
- from notifications.models import Notification, NotificationType
-
- owner = make_user(username="owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": invited.id,
- "can_edit": True,
- "notify": True, # Включаем уведомления
- },
- format="json",
- )
-
- assert response.status_code == 201
-
- # Получаем тип уведомления
- notification_type = NotificationType.objects.filter(
- code="calendar_invitation"
- ).first()
-
- # Проверяем, что уведомление создано
- notification = Notification.objects.filter(
- recipient=invited,
- notification_type=notification_type,
- object_id=calendar.id,
- ).first()
-
- assert notification is not None
- assert "приглас" in notification.message.lower()
- assert calendar.title in notification.message
-
- def test_invite_without_notification(self, auth_client, make_user, make_calendar):
- """Приглашение без уведомления (notify=False)."""
- from notifications.models import Notification, NotificationType
-
- owner = make_user(username="owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": invited.id,
- "can_edit": True,
- "notify": False, # Отключаем уведомления
- },
- format="json",
- )
-
- assert response.status_code == 201
-
- # Получаем тип уведомления (если он существует)
- notification_type = NotificationType.objects.filter(
- code="calendar_invitation"
- ).first()
-
- # Проверяем, что уведомление НЕ создано
- if notification_type:
- notification = Notification.objects.filter(
- recipient=invited,
- notification_type=notification_type,
- ).first()
- assert notification is None
-
- def test_invite_without_user_identifier(
- self, auth_client, make_user, make_calendar
- ):
- """Ошибка при отсутствии user_id и username."""
- owner = make_user(username="owner")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 400
-
- def test_invite_with_both_identifiers(self, auth_client, make_user, make_calendar):
- """Ошибка при указании и user_id и username одновременно."""
- owner = make_user(username="owner")
- invited = make_user(username="invited_user")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- {
- "user_id": invited.id,
- "username": "invited_user",
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 400
-
-
-@pytest.mark.django_db
-class TestCalendarInviteBulk:
- """Тесты для массового приглашения пользователей."""
-
- def test_invite_bulk_multiple_users(
- self, auth_client, make_user, make_calendar, CalendarSubscription
- ):
- """Массовое приглашение нескольких пользователей."""
- owner = make_user(username="owner")
- user1 = make_user(username="user1")
- user2 = make_user(username="user2")
- user3 = make_user(username="user3")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [user1.id, user2.id, user3.id],
- "can_edit": True,
- "can_manage": False,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 201
- assert response.data["total_created"] == 3
- assert response.data["total_already_subscribed"] == 0
- assert response.data["total_errors"] == 0
- assert len(response.data["created"]) == 3
-
- # Проверяем, что подписки созданы
- assert CalendarSubscription.objects.filter(
- calendar=calendar, user=user1
- ).exists()
- assert CalendarSubscription.objects.filter(
- calendar=calendar, user=user2
- ).exists()
- assert CalendarSubscription.objects.filter(
- calendar=calendar, user=user3
- ).exists()
-
- def test_invite_bulk_with_already_subscribed(
- self, auth_client, make_user, make_calendar, make_subscription
- ):
- """Обработка уже подписанных пользователей."""
- owner = make_user(username="owner")
- user1 = make_user(username="user1")
- user2 = make_user(username="user2")
- user3 = make_user(username="user3")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- # User2 уже подписан
- existing_sub = make_subscription(calendar=calendar, user=user2)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [user1.id, user2.id, user3.id],
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- # Должен быть статус 201, даже если некоторые уже подписаны
- assert response.status_code in [200, 201]
- assert response.data["total_created"] == 2
- assert response.data["total_already_subscribed"] == 1
- assert response.data["total_errors"] == 0
-
- # Проверяем список уже подписанных
- already_sub = response.data["already_subscribed"]
- assert len(already_sub) == 1
- assert already_sub[0]["user_id"] == user2.id
- assert already_sub[0]["subscription_id"] == existing_sub.id
-
- def test_invite_bulk_excludes_owner(
- self, auth_client, make_user, make_calendar, CalendarSubscription
- ):
- """Владелец автоматически исключается из списка приглашаемых."""
- owner = make_user(username="owner")
- user1 = make_user(username="user1")
- user2 = make_user(username="user2")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [user1.id, owner.id, user2.id], # Включаем owner
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- # Владелец должен быть исключен
- assert response.status_code in [200, 201]
- assert response.data["total_created"] == 2
-
- # Владелец не должен быть в подписках
- assert not CalendarSubscription.objects.filter(
- calendar=calendar, user=owner
- ).exists()
-
- def test_invite_bulk_partial_errors(self, auth_client, make_user, make_calendar):
- """Обработка частичных ошибок при массовом приглашении."""
- owner = make_user(username="owner")
- user1 = make_user(username="user1")
- user2 = make_user(username="user2")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [
- user1.id,
- 99999, # несуществующий пользователь
- user2.id,
- ],
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- # Должны создаться подписки для существующих пользователей
- assert response.status_code in [200, 201]
- # Несуществующий пользователь просто не будет найден и пропущен
- # (User.objects.filter(id__in=[...]) вернет только существующих)
-
- def test_invite_bulk_by_usernames(
- self, auth_client, make_user, make_calendar, CalendarSubscription
- ):
- """Массовое приглашение по usernames."""
- owner = make_user(username="owner")
- user1 = make_user(username="user1")
- user2 = make_user(username="user2")
- user3 = make_user(username="user3")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "usernames": ["user1", "user2", "user3"],
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 201
- assert response.data["total_created"] == 3
-
- # Проверяем, что подписки созданы
- assert CalendarSubscription.objects.filter(
- calendar=calendar, user=user1
- ).exists()
- assert CalendarSubscription.objects.filter(
- calendar=calendar, user=user2
- ).exists()
- assert CalendarSubscription.objects.filter(
- calendar=calendar, user=user3
- ).exists()
-
- def test_invite_bulk_empty_list(self, auth_client, make_user, make_calendar):
- """Ошибка при пустом списке пользователей."""
- owner = make_user(username="owner")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [],
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 400
-
- def test_invite_bulk_sends_notifications(
- self, auth_client, make_user, make_calendar
- ):
- """Проверка отправки уведомлений всем приглашенным."""
- from notifications.models import Notification, NotificationType
-
- owner = make_user(username="owner")
- user1 = make_user(username="user1")
- user2 = make_user(username="user2")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [user1.id, user2.id],
- "can_edit": True,
- "notify": True, # Включаем уведомления
- },
- format="json",
- )
-
- assert response.status_code == 201
-
- # Получаем тип уведомления
- notification_type = NotificationType.objects.filter(
- code="calendar_invitation"
- ).first()
-
- # Проверяем, что уведомления созданы для обоих пользователей
- notification1 = Notification.objects.filter(
- recipient=user1,
- notification_type=notification_type,
- object_id=calendar.id,
- ).first()
- notification2 = Notification.objects.filter(
- recipient=user2,
- notification_type=notification_type,
- object_id=calendar.id,
- ).first()
-
- assert notification1 is not None
- assert notification2 is not None
-
- def test_invite_bulk_without_notifications(
- self, auth_client, make_user, make_calendar
- ):
- """Массовое приглашение без уведомлений."""
- from notifications.models import Notification, NotificationType
-
- owner = make_user(username="owner")
- user1 = make_user(username="user1")
- user2 = make_user(username="user2")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [user1.id, user2.id],
- "can_edit": True,
- "notify": False, # Отключаем уведомления
- },
- format="json",
- )
-
- assert response.status_code == 201
-
- # Получаем тип уведомления (если он существует)
- notification_type = NotificationType.objects.filter(
- code="calendar_invitation"
- ).first()
-
- # Проверяем, что уведомления НЕ созданы
- if notification_type:
- notifications_count = Notification.objects.filter(
- notification_type=notification_type,
- object_id=calendar.id,
- ).count()
-
- assert notifications_count == 0
-
- def test_invite_bulk_statistics(
- self, auth_client, make_user, make_calendar, make_subscription
- ):
- """Проверка статистики результата массового приглашения."""
- owner = make_user(username="owner")
- user1 = make_user(username="user1")
- user2 = make_user(username="user2")
- user3 = make_user(username="user3")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- # User2 уже подписан
- make_subscription(calendar=calendar, user=user2)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [user1.id, user2.id, user3.id],
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code in [200, 201]
-
- # Проверяем статистику
- assert "total_created" in response.data
- assert "total_already_subscribed" in response.data
- assert "total_errors" in response.data
- assert "created" in response.data
- assert "already_subscribed" in response.data
- assert "errors" in response.data
-
- assert response.data["total_created"] == 2
- assert response.data["total_already_subscribed"] == 1
- assert response.data["total_errors"] == 0
-
- def test_invite_bulk_not_owner(self, auth_client, make_user, make_calendar):
- """Не-владелец не может массово приглашать."""
- owner = make_user(username="owner")
- non_owner = make_user(username="non_owner")
- user1 = make_user(username="user1")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(non_owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [user1.id],
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 403
- assert "владелец" in response.data["detail"].lower()
-
- def test_invite_bulk_no_users_found(self, auth_client, make_user, make_calendar):
- """Ошибка когда ни один пользователь не найден."""
- owner = make_user(username="owner")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "user_ids": [99998, 99999], # несуществующие ID
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 404
- assert "не найден" in response.data["detail"].lower()
-
- def test_invite_bulk_without_identifier_list(
- self, auth_client, make_user, make_calendar
- ):
- """Ошибка при отсутствии списка пользователей."""
- owner = make_user(username="owner")
- calendar = make_calendar(title="Test Calendar", owner_user=owner)
-
- client = auth_client(owner)
- response = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite-bulk/",
- {
- "can_edit": True,
- "notify": False,
- },
- format="json",
- )
-
- assert response.status_code == 400
diff --git a/backend/tests/api/v1/calendar_app/test_new_calendar_api.py b/backend/tests/api/v1/calendar_app/test_new_calendar_api.py
deleted file mode 100644
index a4e81168..00000000
--- a/backend/tests/api/v1/calendar_app/test_new_calendar_api.py
+++ /dev/null
@@ -1,404 +0,0 @@
-# tests/api/v1/calendar_app/test_new_calendar_api.py
-"""
-Тесты для новых эндпоинтов Calendar и CalendarSubscription.
-
-Проверяют функциональность опциональных календарей с настраиваемой видимостью
-и системой подписок.
-"""
-
-from __future__ import annotations
-
-import pytest
-
-
-@pytest.mark.django_db
-class TestCalendarCRUD:
- """Тесты CRUD операций для Calendar."""
-
- def test_list_calendars_unauthenticated(self, api_client):
- """Неавторизованный пользователь не может просмотреть календари."""
- r = api_client.get("/api/v1/calendar/calendars/")
- assert r.status_code == 401
-
- def test_list_calendars_authenticated(self, auth_client, regular_user):
- """Авторизованный пользователь видит доступные календари."""
- client = auth_client(regular_user)
- r = client.get("/api/v1/calendar/calendars/")
- assert r.status_code == 200
- # API использует пагинацию
- assert "results" in r.data
- assert isinstance(r.data["results"], list)
-
- def test_create_personal_calendar(self, auth_client, regular_user):
- """Пользователь может создать личный календарь."""
- client = auth_client(regular_user)
- payload = {
- "title": "Мой календарь",
- "description": "Личный календарь",
- "color": "#ff0000",
- "visibility": "private",
- "owner_user": regular_user.id,
- }
- r = client.post("/api/v1/calendar/calendars/", payload, format="json")
- assert r.status_code == 201
- assert r.data.get("title") == "Мой календарь"
- assert r.data.get("visibility") == "private"
- assert r.data.get("is_personal") is True
-
- def test_create_public_calendar(self, auth_client, admin_user):
- """Администратор может создать публичный календарь."""
- client = auth_client(admin_user)
- payload = {
- "title": "Общий календарь",
- "description": "Календарь для всех",
- "color": "#0000ff",
- "visibility": "public",
- }
- r = client.post("/api/v1/calendar/calendars/", payload, format="json")
- assert r.status_code == 201
- assert r.data.get("title") == "Общий календарь"
- assert r.data.get("visibility") == "public"
- assert r.data.get("is_global") is True
-
- def test_create_department_calendar(self, auth_client, admin_user, make_department):
- """Администратор может создать календарь отдела."""
- dept = make_department("IT")
- client = auth_client(admin_user)
- payload = {
- "title": "IT календарь",
- "description": "Календарь отдела IT",
- "color": "#00ff00",
- "visibility": "department",
- "owner_department": dept.id,
- }
- r = client.post("/api/v1/calendar/calendars/", payload, format="json")
- assert r.status_code == 201
- assert r.data.get("title") == "IT календарь"
- assert r.data.get("visibility") == "department"
- assert r.data.get("is_department") is True
-
- def test_cannot_create_with_both_owners(
- self, auth_client, admin_user, make_department
- ):
- """Нельзя создать календарь с двумя владельцами одновременно."""
- dept = make_department("HR")
- client = auth_client(admin_user)
- payload = {
- "title": "Неверный календарь",
- "visibility": "public",
- "owner_user": admin_user.id,
- "owner_department": dept.id,
- }
- r = client.post("/api/v1/calendar/calendars/", payload, format="json")
- assert r.status_code == 400
-
- def test_retrieve_calendar(self, auth_client, admin_user, make_calendar):
- """Получение детальной информации о календаре."""
- calendar = make_calendar(title="Test Calendar")
- client = auth_client(admin_user)
- r = client.get(f"/api/v1/calendar/calendars/{calendar.id}/")
- assert r.status_code == 200
- assert r.data.get("title") == "Test Calendar"
- # event_count и subscriber_count - опциональные поля (добавляются через annotate)
- # В простом retrieve они могут отсутствовать
-
- def test_update_calendar_as_owner(self, auth_client, regular_user, make_calendar):
- """Владелец может обновить свой календарь."""
- calendar = make_calendar(title="Old Title", owner_user=regular_user)
- client = auth_client(regular_user)
- r = client.patch(
- f"/api/v1/calendar/calendars/{calendar.id}/",
- {"title": "New Title", "color": "#ff00ff"},
- format="json",
- )
- assert r.status_code == 200
- assert r.data.get("title") == "New Title"
- assert r.data.get("color") == "#ff00ff"
-
- def test_cannot_update_others_calendar(
- self, auth_client, regular_user, make_calendar, make_user
- ):
- """Нельзя обновить чужой календарь."""
- other_user = make_user(username="other")
- calendar = make_calendar(title="Other's Calendar", owner_user=other_user)
- client = auth_client(regular_user)
- r = client.patch(
- f"/api/v1/calendar/calendars/{calendar.id}/",
- {"title": "Hacked"},
- format="json",
- )
- assert r.status_code == 403
-
- def test_delete_calendar_as_owner(self, auth_client, regular_user, make_calendar):
- """Владелец может удалить свой календарь."""
- calendar = make_calendar(title="To Delete", owner_user=regular_user)
- client = auth_client(regular_user)
- r = client.delete(f"/api/v1/calendar/calendars/{calendar.id}/")
- assert r.status_code == 204
-
- def test_cannot_delete_others_calendar(
- self, auth_client, regular_user, make_calendar, make_user
- ):
- """Нельзя удалить чужой календарь."""
- other_user = make_user(username="other2")
- calendar = make_calendar(title="Other's Calendar", owner_user=other_user)
- client = auth_client(regular_user)
- r = client.delete(f"/api/v1/calendar/calendars/{calendar.id}/")
- assert r.status_code == 403
-
-
-@pytest.mark.django_db
-class TestCalendarSubscriptions:
- """Тесты для подписок на календари."""
-
- def test_subscribe_to_public_calendar(
- self, auth_client, regular_user, make_calendar
- ):
- """Подписка на публичный календарь."""
- calendar = make_calendar(title="Public", visibility="public")
- client = auth_client(regular_user)
- r = client.post(f"/api/v1/calendar/calendars/{calendar.id}/subscribe/")
- assert r.status_code == 201
- assert r.data.get("calendar") == calendar.id
- assert r.data.get("user") == regular_user.id
-
- def test_subscribe_with_permissions(
- self, auth_client, admin_user, regular_user, make_calendar
- ):
- """Владелец может выдать права при подписке."""
- calendar = make_calendar(title="Admin Calendar", owner_user=admin_user)
- client = auth_client(regular_user)
- # Попытка подписаться с правами (только владелец может выдавать права)
- r = client.post(
- f"/api/v1/calendar/calendars/{calendar.id}/subscribe/",
- {"can_edit": True, "can_manage": True},
- format="json",
- )
- # Пользователь подпишется, но без прав (только владелец может выдавать права)
- assert r.status_code == 201
- assert r.data.get("can_edit") is False
- assert r.data.get("can_manage") is False
-
- def test_cannot_subscribe_twice(self, auth_client, regular_user, make_calendar):
- """Нельзя подписаться на календарь дважды."""
- calendar = make_calendar(title="Once", visibility="public")
- client = auth_client(regular_user)
- # Первая подписка
- r1 = client.post(f"/api/v1/calendar/calendars/{calendar.id}/subscribe/")
- assert r1.status_code == 201
- # Вторая подписка
- r2 = client.post(f"/api/v1/calendar/calendars/{calendar.id}/subscribe/")
- assert r2.status_code == 400
-
- def test_unsubscribe_from_calendar(
- self, auth_client, regular_user, make_calendar, make_subscription
- ):
- """Отписка от календаря."""
- calendar = make_calendar(title="Unsubscribe", visibility="public")
- subscription = make_subscription(calendar=calendar, user=regular_user)
- client = auth_client(regular_user)
- r = client.post(f"/api/v1/calendar/calendars/{calendar.id}/unsubscribe/")
- assert r.status_code == 200
-
- def test_unsubscribe_without_subscription(
- self, auth_client, regular_user, make_calendar
- ):
- """Нельзя отписаться, если не подписан."""
- calendar = make_calendar(title="Not Subscribed", visibility="public")
- client = auth_client(regular_user)
- r = client.post(f"/api/v1/calendar/calendars/{calendar.id}/unsubscribe/")
- assert r.status_code == 400
-
- def test_list_my_subscriptions(
- self, auth_client, regular_user, make_calendar, make_subscription
- ):
- """Список подписок текущего пользователя."""
- cal1 = make_calendar(title="Calendar 1", visibility="public")
- cal2 = make_calendar(title="Calendar 2", visibility="public")
- make_subscription(calendar=cal1, user=regular_user)
- make_subscription(calendar=cal2, user=regular_user)
-
- client = auth_client(regular_user)
- r = client.get("/api/v1/calendar/subscriptions/")
- assert r.status_code == 200
- # API использует пагинацию
- assert "results" in r.data
- assert len(r.data["results"]) == 2
-
- def test_my_calendars_endpoint(self, auth_client, regular_user, make_calendar):
- """Эндпоинт my-calendars возвращает доступные календари."""
- # Создаем публичный календарь
- make_calendar(title="Public", visibility="public")
- # Создаем личный календарь
- make_calendar(title="My Private", visibility="private", owner_user=regular_user)
-
- client = auth_client(regular_user)
- r = client.get("/api/v1/calendar/calendars/my-calendars/")
- assert r.status_code == 200
- assert isinstance(r.data, list)
- assert len(r.data) >= 2 # Минимум 2 календаря
-
-
-@pytest.mark.django_db
-class TestCalendarEventsWithCalendarId:
- """Тесты для событий с привязкой к календарю (новая архитектура)."""
-
- def test_create_event_with_calendar_id(
- self, auth_client, admin_user, make_calendar
- ):
- """Создание события с привязкой к календарю."""
- calendar = make_calendar(title="Project Calendar", visibility="public")
- client = auth_client(admin_user)
- payload = {
- "title": "Project Meeting",
- "start_date": "2025-09-15",
- "all_day": True,
- "calendar_id": calendar.id,
- }
- r = client.post("/api/v1/calendar/events/", payload, format="json")
- assert r.status_code == 201
- assert r.data.get("title") == "Project Meeting"
-
- def test_filter_events_by_calendar_id(
- self, auth_client, admin_user, make_calendar, make_event
- ):
- """Фильтрация событий по calendar_id."""
- cal1 = make_calendar(title="Calendar 1")
- cal2 = make_calendar(title="Calendar 2")
-
- # Создаем события в разных календарях
- event1 = make_event(title="Event 1", calendar=cal1, start_date="2025-09-10")
- event2 = make_event(title="Event 2", calendar=cal2, start_date="2025-09-10")
-
- client = auth_client(admin_user)
- # Запрашиваем события первого календаря
- r = client.get(
- "/api/v1/calendar/events/",
- {"calendar_id": cal1.id, "start": "2025-09-01", "end": "2025-09-30"},
- )
- assert r.status_code == 200
- titles = [item.get("title") for item in r.data]
- assert "Event 1" in titles
- assert "Event 2" not in titles
-
- def test_legacy_events_not_mixed_with_calendar_events(
- self, auth_client, admin_user, make_calendar, make_event
- ):
- """Legacy события (без calendar) не смешиваются с календарными событиями."""
- calendar = make_calendar(title="New Calendar")
-
- # Legacy событие (без calendar)
- legacy_event = make_event(title="Legacy Event", start_date="2025-09-10")
- # Событие нового календаря
- calendar_event = make_event(
- title="Calendar Event", calendar=calendar, start_date="2025-09-10"
- )
-
- client = auth_client(admin_user)
-
- # Запрос legacy событий (без calendar_id)
- r1 = client.get(
- "/api/v1/calendar/events/", {"start": "2025-09-01", "end": "2025-09-30"}
- )
- assert r1.status_code == 200
- legacy_titles = [item.get("title") for item in r1.data]
- assert "Legacy Event" in legacy_titles
- assert "Calendar Event" not in legacy_titles
-
- # Запрос событий календаря
- r2 = client.get(
- "/api/v1/calendar/events/",
- {"calendar_id": calendar.id, "start": "2025-09-01", "end": "2025-09-30"},
- )
- assert r2.status_code == 200
- calendar_titles = [item.get("title") for item in r2.data]
- assert "Calendar Event" in calendar_titles
- assert "Legacy Event" not in calendar_titles
-
-
-@pytest.mark.django_db
-class TestCalendarVisibility:
- """Тесты видимости календарей."""
-
- def test_public_calendar_visible_to_all(
- self, auth_client, regular_user, make_calendar
- ):
- """Публичный календарь виден всем."""
- calendar = make_calendar(title="Public", visibility="public")
- client = auth_client(regular_user)
- r = client.get("/api/v1/calendar/calendars/")
- assert r.status_code == 200
- titles = [c.get("title") for c in r.data["results"]]
- assert "Public" in titles
-
- def test_private_calendar_only_visible_to_owner(
- self, auth_client, regular_user, make_calendar, make_user
- ):
- """Приватный календарь виден только владельцу."""
- other_user = make_user(username="other3")
- calendar = make_calendar(
- title="Private", visibility="private", owner_user=other_user
- )
-
- client = auth_client(regular_user)
- r = client.get("/api/v1/calendar/calendars/")
- assert r.status_code == 200
- titles = [c.get("title") for c in r.data["results"]]
- assert "Private" not in titles
-
- def test_department_calendar_visible_to_members(
- self, auth_client, dept_manager_user, make_department, make_calendar
- ):
- """Календарь отдела виден членам отдела."""
- dept = make_department("QA")
- calendar = make_calendar(
- title="QA Calendar", visibility="department", owner_department=dept
- )
-
- # dept_manager_user должен видеть календарь отдела
- client = auth_client(dept_manager_user)
- r = client.get("/api/v1/calendar/calendars/")
- assert r.status_code == 200
- titles = [c.get("title") for c in r.data["results"]]
- # В зависимости от настройки dept_manager_user, может видеть или не видеть
- # Допускаем оба варианта в базовом тесте
-
-
-@pytest.mark.django_db
-class TestCalendarPermissions:
- """Тесты прав доступа к календарям."""
-
- def test_is_owner_method(self, admin_user, make_calendar):
- """Проверка метода is_owner."""
- calendar = make_calendar(title="Owner Test", owner_user=admin_user)
- assert calendar.is_owner(admin_user) is True
-
- def test_can_user_view_public(self, regular_user, make_calendar):
- """Проверка can_user_view для публичного календаря."""
- calendar = make_calendar(title="Public View", visibility="public")
- assert calendar.can_user_view(regular_user) is True
-
- def test_can_user_edit_owner(self, regular_user, make_calendar):
- """Владелец может редактировать календарь."""
- calendar = make_calendar(title="Edit Test", owner_user=regular_user)
- assert calendar.can_user_edit(regular_user) is True
-
- def test_can_user_edit_with_subscription(
- self, regular_user, make_calendar, make_subscription
- ):
- """Подписчик с правом can_edit может редактировать."""
- calendar = make_calendar(title="Edit Sub", visibility="custom")
- subscription = make_subscription(
- calendar=calendar, user=regular_user, can_edit=True
- )
- assert calendar.can_user_edit(regular_user) is True
-
- def test_cannot_edit_without_permission(
- self, regular_user, make_calendar, make_user
- ):
- """Нельзя редактировать без прав."""
- other_user = make_user(username="other4")
- calendar = make_calendar(
- title="No Edit", owner_user=other_user, visibility="private"
- )
- assert calendar.can_user_edit(regular_user) is False
diff --git a/backend/tests/api/v1/calendar_app/test_subscription_permissions.py b/backend/tests/api/v1/calendar_app/test_subscription_permissions.py
deleted file mode 100644
index c2c6a872..00000000
--- a/backend/tests/api/v1/calendar_app/test_subscription_permissions.py
+++ /dev/null
@@ -1,213 +0,0 @@
-"""
-Тесты для прав доступа к подпискам календарей.
-
-Проверяет логику:
-- Владелец календаря может изменять can_edit, can_manage
-- Владелец подписки может изменять is_visible, color_override
-- Другие пользователи получают 403
-"""
-
-import pytest
-from rest_framework import status
-
-
-@pytest.mark.django_db
-class TestSubscriptionPermissions:
- """Тесты прав доступа к обновлению подписок."""
-
- def test_owner_can_change_permissions(self, auth_client, make_user, make_calendar):
- """Владелец календаря может изменять права подписки."""
- owner = make_user(username="owner")
- subscriber = make_user(username="subscriber")
- calendar = make_calendar(owner_user=owner)
-
- # Создаём подписку
- response = auth_client(owner).post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- data={"user_id": subscriber.id, "can_edit": False, "notify": False},
- format="json",
- )
- assert response.status_code == status.HTTP_201_CREATED
- subscription_id = response.data["id"]
-
- # Владелец календаря меняет права
- response = auth_client(owner).patch(
- f"/api/v1/calendar/subscriptions/{subscription_id}/",
- data={"can_edit": True, "can_manage": True},
- format="json",
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data["can_edit"] is True
- assert response.data["can_manage"] is True
-
- def test_subscriber_can_change_personal_settings(
- self, auth_client, make_user, make_calendar
- ):
- """Подписчик может изменять свои личные настройки."""
- owner = make_user(username="owner")
- subscriber = make_user(username="subscriber")
- calendar = make_calendar(owner_user=owner)
-
- # Создаём подписку
- response = auth_client(owner).post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- data={"user_id": subscriber.id, "can_edit": False, "notify": False},
- format="json",
- )
- assert response.status_code == status.HTTP_201_CREATED
- subscription_id = response.data["id"]
-
- # Подписчик меняет видимость
- response = auth_client(subscriber).patch(
- f"/api/v1/calendar/subscriptions/{subscription_id}/",
- data={"is_visible": False},
- format="json",
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data["is_visible"] is False
-
- # Подписчик меняет цвет
- response = auth_client(subscriber).patch(
- f"/api/v1/calendar/subscriptions/{subscription_id}/",
- data={"color_override": "#FF5733"},
- format="json",
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data["color_override"] == "#FF5733"
-
- def test_subscriber_cannot_change_permissions(
- self, auth_client, make_user, make_calendar
- ):
- """Подписчик НЕ может изменять права доступа."""
- owner = make_user(username="owner")
- subscriber = make_user(username="subscriber")
- calendar = make_calendar(owner_user=owner)
-
- # Создаём подписку
- response = auth_client(owner).post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- data={"user_id": subscriber.id, "can_edit": False, "notify": False},
- format="json",
- )
- assert response.status_code == status.HTTP_201_CREATED
- subscription_id = response.data["id"]
-
- # Подписчик пытается изменить права - должен получить 403
- response = auth_client(subscriber).patch(
- f"/api/v1/calendar/subscriptions/{subscription_id}/",
- data={"can_edit": True},
- format="json",
- )
- assert response.status_code == status.HTTP_403_FORBIDDEN
- assert "владелец календаря" in response.data["detail"].lower()
-
- def test_other_user_cannot_change_subscription(
- self, auth_client, make_user, make_calendar
- ):
- """Другой пользователь не может изменять чужую подписку."""
- owner = make_user(username="owner")
- subscriber = make_user(username="subscriber")
- other_user = make_user(username="other")
- calendar = make_calendar(owner_user=owner)
-
- # Создаём подписку
- response = auth_client(owner).post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- data={"user_id": subscriber.id, "can_edit": False, "notify": False},
- format="json",
- )
- assert response.status_code == status.HTTP_201_CREATED
- subscription_id = response.data["id"]
-
- # Другой пользователь пытается изменить is_visible - 404
- # (не видит подписку в get_queryset)
- response = auth_client(other_user).patch(
- f"/api/v1/calendar/subscriptions/{subscription_id}/",
- data={"is_visible": False},
- format="json",
- )
- assert response.status_code == status.HTTP_404_NOT_FOUND
-
- def test_owner_can_change_subscriber_personal_settings(
- self, auth_client, make_user, make_calendar
- ):
- """Владелец календаря может изменять личные настройки подписчиков."""
- owner = make_user(username="owner")
- subscriber = make_user(username="subscriber")
- calendar = make_calendar(owner_user=owner)
-
- # Создаём подписку
- response = auth_client(owner).post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- data={"user_id": subscriber.id, "can_edit": False, "notify": False},
- format="json",
- )
- assert response.status_code == status.HTTP_201_CREATED
- subscription_id = response.data["id"]
-
- # Владелец может изменить is_visible подписчика
- response = auth_client(owner).patch(
- f"/api/v1/calendar/subscriptions/{subscription_id}/",
- data={"is_visible": False, "color_override": "#123456"},
- format="json",
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data["is_visible"] is False
- assert response.data["color_override"] == "#123456"
-
- def test_combined_changes_by_owner(self, auth_client, make_user, make_calendar):
- """Владелец может изменять права И личные настройки одновременно."""
- owner = make_user(username="owner")
- subscriber = make_user(username="subscriber")
- calendar = make_calendar(owner_user=owner)
-
- # Создаём подписку
- response = auth_client(owner).post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- data={"user_id": subscriber.id, "can_edit": False, "notify": False},
- format="json",
- )
- assert response.status_code == status.HTTP_201_CREATED
- subscription_id = response.data["id"]
-
- # Владелец меняет всё разом
- response = auth_client(owner).patch(
- f"/api/v1/calendar/subscriptions/{subscription_id}/",
- data={
- "can_edit": True,
- "can_manage": True,
- "is_visible": False,
- "color_override": "#ABCDEF",
- },
- format="json",
- )
- assert response.status_code == status.HTTP_200_OK
- assert response.data["can_edit"] is True
- assert response.data["can_manage"] is True
- assert response.data["is_visible"] is False
- assert response.data["color_override"] == "#ABCDEF"
-
- def test_subscriber_can_only_change_own_settings(
- self, auth_client, make_user, make_calendar
- ):
- """Подписчик может изменять только свои настройки, не может права."""
- owner = make_user(username="owner")
- subscriber = make_user(username="subscriber")
- calendar = make_calendar(owner_user=owner)
-
- # Создаём подписку
- response = auth_client(owner).post(
- f"/api/v1/calendar/calendars/{calendar.id}/invite/",
- data={"user_id": subscriber.id, "can_edit": False, "notify": False},
- format="json",
- )
- assert response.status_code == status.HTTP_201_CREATED
- subscription_id = response.data["id"]
-
- # Попытка изменить is_visible + can_edit - должна провалиться
- response = auth_client(subscriber).patch(
- f"/api/v1/calendar/subscriptions/{subscription_id}/",
- data={"is_visible": False, "can_edit": True},
- format="json",
- )
- assert response.status_code == status.HTTP_403_FORBIDDEN
diff --git a/backend/tests/test_calendar_permissions.py b/backend/tests/test_calendar_permissions.py
deleted file mode 100644
index fc71953e..00000000
--- a/backend/tests/test_calendar_permissions.py
+++ /dev/null
@@ -1,300 +0,0 @@
-"""
-Тесты для проверки прав доступа к календарям.
-
-Проверяет исправление бага, где обычные пользователи не могли
-создавать события в глобальных календарях с правом can_edit.
-"""
-
-from datetime import date
-
-import pytest
-from calendar_app.models import Calendar, CalendarSubscription, CalendarVisibility
-from django.contrib.auth import get_user_model
-from rest_framework import status
-from rest_framework.test import APIClient
-
-User = get_user_model()
-
-
-@pytest.fixture
-def api_client():
- """API клиент."""
- return APIClient()
-
-
-@pytest.fixture
-def admin_user(db):
- """Создаёт администратора."""
- return User.objects.create_superuser(
- username="admin", email="admin@test.com", password="admin123"
- )
-
-
-@pytest.fixture
-def regular_user(db):
- """Создаёт обычного пользователя."""
- return User.objects.create_user(
- username="user",
- email="user@test.com",
- password="user123",
- phone_number="+79001234567",
- send_activation_email=False,
- )
-
-
-@pytest.fixture
-def global_calendar(db, admin_user):
- """Создаёт глобальный календарь."""
- return Calendar.objects.create(
- title="Общий календарь",
- description="Календарь для всех",
- visibility=CalendarVisibility.PUBLIC,
- default_can_edit=False, # По умолчанию не могут редактировать
- created_by=admin_user,
- )
-
-
-@pytest.mark.django_db
-class TestGlobalCalendarPermissions:
- """Тесты прав доступа для глобальных календарей."""
-
- def test_admin_can_create_event_in_global_calendar(
- self, api_client, admin_user, global_calendar
- ):
- """Админ может создавать события в глобальном календаре."""
- api_client.force_authenticate(user=admin_user)
-
- response = api_client.post(
- "/api/v1/calendar/events/",
- {
- "calendar_id": global_calendar.id,
- "title": "Встреча админов",
- "start_date": "2026-03-01",
- "all_day": True,
- },
- format="json",
- )
-
- assert response.status_code == status.HTTP_201_CREATED
- assert response.data["title"] == "Встреча админов"
-
- def test_regular_user_cannot_create_without_permission(
- self, api_client, regular_user, global_calendar
- ):
- """Обычный пользователь НЕ может создавать без прав."""
- api_client.force_authenticate(user=regular_user)
-
- response = api_client.post(
- "/api/v1/calendar/events/",
- {
- "calendar_id": global_calendar.id,
- "title": "Попытка создать",
- "start_date": "2026-03-01",
- "all_day": True,
- },
- format="json",
- )
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
- def test_regular_user_can_create_with_subscription(
- self, api_client, regular_user, global_calendar
- ):
- """Обычный пользователь МОЖЕТ создавать с подпиской can_edit=True."""
- # Создаём подписку с правом редактирования
- CalendarSubscription.objects.create(
- calendar=global_calendar, user=regular_user, can_edit=True
- )
-
- api_client.force_authenticate(user=regular_user)
-
- response = api_client.post(
- "/api/v1/calendar/events/",
- {
- "calendar_id": global_calendar.id,
- "title": "Событие пользователя",
- "start_date": "2026-03-01",
- "all_day": True,
- },
- format="json",
- )
-
- assert response.status_code == status.HTTP_201_CREATED
- assert response.data["title"] == "Событие пользователя"
-
- def test_regular_user_cannot_create_with_readonly_subscription(
- self, api_client, regular_user, global_calendar
- ):
- """Пользователь НЕ может создавать с подпиской can_edit=False."""
- # Создаём подписку БЕЗ права редактирования
- CalendarSubscription.objects.create(
- calendar=global_calendar, user=regular_user, can_edit=False
- )
-
- api_client.force_authenticate(user=regular_user)
-
- response = api_client.post(
- "/api/v1/calendar/events/",
- {
- "calendar_id": global_calendar.id,
- "title": "Попытка создать",
- "start_date": "2026-03-01",
- "all_day": True,
- },
- format="json",
- )
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
- def test_regular_user_can_create_when_default_can_edit_true(
- self, api_client, regular_user, admin_user
- ):
- """Пользователь МОЖЕТ создавать если default_can_edit=True."""
- # Создаём календарь с default_can_edit=True
- calendar = Calendar.objects.create(
- title="Открытый календарь",
- visibility=CalendarVisibility.PUBLIC,
- default_can_edit=True, # ✅ Все могут редактировать
- created_by=admin_user,
- )
-
- # Подписываем пользователя (обязательно для доступа)
- CalendarSubscription.objects.create(
- calendar=calendar, user=regular_user, can_edit=True
- )
-
- api_client.force_authenticate(user=regular_user)
-
- response = api_client.post(
- "/api/v1/calendar/events/",
- {
- "calendar_id": calendar.id,
- "title": "Открытое событие",
- "start_date": "2026-03-01",
- "all_day": True,
- },
- format="json",
- )
-
- assert response.status_code == status.HTTP_201_CREATED
- assert response.data["title"] == "Открытое событие"
-
-
-@pytest.mark.django_db
-class TestLegacyCalendarPermissions:
- """Тесты обратной совместимости с legacy календарями."""
-
- def test_regular_user_cannot_create_in_company_calendar(
- self, api_client, regular_user
- ):
- """Обычный пользователь НЕ может создавать в календаре компании (legacy)."""
- api_client.force_authenticate(user=regular_user)
-
- response = api_client.post(
- "/api/v1/calendar/events/",
- {
- # Без calendar_id, department_id, employee_id = календарь компании
- "title": "Попытка создать",
- "start_date": "2026-03-01",
- "all_day": True,
- },
- format="json",
- )
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
- def test_admin_can_create_in_company_calendar(self, api_client, admin_user):
- """Админ МОЖЕТ создавать в календаре компании (legacy)."""
- api_client.force_authenticate(user=admin_user)
-
- response = api_client.post(
- "/api/v1/calendar/events/",
- {
- "title": "Собрание компании",
- "start_date": "2026-03-01",
- "all_day": True,
- },
- format="json",
- )
-
- assert response.status_code == status.HTTP_201_CREATED
- assert response.data["title"] == "Собрание компании"
-
- def test_user_can_create_in_personal_calendar(self, api_client, regular_user):
- """Пользователь МОЖЕТ создавать в своём личном календаре."""
- api_client.force_authenticate(user=regular_user)
-
- response = api_client.post(
- "/api/v1/calendar/events/",
- {
- "employee_id": regular_user.id,
- "title": "Личное событие",
- "start_date": "2026-03-01",
- "all_day": True,
- },
- format="json",
- )
-
- assert response.status_code == status.HTTP_201_CREATED
- assert response.data["title"] == "Личное событие"
-
-
-@pytest.mark.django_db
-class TestEventUpdateDeletePermissions:
- """Тесты прав на обновление и удаление событий."""
-
- def test_user_can_update_own_event_in_global_calendar(
- self, api_client, regular_user, global_calendar
- ):
- """Пользователь МОЖЕТ обновлять своё событие если есть права."""
- from calendar_app.models import CalendarEvent
-
- # Даём права
- CalendarSubscription.objects.create(
- calendar=global_calendar, user=regular_user, can_edit=True
- )
-
- # Создаём событие
- event = CalendarEvent.objects.create(
- calendar=global_calendar,
- title="Моё событие",
- start_date=date(2026, 3, 1),
- all_day=True,
- created_by=regular_user,
- )
-
- api_client.force_authenticate(user=regular_user)
-
- response = api_client.patch(
- f"/api/v1/calendar/events/{event.id}/",
- {"title": "Обновлённое событие"},
- format="json",
- )
-
- assert response.status_code == status.HTTP_200_OK
- assert response.data["title"] == "Обновлённое событие"
-
- def test_user_cannot_update_without_permission(
- self, api_client, regular_user, global_calendar, admin_user
- ):
- """Пользователь НЕ может обновлять без прав."""
- from calendar_app.models import CalendarEvent
-
- # Создаём событие БЕЗ прав у пользователя
- event = CalendarEvent.objects.create(
- calendar=global_calendar,
- title="Событие админа",
- start_date=date(2026, 3, 1),
- all_day=True,
- created_by=admin_user,
- )
-
- api_client.force_authenticate(user=regular_user)
-
- response = api_client.patch(
- f"/api/v1/calendar/events/{event.id}/",
- {"title": "Попытка обновить"},
- format="json",
- )
-
- assert response.status_code == status.HTTP_403_FORBIDDEN