656 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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