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:
parent
c6c0adbd31
commit
ef734f19c0
@ -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]
|
||||
|
||||
|
||||
134
backend/calendar_app/admin_calendar.py
Normal file
134
backend/calendar_app/admin_calendar.py
Normal 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)
|
||||
|
||||
139
backend/calendar_app/admin_subscription.py
Normal file
139
backend/calendar_app/admin_subscription.py
Normal 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",)
|
||||
@ -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')},
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user