39 KiB
39 KiB
Архитектура: Календари как настраиваемые сущности
Дата: 11 февраля 2026 г.
Задача: Реализация календарей как отдельных настраиваемых объектов с гибким управлением доступом
📋 Проблема текущей архитектуры
Текущая реализация:
CalendarEvent:
- department FK (может быть NULL)
- employee FK (может быть NULL)
- title, description, dates, ...
Ограничения:
- ❌ Календари не существуют как отдельные объекты
- ❌ Нельзя создать несколько глобальных календарей ("Праздники", "Обучение", "Корпоративы")
- ❌ Нельзя настроить права доступа к конкретному календарю
- ❌ Нельзя дать отделу доступ к календарю другого отдела
- ❌ Нет возможности подписки пользователей на календари
- ❌ Автоматическое создание при регистрации/создании отдела неявное
Требуемая функциональность:
1. Множественные глобальные календари
✅ "Праздники компании" (админы)
✅ "Корпоративные мероприятия" (HR)
✅ "Обучающие курсы" (Учебный отдел)
✅ "Релизы продуктов" (Разработка)
2. Настраиваемый доступ к календарям
✅ Просмотр: все сотрудники / конкретные отделы / конкретные пользователи
✅ Редактирование: админы / владельцы / группа редакторов
✅ Подписка: пользователи могут подписаться/отписаться
3. Календари отделов с делегированием
✅ Отдел может создать несколько календарей ("Планёрки", "Дедлайны")
✅ Отдел может предоставить доступ другим отделам
✅ Руководитель может делегировать управление календарём
4. Личные календари с расшариванием
✅ Пользователь может создать несколько личных календарей
✅ Можно расшарить календарь коллегам (read-only или read-write)
🏗️ Новая архитектура
Модель: Calendar (Календарь)
class CalendarType(models.TextChoices):
"""Тип календаря."""
GLOBAL = "global", _("Глобальный")
DEPARTMENT = "department", _("Отдела")
PERSONAL = "personal", _("Личный")
SHARED = "shared", _("Общий (расшаренный)")
class Calendar(models.Model):
"""Календарь как отдельная сущность с настраиваемым доступом."""
# Основное
title = models.CharField(_("Название"), max_length=200)
description = models.TextField(_("Описание"), blank=True)
color = models.CharField(_("Цвет"), max_length=7, default="#0d6efd")
# Тип и владение
calendar_type = models.CharField(
_("Тип календаря"),
max_length=20,
choices=CalendarType.choices,
default=CalendarType.PERSONAL,
)
# Владельцы (взаимоисключающие для GLOBAL/DEPARTMENT/PERSONAL)
owner_user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="owned_calendars",
null=True,
blank=True,
verbose_name=_("Владелец (пользователь)"),
)
owner_department = models.ForeignKey(
"employees.Department",
on_delete=models.CASCADE,
related_name="owned_calendars",
null=True,
blank=True,
verbose_name=_("Владелец (отдел)"),
)
# Системный календарь (автоматически созданный)
is_system = models.BooleanField(
_("Системный"),
default=False,
help_text=_("Создан автоматически при регистрации/инициализации"),
)
system_key = models.CharField(
_("Системный ключ"),
max_length=100,
blank=True,
db_index=True,
unique=True,
null=True,
help_text=_("Уникальный идентификатор системного календаря"),
)
# Настройки доступа
is_public = models.BooleanField(
_("Публичный"),
default=False,
help_text=_("Доступен всем для просмотра"),
)
allow_subscription = models.BooleanField(
_("Разрешить подписку"),
default=True,
help_text=_("Пользователи могут подписаться на этот календарь"),
)
default_for_new_users = models.BooleanField(
_("По умолчанию для новых пользователей"),
default=False,
help_text=_("Автоматически добавляется новым сотрудникам"),
)
# Служебное
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)
is_active = models.BooleanField(_("Активен"), default=True)
class Meta:
verbose_name = _("Календарь")
verbose_name_plural = _("Календари")
ordering = ["calendar_type", "title"]
indexes = [
models.Index(fields=["calendar_type", "is_active"]),
models.Index(fields=["owner_user", "is_active"]),
models.Index(fields=["owner_department", "is_active"]),
models.Index(fields=["system_key"]),
]
constraints = [
# Системный календарь обязан иметь system_key
models.CheckConstraint(
check=(
models.Q(is_system=False) | models.Q(system_key__isnull=False)
),
name="system_calendar_must_have_key"
),
# Уникальность system_key для системных календарей
models.UniqueConstraint(
fields=["system_key"],
condition=models.Q(is_system=True),
name="unique_system_key"
),
]
def __str__(self):
type_label = self.get_calendar_type_display()
owner = ""
if self.owner_user:
owner = f" ({self.owner_user.get_full_name() or self.owner_user.username})"
elif self.owner_department:
owner = f" ({self.owner_department.name})"
return f"[{type_label}] {self.title}{owner}"
def clean(self):
"""Валидация владения календарём."""
# GLOBAL не имеет владельца
if self.calendar_type == CalendarType.GLOBAL:
if self.owner_user or self.owner_department:
raise ValidationError(
_("Глобальный календарь не может иметь владельца.")
)
# DEPARTMENT должен иметь owner_department
if self.calendar_type == CalendarType.DEPARTMENT:
if not self.owner_department:
raise ValidationError(
_("Календарь отдела должен иметь владельца (отдел).")
)
if self.owner_user:
raise ValidationError(
_("Календарь отдела не может иметь владельца-пользователя.")
)
# PERSONAL должен иметь owner_user
if self.calendar_type == CalendarType.PERSONAL:
if not self.owner_user:
raise ValidationError(
_("Личный календарь должен иметь владельца (пользователя).")
)
if self.owner_department:
raise ValidationError(
_("Личный календарь не может иметь владельца-отдел.")
)
# Системный календарь должен иметь уникальный system_key
if self.is_system and not self.system_key:
raise ValidationError(
_("Системный календарь должен иметь system_key.")
)
@property
def is_global(self):
return self.calendar_type == CalendarType.GLOBAL
@property
def is_department_calendar(self):
return self.calendar_type == CalendarType.DEPARTMENT
@property
def is_personal_calendar(self):
return self.calendar_type == CalendarType.PERSONAL
Модель: CalendarPermission (Права доступа к календарю)
class PermissionLevel(models.TextChoices):
"""Уровень доступа к календарю."""
VIEW = "view", _("Просмотр")
EDIT = "edit", _("Редактирование")
MANAGE = "manage", _("Управление") # + права доступа
class CalendarPermission(models.Model):
"""Права доступа к календарю для пользователя или отдела."""
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="permissions",
verbose_name=_("Календарь"),
)
# Кому предоставлен доступ (одно из двух)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="calendar_permissions",
null=True,
blank=True,
verbose_name=_("Пользователь"),
)
department = models.ForeignKey(
"employees.Department",
on_delete=models.CASCADE,
related_name="calendar_permissions",
null=True,
blank=True,
verbose_name=_("Отдел"),
)
# Уровень доступа
level = models.CharField(
_("Уровень доступа"),
max_length=10,
choices=PermissionLevel.choices,
default=PermissionLevel.VIEW,
)
# Служебное
granted_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
verbose_name=_("Предоставил"),
)
granted_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Право доступа к календарю")
verbose_name_plural = _("Права доступа к календарям")
indexes = [
models.Index(fields=["calendar", "user"]),
models.Index(fields=["calendar", "department"]),
]
constraints = [
# Либо user, либо department
models.CheckConstraint(
check=(
models.Q(user__isnull=False, department__isnull=True) |
models.Q(user__isnull=True, department__isnull=False)
),
name="permission_target_xor"
),
# Уникальность: один пользователь = один уровень доступа
models.UniqueConstraint(
fields=["calendar", "user"],
condition=models.Q(user__isnull=False),
name="unique_user_permission"
),
models.UniqueConstraint(
fields=["calendar", "department"],
condition=models.Q(department__isnull=False),
name="unique_dept_permission"
),
]
def __str__(self):
target = self.user if self.user else self.department
return f"{self.calendar.title} → {target} ({self.get_level_display()})"
def clean(self):
"""Валидация целевого получателя прав."""
if not self.user and not self.department:
raise ValidationError(
_("Необходимо указать либо пользователя, либо отдел.")
)
if self.user and self.department:
raise ValidationError(
_("Нельзя одновременно указывать пользователя и отдел.")
)
Модель: CalendarSubscription (Подписки пользователей)
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,
help_text=_("Показывать события этого календаря в виджете"),
)
color_override = models.CharField(
_("Переопределить цвет"),
max_length=7,
blank=True,
help_text=_("Личный цвет для событий этого календаря"),
)
receive_notifications = models.BooleanField(
_("Получать уведомления"),
default=True,
)
# Автоматическая подписка (по умолчанию для новых пользователей)
is_auto_subscribed = models.BooleanField(
_("Автоподписка"),
default=False,
help_text=_("Добавлена автоматически при регистрации"),
)
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", "is_visible"]),
]
def __str__(self):
return f"{self.user.username} → {self.calendar.title}"
Обновление: CalendarEvent
class CalendarEvent(models.Model):
"""Событие календаря с поддержкой повторяемости."""
# НОВОЕ: Связь с календарём
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="events",
verbose_name=_("Календарь"),
)
# УДАЛИТЬ: department, employee (теперь через calendar)
# Основное (без изменений)
title = models.CharField(_("Название"), max_length=200)
description = models.TextField(_("Описание"), blank=True)
# Даты/время (без изменений)
start_date = models.DateField(_("Дата начала"))
end_date = models.DateField(_("Дата окончания"), null=True, blank=True)
start_time = models.TimeField(_("Время начала"), null=True, blank=True)
end_time = models.TimeField(_("Время окончания"), null=True, blank=True)
all_day = models.BooleanField(_("Весь день"), default=True)
# Повторяемость (без изменений)
recurrence = models.CharField(...)
recurrence_interval = models.PositiveSmallIntegerField(...)
# ... остальные поля recurrence
# Отображение
color = models.CharField(
_("Цвет"),
max_length=7,
blank=True,
help_text=_("Переопределяет цвет календаря для этого события"),
)
location = models.CharField(_("Локация"), max_length=200, blank=True)
# Служебное
created_by = models.ForeignKey(...)
created_at = models.DateTimeField(auto_now_add=True)
source = models.CharField(...) # Для системных событий (дни рождения)
class Meta:
verbose_name = _("Событие календаря")
verbose_name_plural = _("События календаря")
ordering = ["calendar", "start_date", "start_time"]
indexes = [
models.Index(fields=["calendar", "start_date"]),
models.Index(fields=["calendar", "recurrence", "start_date"]),
models.Index(fields=["source"]),
]
🔄 Миграция данных
Шаг 1: Создание новых моделей
# backend/calendar_app/migrations/000X_add_calendar_models.py
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('calendar_app', '000X_previous_migration'),
('employees', '000X_department_migration'),
]
operations = [
# Создание модели Calendar
migrations.CreateModel(
name='Calendar',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True)),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('color', models.CharField(default='#0d6efd', max_length=7)),
('calendar_type', models.CharField(max_length=20, choices=[...])),
('is_system', models.BooleanField(default=False)),
('system_key', models.CharField(max_length=100, unique=True, null=True)),
('is_public', models.BooleanField(default=False)),
('allow_subscription', models.BooleanField(default=True)),
('default_for_new_users', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
# ForeignKeys будут добавлены отдельно
],
),
# Добавление ForeignKey полей
migrations.AddField(
model_name='calendar',
name='owner_user',
field=models.ForeignKey(
null=True, blank=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='owned_calendars',
to=settings.AUTH_USER_MODEL
),
),
# ... остальные FK
# Создание CalendarPermission
migrations.CreateModel(name='CalendarPermission', ...),
# Создание CalendarSubscription
migrations.CreateModel(name='CalendarSubscription', ...),
]
Шаг 2: Миграция существующих событий
# backend/calendar_app/migrations/000X_migrate_events_to_calendars.py
from django.db import migrations
def migrate_events_to_calendars(apps, schema_editor):
"""Создаёт календари для существующих событий и привязывает их."""
CalendarEvent = apps.get_model('calendar_app', 'CalendarEvent')
Calendar = apps.get_model('calendar_app', 'Calendar')
User = apps.get_model('employees', 'Employee')
Department = apps.get_model('employees', 'Department')
# 1. Создать глобальный календарь для событий компании
company_calendar, _ = Calendar.objects.get_or_create(
system_key='company_default',
defaults={
'title': 'Календарь компании',
'description': 'Основной календарь корпоративных событий',
'calendar_type': 'global',
'is_system': True,
'is_public': True,
'default_for_new_users': True,
}
)
# 2. Мигрировать события компании (department=NULL, employee=NULL)
company_events = CalendarEvent.objects.filter(
department__isnull=True,
employee__isnull=True
)
for event in company_events:
event.calendar = company_calendar
event.save(update_fields=['calendar'])
print(f"✅ Мигрировано событий компании: {company_events.count()}")
# 3. Создать календари для отделов
for dept in Department.objects.all():
dept_calendar, created = Calendar.objects.get_or_create(
system_key=f'department_{dept.id}_default',
defaults={
'title': f'Календарь отдела {dept.name}',
'description': f'Календарь событий отдела {dept.name}',
'calendar_type': 'department',
'owner_department': dept,
'is_system': True,
'is_public': False,
'allow_subscription': True,
}
)
# Мигрировать события отдела
dept_events = CalendarEvent.objects.filter(department=dept)
for event in dept_events:
event.calendar = dept_calendar
event.save(update_fields=['calendar'])
if created:
print(f"✅ Создан календарь отдела: {dept.name} ({dept_events.count()} событий)")
# 4. Создать личные календари для пользователей
for user in User.objects.all():
user_calendar, created = Calendar.objects.get_or_create(
system_key=f'personal_{user.id}_default',
defaults={
'title': 'Мой календарь',
'description': 'Личный календарь событий',
'calendar_type': 'personal',
'owner_user': user,
'is_system': True,
'is_public': False,
'allow_subscription': False,
}
)
# Мигрировать личные события
personal_events = CalendarEvent.objects.filter(employee=user)
for event in personal_events:
event.calendar = user_calendar
event.save(update_fields=['calendar'])
if created and personal_events.exists():
print(f"✅ Создан личный календарь: {user.username} ({personal_events.count()} событий)")
def reverse_migration(apps, schema_editor):
"""Откат: восстановление department/employee из calendar."""
CalendarEvent = apps.get_model('calendar_app', 'CalendarEvent')
for event in CalendarEvent.objects.select_related('calendar').all():
cal = event.calendar
if cal.calendar_type == 'department' and cal.owner_department:
event.department = cal.owner_department
elif cal.calendar_type == 'personal' and cal.owner_user:
event.employee = cal.owner_user
event.save(update_fields=['department', 'employee'])
class Migration(migrations.Migration):
dependencies = [
('calendar_app', '000X_add_calendar_field_to_events'),
]
operations = [
migrations.RunPython(
migrate_events_to_calendars,
reverse_migration
),
]
Шаг 3: Удаление старых полей
# backend/calendar_app/migrations/000X_remove_old_fields.py
class Migration(migrations.Migration):
dependencies = [
('calendar_app', '000X_migrate_events_to_calendars'),
]
operations = [
# Удалить старые поля department и employee из CalendarEvent
migrations.RemoveField(
model_name='calendarevent',
name='department',
),
migrations.RemoveField(
model_name='calendarevent',
name='employee',
),
]
🎯 Примеры использования
1. Создание глобального календаря (админ)
from calendar_app.models import Calendar, CalendarType
# Календарь праздников (доступен всем)
holidays_cal = Calendar.objects.create(
title="Праздники компании",
description="Официальные праздники и выходные",
calendar_type=CalendarType.GLOBAL,
color="#FF5733",
is_public=True,
default_for_new_users=True,
created_by=admin_user
)
# Календарь обучения (доступ по подписке)
training_cal = Calendar.objects.create(
title="Обучающие мероприятия",
calendar_type=CalendarType.GLOBAL,
color="#28A745",
is_public=True,
allow_subscription=True,
created_by=hr_manager
)
2. Настройка прав доступа
from calendar_app.models import CalendarPermission, PermissionLevel
# HR отделу - право редактирования календаря обучения
CalendarPermission.objects.create(
calendar=training_cal,
department=hr_department,
level=PermissionLevel.EDIT,
granted_by=admin_user
)
# Конкретному пользователю - управление календарём
CalendarPermission.objects.create(
calendar=training_cal,
user=training_coordinator,
level=PermissionLevel.MANAGE,
granted_by=admin_user
)
3. Подписка пользователей
from calendar_app.models import CalendarSubscription
# Пользователь подписывается на календарь
CalendarSubscription.objects.create(
calendar=training_cal,
user=employee,
is_visible=True,
color_override="#00BFFF", # Личный цвет
receive_notifications=True
)
# Автоподписка новых пользователей (при регистрации)
for calendar in Calendar.objects.filter(default_for_new_users=True):
CalendarSubscription.objects.create(
calendar=calendar,
user=new_user,
is_visible=True,
is_auto_subscribed=True
)
4. Создание события в календаре
from calendar_app.models import CalendarEvent, Recurrence
# Создание праздника в глобальном календаре
CalendarEvent.objects.create(
calendar=holidays_cal,
title="Новый год",
start_date=date(2026, 1, 1),
end_date=date(2026, 1, 1),
all_day=True,
recurrence=Recurrence.ANNUAL,
color="#FF0000", # Переопределяет цвет календаря
created_by=admin_user
)
5. Получение календарей пользователя
def get_user_calendars(user):
"""Возвращает все календари, доступные пользователю."""
from django.db.models import Q
# 1. Личные календари пользователя
personal_cals = Calendar.objects.filter(
calendar_type=CalendarType.PERSONAL,
owner_user=user,
is_active=True
)
# 2. Публичные календари
public_cals = Calendar.objects.filter(
is_public=True,
is_active=True
)
# 3. Календари отделов пользователя
user_departments = user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True)
dept_cals = Calendar.objects.filter(
calendar_type=CalendarType.DEPARTMENT,
owner_department_id__in=user_departments,
is_active=True
)
# 4. Календари с явными правами доступа
permitted_cals = Calendar.objects.filter(
Q(permissions__user=user) |
Q(permissions__department_id__in=user_departments),
is_active=True
).distinct()
# 5. Подписки пользователя
subscribed_cals = Calendar.objects.filter(
subscriptions__user=user,
subscriptions__is_visible=True,
is_active=True
)
# Объединение и удаление дубликатов
all_cals = (personal_cals | public_cals | dept_cals |
permitted_cals | subscribed_cals).distinct()
return all_cals.order_by('calendar_type', 'title')
6. Проверка прав пользователя
def check_calendar_permission(calendar, user, required_level='view'):
"""Проверяет права пользователя на календарь."""
from calendar_app.models import PermissionLevel
# Админы могут всё
if user.is_superuser or user.is_staff:
return True
# Владелец может всё
if calendar.owner_user_id == user.id:
return True
# Публичный календарь - просмотр для всех
if required_level == 'view' and calendar.is_public:
return True
# Проверка календаря отдела
if calendar.calendar_type == CalendarType.DEPARTMENT:
user_departments = user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True)
if calendar.owner_department_id in user_departments:
if required_level == 'view':
return True
# Для редактирования нужны явные права
# Проверка явных прав доступа
level_hierarchy = {
'view': 1,
'edit': 2,
'manage': 3
}
required_int = level_hierarchy.get(required_level, 1)
# Права пользователя
user_perm = calendar.permissions.filter(user=user).first()
if user_perm:
user_level = level_hierarchy.get(user_perm.level, 0)
if user_level >= required_int:
return True
# Права через отдел
user_departments = user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True)
dept_perm = calendar.permissions.filter(
department_id__in=user_departments
).order_by('-level').first()
if dept_perm:
dept_level = level_hierarchy.get(dept_perm.level, 0)
if dept_level >= required_int:
return True
return False
📡 API изменения
Новые эндпоинты:
# backend/api/v1/calendar/urls.py
urlpatterns = [
# Календари
path('calendars/', CalendarListCreateView.as_view(), name='calendar-list'),
path('calendars/<int:pk>/', CalendarDetailView.as_view(), name='calendar-detail'),
path('calendars/<int:pk>/permissions/', CalendarPermissionsView.as_view()),
path('calendars/<int:pk>/subscribe/', CalendarSubscribeView.as_view()),
path('calendars/<int:pk>/unsubscribe/', CalendarUnsubscribeView.as_view()),
path('calendars/my/', MyCalendarsView.as_view(), name='my-calendars'),
# События (обновлённый эндпоинт)
path('calendars/<int:calendar_id>/events/', CalendarEventsView.as_view()),
path('events/<int:pk>/', EventDetailView.as_view()),
# Подписки
path('subscriptions/', MySubscriptionsView.as_view()),
]
Примеры запросов:
# Получить все календари пользователя
GET /api/v1/calendar/calendars/my/
# Создать новый календарь
POST /api/v1/calendar/calendars/
{
"title": "Дедлайны проекта X",
"calendar_type": "department",
"owner_department_id": 5,
"color": "#FF6347",
"is_public": false,
"allow_subscription": true
}
# Предоставить права редактирования
POST /api/v1/calendar/calendars/123/permissions/
{
"user_id": 456,
"level": "edit"
}
# Подписаться на календарь
POST /api/v1/calendar/calendars/123/subscribe/
{
"receive_notifications": true,
"color_override": "#00FF00"
}
# Получить события конкретного календаря
GET /api/v1/calendar/calendars/123/events/?start=2025-11-01&end=2025-11-30
🔧 Сигналы и автоматизация
Автосоздание календарей
# backend/calendar_app/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=User)
def create_personal_calendar(sender, instance, created, **kwargs):
"""Создаёт личный календарь при регистрации пользователя."""
if created:
Calendar.objects.create(
title="Мой календарь",
calendar_type=CalendarType.PERSONAL,
owner_user=instance,
is_system=True,
system_key=f"personal_{instance.id}_default",
color="#6C757D"
)
# Автоподписка на календари по умолчанию
default_calendars = Calendar.objects.filter(
default_for_new_users=True,
is_active=True
)
for calendar in default_calendars:
CalendarSubscription.objects.create(
calendar=calendar,
user=instance,
is_auto_subscribed=True
)
@receiver(post_save, sender=Department)
def create_department_calendar(sender, instance, created, **kwargs):
"""Создаёт календарь отдела при создании отдела."""
if created:
Calendar.objects.create(
title=f"Календарь {instance.name}",
calendar_type=CalendarType.DEPARTMENT,
owner_department=instance,
is_system=True,
system_key=f"department_{instance.id}_default",
color="#0D6EFD",
allow_subscription=True
)
📊 Итоговое сравнение
| Функция | Было | Стало |
|---|---|---|
| Глобальные календари | 1 (неявный) | Множество (создаются админами) |
| Календари отдела | 1 на отдел (автомат) | Множество на отдел |
| Личные календари | 1 на пользователя (неявный) | Множество на пользователя |
| Права доступа | Жёсткие (по типу) | Гибкие (CalendarPermission) |
| Подписки | Нет | Да (CalendarSubscription) |
| Делегирование | Нет | Да (MANAGE уровень) |
| Расшаривание | Нет | Да (permissions на user/dept) |
| Настройка видимости | Нет | Да (is_visible в подписке) |
| Цвет календаря | Только на событии | На календаре + override на событии |
| Автосоздание | Неявное | Явное через сигналы |
✅ Преимущества новой архитектуры
- ✅ Гибкость: Любое количество календарей любого типа
- ✅ Настраиваемость: Права доступа на уровне calendar + user/dept
- ✅ Масштабируемость: Отдел может иметь "Планёрки", "Дедлайны", "Отпуска"
- ✅ UX: Пользователь видит только нужные календари (подписки)
- ✅ Безопасность: Явные права вместо неявных правил
- ✅ Прозрачность: Системные календари помечены
is_system=True - ✅ Расширяемость: Легко добавить новые типы календарей (PROJECT, TEAM, etc.)
🚀 План реализации
Фаза 1: Модели и миграции (2-3 дня)
- Создать модели Calendar, CalendarPermission, CalendarSubscription
- Написать миграции с переносом данных
- Добавить сигналы автосоздания
Фаза 2: API (3-4 дня)
- Сериализаторы для новых моделей
- ViewSets для календарей и подписок
- Обновить CalendarEventsViewSet (filter по calendar_id)
- Middleware проверки прав доступа
Фаза 3: Frontend (4-5 дней)
- UI выбора календарей (мультивыбор checkbox)
- Модалка создания/настройки календаря
- Управление правами доступа (для MANAGE уровня)
- Управление подписками (список + вкл/выкл)
Фаза 4: Тестирование (2-3 дня)
- Unit тесты моделей
- API тесты (права доступа, CRUD)
- E2E тесты UI
Фаза 5: Документация (1 день)
- Обновить README
- API документация (Swagger)
- Гайд для пользователей
Общее время: ~12-16 рабочих дней
📖 Следующие шаги
- Утвердить архитектуру с командой/владельцем продукта
- Создать ветку
feature/calendar-entities - Начать с миграций (Фаза 1) — критично для целостности данных
- Параллельно разработать API (Фаза 2)
- Обновить фронт (Фаза 3) после готовности API
Автор: GitHub Copilot
Дата: 11 февраля 2026 г.