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:
lizerk 2026-03-10 15:21:28 +03:00
parent 4d985990f5
commit b0e88e8e8b
54 changed files with 983 additions and 8360 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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)

View File

@ -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",)

View File

@ -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

View File

@ -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")

View File

@ -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')

View File

@ -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'],
},
),
]

View File

@ -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'),
),
]

View File

@ -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': 'События отделов'},
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

@ -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='Отдел'),
),
]

View File

@ -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')},
),
]

View File

@ -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='Свой цвет'),
),
]

View File

@ -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
),
]

View File

@ -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)

View File

@ -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'

View File

@ -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',
]

View File

@ -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,
}
)

View File

@ -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)

View File

@ -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)
"""

View File

@ -1,7 +0,0 @@
"""
Сервисный слой для calendar_app.
Note: Birthday event services были перенесены в employees.services
"""
__all__ = []

View File

@ -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

View File

@ -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()

View 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] Создана документация

View File

@ -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",

View 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 г.

View File

@ -0,0 +1,10 @@
"""
Приложение scheduling - интеграция и расширение django-scheduler.
Содержит:
- Патчи для исправления багов django-scheduler
- Систему уведомлений для событий календаря
- Правила доступа (django-rules)
"""
default_app_config = 'scheduling.apps.SchedulingConfig'

View 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

View 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 не ругался
]

View 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=[
# Откат невозможен - данные удалены
# Восстанавливать нужно из бэкапа
],
),
]

View File

@ -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:

View 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',
]

View 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)

View 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)

View File

@ -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
View 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 %}

View File

@ -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

View File

@ -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
# допускаем отсутствие такого события

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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