From ef734f19c0a3a39b2a2c0db4f9c8120268f1b515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 11 Feb 2026 08:12:28 +0300 Subject: [PATCH] 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 --- backend/calendar_app/admin.py | 59 +++- backend/calendar_app/admin_calendar.py | 134 ++++++++ backend/calendar_app/admin_subscription.py | 139 +++++++++ ...on_alter_calendarevent_options_and_more.py | 124 ++++++++ backend/calendar_app/models.py | 289 ++++++++++++++++-- 5 files changed, 715 insertions(+), 30 deletions(-) create mode 100644 backend/calendar_app/admin_calendar.py create mode 100644 backend/calendar_app/admin_subscription.py create mode 100644 backend/calendar_app/migrations/0011_calendarsubscription_alter_calendarevent_options_and_more.py diff --git a/backend/calendar_app/admin.py b/backend/calendar_app/admin.py index a814eb0e..516b363e 100644 --- a/backend/calendar_app/admin.py +++ b/backend/calendar_app/admin.py @@ -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] + diff --git a/backend/calendar_app/admin_calendar.py b/backend/calendar_app/admin_calendar.py new file mode 100644 index 00000000..2ce6813b --- /dev/null +++ b/backend/calendar_app/admin_calendar.py @@ -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'
{obj.color}' + ) + + color_swatch.allow_tags = True # type: ignore[attr-defined] + + @admin.display(description=_("События")) + def event_count(self, obj: Calendar) -> int: + """Возвращает количество событий в календаре.""" + return obj.events.count() + + @admin.display(description=_("Подписчики")) + def subscriber_count(self, obj: Calendar) -> int: + """Возвращает количество подписчиков календаря.""" + return obj.subscriptions.count() + + def save_model( + self, request: HttpRequest, obj: Calendar, form, change: bool + ) -> None: + """Валидация перед сохранением. + + Args: + request: Текущий запрос. + obj: Объект календаря. + form: Форма админки. + change: True, если редактирование. + """ + # Проставляем created_by при создании + if not change and obj.created_by_id is None: + obj.created_by = request.user + + super().save_model(request, obj, form, change) + diff --git a/backend/calendar_app/admin_subscription.py b/backend/calendar_app/admin_subscription.py new file mode 100644 index 00000000..c42ef6d8 --- /dev/null +++ b/backend/calendar_app/admin_subscription.py @@ -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'
{color}' + ) + + color_display.allow_tags = True # type: ignore[attr-defined] + + def save_model( + self, + request: HttpRequest, + obj: CalendarSubscription, + form, + change: bool, + ) -> None: + """Валидация перед сохранением. + + Args: + request: Текущий запрос. + obj: Объект подписки. + form: Форма админки. + change: True, если редактирование. + """ + # Проверка: can_manage требует can_edit + if obj.can_manage and not obj.can_edit: + from django.core.exceptions import ValidationError + + raise ValidationError( + _("can_manage требует can_edit=True") + ) + + super().save_model(request, obj, form, change) + + +class CalendarSubscriptionInline(admin.TabularInline): + """Инлайн для отображения подписок в календаре.""" + + model = CalendarSubscription + extra = 0 + raw_id_fields = ("user",) + fields = ( + "user", + "is_visible", + "can_edit", + "can_manage", + "color_override", + "subscribed_at", + ) + readonly_fields = ("subscribed_at",) diff --git a/backend/calendar_app/migrations/0011_calendarsubscription_alter_calendarevent_options_and_more.py b/backend/calendar_app/migrations/0011_calendarsubscription_alter_calendarevent_options_and_more.py new file mode 100644 index 00000000..50584204 --- /dev/null +++ b/backend/calendar_app/migrations/0011_calendarsubscription_alter_calendarevent_options_and_more.py @@ -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')}, + ), + ] diff --git a/backend/calendar_app/models.py b/backend/calendar_app/models.py index e78e5f58..aca40254 100644 --- a/backend/calendar_app/models.py +++ b/backend/calendar_app/models.py @@ -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: