feat(calendar): add optional Calendar and CalendarSubscription models

- Add Calendar model with visibility settings and auto-subscription
- Add CalendarSubscription model for user subscriptions with permissions
- Add optional 'calendar' field to CalendarEvent (null=True for backward compatibility)
- Update CalendarEvent logic: if calendar=NULL uses legacy (department/employee), if calendar set uses new logic
- Add admin interfaces for Calendar and CalendarSubscription
- Add inline subscription editor in Calendar admin
- Migration 0011: create new tables and add calendar FK to events
- Full backward compatibility: existing code works without changes
This commit is contained in:
admin i 2026-02-11 08:12:28 +03:00
parent c6c0adbd31
commit ef734f19c0
5 changed files with 715 additions and 30 deletions

View File

@ -9,7 +9,12 @@ from django.http import HttpRequest
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from .models import CalendarEvent, Recurrence
from .admin_calendar import CalendarAdmin # noqa: F401
from .admin_subscription import ( # noqa: F401
CalendarSubscriptionAdmin,
CalendarSubscriptionInline,
)
from .models import Calendar, CalendarEvent, CalendarSubscription, Recurrence
class ScopeListFilter(admin.SimpleListFilter):
@ -85,6 +90,7 @@ class CalendarEventAdmin(admin.ModelAdmin):
list_display = (
"id",
"title",
"calendar_display",
"scope_display",
"start_date",
"end_date",
@ -96,36 +102,49 @@ class CalendarEventAdmin(admin.ModelAdmin):
"created_by",
"created_at",
)
list_select_related = ("department", "created_by")
list_select_related = ("calendar", "department", "employee", "created_by")
list_per_page = 50
ordering = ("department_id", "start_date", "start_time")
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, # <— ПЕРЕДАЁМ КЛАСС, НЕ СТРОКУ
HasTimeListFilter,
ScopeListFilter,
)
date_hierarchy = "start_date"
# ===== Поля формы =====
readonly_fields = ("created_at",)
raw_id_fields = ("department", "created_by")
raw_id_fields = ("calendar", "department", "employee", "created_by")
fieldsets = (
(_("Основное"), {
"fields": ("title", "description", "department", "location"),
(_("Календарь"), {
"fields": ("calendar",),
"description": _(
"Если указан календарь, поля department и employee "
"игнорируются (новый режим)"
),
}),
(_("Основное (LEGACY)"), {
"fields": ("title", "description", "department", "employee", "location"),
"description": _("Department/Employee используются только если calendar=NULL"),
}),
(_("Даты и время"), {
"fields": (
@ -145,9 +164,16 @@ class CalendarEventAdmin(admin.ModelAdmin):
# ===== Экшены =====
actions = ("make_all_day", "clear_time", "set_annual", "set_one_time")
@admin.display(description=_("Область"), ordering="department_id")
@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.
@ -155,7 +181,13 @@ class CalendarEventAdmin(admin.ModelAdmin):
Returns:
str: Человекочитаемое имя области.
"""
return _("Компания") if obj.department_id is None else str(obj.department)
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:
@ -224,3 +256,8 @@ class CalendarEventAdmin(admin.ModelAdmin):
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

@ -0,0 +1,134 @@
"""Админка для модели 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

@ -0,0 +1,139 @@
"""Админка для модели 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

@ -0,0 +1,124 @@
# 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

@ -23,15 +23,220 @@ class Recurrence(models.TextChoices):
ANNUAL = "annual", _("Ежегодно")
class CalendarEvent(models.Model):
"""Событие календаря (компании, отдела или сотрудника) с поддержкой повторяемости.
class CalendarVisibility(models.TextChoices):
"""Видимость календаря."""
PUBLIC = "public", _("Публичный (все видят)")
DEPARTMENT = "department", _("Отдел (только члены отдела)")
PRIVATE = "private", _("Приватный (только владелец)")
CUSTOM = "custom", _("Настраиваемый (через права)")
Если `department` = NULL и `employee` = NULL → событие компании (глобальное).
Если `department` задан и `employee` = NULL → событие конкретного отдела.
Если `employee` задан и `department` = NULL → личное событие сотрудника.
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)
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)
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,
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,
@ -39,7 +244,7 @@ class CalendarEvent(models.Model):
verbose_name=_("Отдел"),
null=True,
blank=True,
help_text=_("Пусто — событие компании или сотрудника; задано — событие отдела."),
help_text=_("LEGACY: используется если calendar=NULL"),
)
employee = models.ForeignKey(
@ -49,7 +254,7 @@ class CalendarEvent(models.Model):
verbose_name=_("Сотрудник"),
null=True,
blank=True,
help_text=_("Если задано — личное событие сотрудника."),
help_text=_("LEGACY: используется если calendar=NULL"),
)
# Основное
@ -127,10 +332,15 @@ class CalendarEvent(models.Model):
class Meta:
verbose_name = _("Событие календаря")
verbose_name_plural = _("События календаря")
ordering = ["department", "start_date", "start_time"]
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"]),
@ -144,6 +354,14 @@ class CalendarEvent(models.Model):
]
def __str__(self) -> str:
# НОВОЕ: Приоритет calendar
if self.calendar_id:
return (
f"[{self.calendar.title}] {self.title} "
f"({self.start_date:%d.%m.%Y})"
)
# LEGACY: Старая логика
if self.employee_id:
scope = f"Личный ({self.employee})"
elif self.department_id:
@ -152,17 +370,36 @@ class CalendarEvent(models.Model):
scope = _("Компания")
if self.end_date:
return f"{scope}: {self.title} ({self.start_date:%d.%m.%Y}–{self.end_date:%d.%m.%Y})"
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 если событие глобальное (без отдела и сотрудника)."""
return self.department_id is None and self.employee_id is None
"""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 если событие личное (принадлежит конкретному сотруднику)."""
"""True если событие личное (LEGACY или calendar.is_personal)."""
if self.calendar_id:
return self.calendar.is_personal
return self.employee_id is not None
@property
@ -175,13 +412,27 @@ class CalendarEvent(models.Model):
"""Валидирует согласованность дат/времени и повторяемости.
Raises:
ValidationError: При некорректных датах/времени или настройках повторения.
ValidationError: При некорректных датах/времени или настройках.
"""
# Проверка взаимоисключающих полей: department и employee
if self.department_id and self.employee_id:
raise 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: