From b0e88e8e8b7d4aec7fe0cf5b4e6daca25b63d8f1 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 10 Mar 2026 15:21:28 +0300 Subject: [PATCH] refactor(backend): migrate calendar_app to scheduling app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename calendar_app → scheduling (integration app for django-scheduler) - Move schedule_patch.py → scheduling/patch.py with improved logging - Create scheduling.notifications system for django-scheduler events - Add scheduling.rules for django-rules integration - Remove unused calendar_app models and API (not registered in urls) - Remove backend/api/v1/calendar/ (dead code) - Remove old tests for calendar_app - Add comprehensive documentation in scheduling/README.md - Update settings.py: calendar_app.apps.CalendarAppConfig → scheduling.apps.SchedulingConfig - Clean api/apps.py: remove schedule_patch import (now in scheduling.apps) The scheduling app provides: - Patches for django-scheduler bugs (byweekday fix) - Notification system for calendar events - Access rules via django-rules - Auto-applies on Django startup Closes integration of django-scheduler for EUSRR calendar system. --- backend/api/apps.py | 5 +- backend/api/v1/calendar/__init__.py | 0 backend/api/v1/calendar/serializers.py | 707 ----------- backend/api/v1/calendar/utils.py | 64 - backend/api/v1/calendar/views.py | 1095 ----------------- backend/calendar_app/__init__.py | 0 backend/calendar_app/admin.py | 306 ----- backend/calendar_app/admin_calendar.py | 159 --- backend/calendar_app/admin_subscription.py | 152 --- backend/calendar_app/apps.py | 45 - backend/calendar_app/cache.py | 74 -- .../migrate_birthdays_to_scheduler.py | 110 -- .../calendar_app/migrations/0001_initial.py | 33 - ...rtmentevent_companyevent_color_and_more.py | 94 -- .../0003_alter_departmentevent_options.py | 17 - ...nt_dept_event_date_range_valid_and_more.py | 38 - ...5_event_delete_departmentevent_and_more.py | 65 - ...006_rename_event_calendarevent_and_more.py | 47 - ...07_alter_calendarevent_options_and_more.py | 64 - .../0008_calendarevent_source_and_more.py | 29 - ...09_alter_calendarevent_options_and_more.py | 21 - .../0010_calendarevent_employee_and_more.py | 27 - ...on_alter_calendarevent_options_and_more.py | 124 -- ...ter_calendarsubscription_color_override.py | 18 - .../0013_migrate_to_django_scheduler.py | 559 --------- backend/calendar_app/migrations/__init__.py | 0 backend/calendar_app/models.py | 655 ---------- .../calendar_app/notification_signals.py.old | 218 ---- .../calendar_app/notifications/__init__.py | 37 - .../calendar_app/notifications/handlers.py | 174 --- backend/calendar_app/notifications/signals.py | 75 -- backend/calendar_app/rules.py | 376 ------ backend/calendar_app/services/__init__.py | 7 - backend/calendar_app/services/recurrence.py | 261 ---- backend/calendar_app/signals.py | 156 --- .../CALENDAR_APP_TO_SCHEDULING_MIGRATION.md | 245 ++++ backend/eusrr_backend/settings.py | 2 +- backend/scheduling/README.md | 117 ++ backend/scheduling/__init__.py | 10 + backend/scheduling/apps.py | 34 + backend/scheduling/migrations/0001_initial.py | 24 + backend/scheduling/migrations/__init__.py | 34 + .../notifications/__init__.py} | 80 +- backend/scheduling/notifications/config.py | 24 + backend/scheduling/notifications/handlers.py | 224 ++++ backend/scheduling/notifications/signals.py | 87 ++ .../patch.py} | 34 +- backend/scheduling/rules.py | 114 ++ backend/tests/api/v1/calendar_app/conftest.py | 402 ------ .../api/v1/calendar_app/test_calendar_api.py | 448 ------- .../v1/calendar_app/test_invitation_api.py | 735 ----------- .../v1/calendar_app/test_new_calendar_api.py | 404 ------ .../test_subscription_permissions.py | 213 ---- backend/tests/test_calendar_permissions.py | 300 ----- 54 files changed, 983 insertions(+), 8360 deletions(-) delete mode 100644 backend/api/v1/calendar/__init__.py delete mode 100644 backend/api/v1/calendar/serializers.py delete mode 100644 backend/api/v1/calendar/utils.py delete mode 100644 backend/api/v1/calendar/views.py delete mode 100644 backend/calendar_app/__init__.py delete mode 100644 backend/calendar_app/admin.py delete mode 100644 backend/calendar_app/admin_calendar.py delete mode 100644 backend/calendar_app/admin_subscription.py delete mode 100644 backend/calendar_app/apps.py delete mode 100644 backend/calendar_app/cache.py delete mode 100644 backend/calendar_app/management/commands/migrate_birthdays_to_scheduler.py delete mode 100644 backend/calendar_app/migrations/0001_initial.py delete mode 100644 backend/calendar_app/migrations/0002_departmentevent_companyevent_color_and_more.py delete mode 100644 backend/calendar_app/migrations/0003_alter_departmentevent_options.py delete mode 100644 backend/calendar_app/migrations/0004_remove_departmentevent_dept_event_date_range_valid_and_more.py delete mode 100644 backend/calendar_app/migrations/0005_event_delete_departmentevent_and_more.py delete mode 100644 backend/calendar_app/migrations/0006_rename_event_calendarevent_and_more.py delete mode 100644 backend/calendar_app/migrations/0007_alter_calendarevent_options_and_more.py delete mode 100644 backend/calendar_app/migrations/0008_calendarevent_source_and_more.py delete mode 100644 backend/calendar_app/migrations/0009_alter_calendarevent_options_and_more.py delete mode 100644 backend/calendar_app/migrations/0010_calendarevent_employee_and_more.py delete mode 100644 backend/calendar_app/migrations/0011_calendarsubscription_alter_calendarevent_options_and_more.py delete mode 100644 backend/calendar_app/migrations/0012_alter_calendarsubscription_color_override.py delete mode 100644 backend/calendar_app/migrations/0013_migrate_to_django_scheduler.py delete mode 100644 backend/calendar_app/migrations/__init__.py delete mode 100644 backend/calendar_app/models.py delete mode 100644 backend/calendar_app/notification_signals.py.old delete mode 100644 backend/calendar_app/notifications/__init__.py delete mode 100644 backend/calendar_app/notifications/handlers.py delete mode 100644 backend/calendar_app/notifications/signals.py delete mode 100644 backend/calendar_app/rules.py delete mode 100644 backend/calendar_app/services/__init__.py delete mode 100644 backend/calendar_app/services/recurrence.py delete mode 100644 backend/calendar_app/signals.py create mode 100644 backend/docs/completed/CALENDAR_APP_TO_SCHEDULING_MIGRATION.md create mode 100644 backend/scheduling/README.md create mode 100644 backend/scheduling/__init__.py create mode 100644 backend/scheduling/apps.py create mode 100644 backend/scheduling/migrations/0001_initial.py create mode 100644 backend/scheduling/migrations/__init__.py rename backend/{calendar_app/notifications/config.py => scheduling/notifications/__init__.py} (60%) create mode 100644 backend/scheduling/notifications/config.py create mode 100644 backend/scheduling/notifications/handlers.py create mode 100644 backend/scheduling/notifications/signals.py rename backend/{schedule_patch.py => scheduling/patch.py} (81%) create mode 100644 backend/scheduling/rules.py delete mode 100644 backend/tests/api/v1/calendar_app/conftest.py delete mode 100644 backend/tests/api/v1/calendar_app/test_calendar_api.py delete mode 100644 backend/tests/api/v1/calendar_app/test_invitation_api.py delete mode 100644 backend/tests/api/v1/calendar_app/test_new_calendar_api.py delete mode 100644 backend/tests/api/v1/calendar_app/test_subscription_permissions.py delete mode 100644 backend/tests/test_calendar_permissions.py 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 %} -
- {% csrf_token %} - -
- {% 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