refactor(backend): migrate calendar_app to scheduling app
- 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.
This commit is contained in:
parent
4d985990f5
commit
b0e88e8e8b
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@ -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(
|
||||
'<span style="display:inline-block;width:12px;height:12px;'
|
||||
"border-radius:2px;border:1px solid #ccc;vertical-align:-2px;"
|
||||
'background:{};margin-right:6px;"></span><code>{}</code>',
|
||||
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]
|
||||
@ -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'<div style="width:20px;height:20px;background-color:{obj.color};'
|
||||
f'border:1px solid #ccc;display:inline-block;"></div> {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)
|
||||
@ -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'<div style="width:20px;height:20px;background-color:{color};'
|
||||
f'border:1px solid #ccc;display:inline-block;"></div> {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",)
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
@ -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')
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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': 'События отделов'},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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:<id>: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'),
|
||||
),
|
||||
]
|
||||
@ -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',
|
||||
),
|
||||
]
|
||||
@ -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='Отдел'),
|
||||
),
|
||||
]
|
||||
@ -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')},
|
||||
),
|
||||
]
|
||||
@ -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='Свой цвет'),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
),
|
||||
]
|
||||
@ -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:<id>: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)
|
||||
@ -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'
|
||||
@ -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',
|
||||
]
|
||||
@ -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,
|
||||
}
|
||||
)
|
||||
@ -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)
|
||||
@ -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 %}
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#eventModal">
|
||||
Создать событие
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% for event in events %}
|
||||
<div class="event">
|
||||
<h4>{{ event.title }}</h4>
|
||||
<p>{{ event.start|date:"d.m.Y H:i" }} - {{ event.end|date:"d.m.Y H:i" }}</p>
|
||||
|
||||
{% has_rule 'calendar_app.change_event' user event as can_edit %}
|
||||
{% if can_edit %}
|
||||
<a href="{% url 'calendar_app:event_edit' event.pk %}" class="btn btn-sm btn-primary">
|
||||
Редактировать
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% has_rule 'calendar_app.delete_event' user event as can_delete %}
|
||||
{% if can_delete %}
|
||||
<form method="post" action="{% url 'calendar_app:event_delete' event.pk %}"
|
||||
onsubmit="return confirm('Удалить событие?')">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Удалить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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)
|
||||
"""
|
||||
@ -1,7 +0,0 @@
|
||||
"""
|
||||
Сервисный слой для calendar_app.
|
||||
|
||||
Note: Birthday event services были перенесены в employees.services
|
||||
"""
|
||||
|
||||
__all__ = []
|
||||
@ -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
|
||||
@ -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:<id>: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()
|
||||
245
backend/docs/completed/CALENDAR_APP_TO_SCHEDULING_MIGRATION.md
Normal file
245
backend/docs/completed/CALENDAR_APP_TO_SCHEDULING_MIGRATION.md
Normal file
@ -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] Создана документация
|
||||
@ -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",
|
||||
|
||||
117
backend/scheduling/README.md
Normal file
117
backend/scheduling/README.md
Normal file
@ -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 %}
|
||||
<button>Редактировать</button>
|
||||
{% 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 г.
|
||||
10
backend/scheduling/__init__.py
Normal file
10
backend/scheduling/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""
|
||||
Приложение scheduling - интеграция и расширение django-scheduler.
|
||||
|
||||
Содержит:
|
||||
- Патчи для исправления багов django-scheduler
|
||||
- Систему уведомлений для событий календаря
|
||||
- Правила доступа (django-rules)
|
||||
"""
|
||||
|
||||
default_app_config = 'scheduling.apps.SchedulingConfig'
|
||||
34
backend/scheduling/apps.py
Normal file
34
backend/scheduling/apps.py
Normal file
@ -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
|
||||
24
backend/scheduling/migrations/0001_initial.py
Normal file
24
backend/scheduling/migrations/0001_initial.py
Normal file
@ -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 не ругался
|
||||
]
|
||||
34
backend/scheduling/migrations/__init__.py
Normal file
34
backend/scheduling/migrations/__init__.py
Normal file
@ -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=[
|
||||
# Откат невозможен - данные удалены
|
||||
# Восстанавливать нужно из бэкапа
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
24
backend/scheduling/notifications/config.py
Normal file
24
backend/scheduling/notifications/config.py
Normal file
@ -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',
|
||||
]
|
||||
224
backend/scheduling/notifications/handlers.py
Normal file
224
backend/scheduling/notifications/handlers.py
Normal file
@ -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)
|
||||
87
backend/scheduling/notifications/signals.py
Normal file
87
backend/scheduling/notifications/signals.py
Normal file
@ -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)
|
||||
@ -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
|
||||
|
||||
114
backend/scheduling/rules.py
Normal file
114
backend/scheduling/rules.py
Normal file
@ -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 %}
|
||||
# <button>Edit Calendar</button>
|
||||
# {% endif %}
|
||||
@ -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
|
||||
@ -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
|
||||
# допускаем отсутствие такого события
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user