30 KiB
30 KiB
Упрощённая архитектура: Опциональные настраиваемые календари
Дата: 11 февраля 2026 г.
Подход: Обратно совместимое расширение без поломки существующего функционала
Статус: ✅ Phase 2 (API) завершена
📊 Прогресс реализации
✅ Phase 1: Модели и миграции (Завершено)
- ✅ Создана модель
Calendarс настройками видимости - ✅ Создана модель
CalendarSubscriptionс правами доступа - ✅ Добавлено опциональное поле
calendarвCalendarEvent - ✅ Миграция 0011 создана и применена
- ✅ Admin интерфейсы настроены
- ✅ Коммит:
feat(calendar): add optional Calendar and CalendarSubscription models
✅ Phase 2: API Layer (Завершено)
- ✅
CalendarEventWriteSerializerподдерживаетcalendar_id - ✅ Созданы сериализаторы для
Calendar(read + write) - ✅ Созданы сериализаторы для
CalendarSubscription(read + write) - ✅
CalendarViewSetс CRUD операциями - ✅ Эндпоинты subscribe/unsubscribe для календарей
- ✅
CalendarSubscriptionViewSetдля управления подписками - ✅
CalendarEventsViewSetобновлен для поддержкиcalendar_id - ✅
CalendarManager.get_available_for_user()метод - ✅ Методы проверки прав в модели
Calendar - ✅ URL маршруты зарегистрированы
- ✅ Коммит:
feat(calendar): add API layer for optional calendars
API эндпоинты (новые):
GET /api/v1/calendar/calendars/ # Список доступных календарей
POST /api/v1/calendar/calendars/ # Создание календаря
GET /api/v1/calendar/calendars/{id}/ # Детали календаря
PATCH /api/v1/calendar/calendars/{id}/ # Обновление календаря
DELETE /api/v1/calendar/calendars/{id}/ # Удаление календаря
POST /api/v1/calendar/calendars/{id}/subscribe/ # Подписка
POST /api/v1/calendar/calendars/{id}/unsubscribe/ # Отписка
GET /api/v1/calendar/calendars/my-calendars/ # Мои календари
GET /api/v1/calendar/subscriptions/ # Мои подписки
POST /api/v1/calendar/subscriptions/ # Создать подписку
GET /api/v1/calendar/subscriptions/{id}/ # Детали подписки
PATCH /api/v1/calendar/subscriptions/{id}/ # Обновить подписку
DELETE /api/v1/calendar/subscriptions/{id}/ # Удалить подписку
GET /api/v1/calendar/events/?calendar_id={id} # События календаря (legacy + new)
⏳ Phase 3: Frontend (В планах)
- ⏳ UI для списка календарей с фильтрацией
- ⏳ Кнопки подписки/отписки
- ⏳ Модальное окно создания/редактирования календаря
- ⏳ Переключатель отображения календарей
- ⏳ Интеграция с FullCalendar.js
⏳ Phase 4: Тестирование (В планах)
- ⏳ Unit тесты для моделей
- ⏳ API тесты для новых эндпоинтов
- ⏳ Тесты прав доступа
- ⏳ Тесты обратной совместимости
⏳ Phase 5: Документация (В планах)
- ⏳ Обновление README
- ⏳ API документация (OpenAPI/Swagger)
- ⏳ Руководство пользователя
💡 Ключевая идея
Принцип работы:
CalendarEvent.calendar = NULL → Работает как раньше (department/employee)
CalendarEvent.calendar = FK → Игнорирует department/employee, использует только calendar
Преимущества:
- ✅ Существующий код продолжает работать без изменений
- ✅ Новый функционал добавляется опционально
- ✅ Миграция данных НЕ требуется
- ✅ Постепенный переход по желанию
🏗️ Архитектура
1. Добавить модель Calendar (опциональная)
# backend/calendar_app/models.py
class CalendarVisibility(models.TextChoices):
"""Видимость календаря."""
PUBLIC = "public", _("Публичный (все видят)")
DEPARTMENT = "department", _("Отдел (только члены отдела)")
PRIVATE = "private", _("Приватный (только владелец)")
CUSTOM = "custom", _("Настраиваемый (через права)")
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")
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",
)
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)
2. Добавить подписки (упрощённая модель)
class CalendarSubscription(models.Model):
"""Подписка пользователя на календарь с настройками отображения."""
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="subscriptions",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="calendar_subscriptions",
)
# Настройки отображения
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}"
3. Расширить CalendarEvent (БЕЗ удаления существующих полей!)
class CalendarEvent(models.Model):
"""Событие календаря с поддержкой повторяемости.
ОБРАТНАЯ СОВМЕСТИМОСТЬ:
- Если calendar = NULL → используется старая логика (department/employee)
- Если calendar задан → игнорируются department/employee, используется calendar
"""
# ✨ НОВОЕ: Опциональная привязка к настраиваемому календарю
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="events",
null=True,
blank=True,
verbose_name=_("Календарь"),
help_text=_("Если не задан — событие использует стандартную логику (department/employee)"),
)
# ✅ ОСТАВЛЯЕМ: Старые поля для обратной совместимости
department = models.ForeignKey(
"employees.Department",
on_delete=models.CASCADE,
related_name="calendar_events",
verbose_name=_("Отдел"),
null=True,
blank=True,
help_text=_("LEGACY: используется если calendar=NULL"),
)
employee = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="personal_calendar_events",
verbose_name=_("Сотрудник"),
null=True,
blank=True,
help_text=_("LEGACY: используется если calendar=NULL"),
)
# ... остальные поля БЕЗ ИЗМЕНЕНИЙ ...
class Meta:
verbose_name = _("Событие календаря")
verbose_name_plural = _("События календаря")
ordering = ["start_date", "start_time"]
indexes = [
# Новый индекс для календарей
models.Index(fields=["calendar", "start_date"]),
# Старые индексы для обратной совместимости
models.Index(fields=["department", "start_date"]),
models.Index(fields=["employee", "start_date"]),
models.Index(fields=["source"]),
]
def __str__(self):
# Новая логика: приоритет calendar
if self.calendar_id:
return f"[{self.calendar.title}] {self.title} ({self.start_date:%d.%m.%Y})"
# Старая логика
if self.employee_id:
scope = f"Личный ({self.employee})"
elif self.department_id:
scope = str(self.department)
else:
scope = _("Компания")
if self.end_date:
return f"{scope}: {self.title} ({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):
"""True если событие использует старую логику (без calendar)."""
return self.calendar_id is None
@property
def is_modern_event(self):
"""True если событие использует новую логику (с calendar)."""
return self.calendar_id is not None
def clean(self):
"""Валидация с учётом новой/старой логики."""
# Если указан calendar — department и employee должны быть пусты
if self.calendar_id:
if self.department_id or self.employee_id:
raise ValidationError(
_("При использовании календаря нельзя указывать department или employee.")
)
# Старая валидация (только если calendar = NULL)
if not self.calendar_id:
if self.department_id and self.employee_id:
raise ValidationError(
_("Событие не может одновременно принадлежать отделу и сотруднику.")
)
# ... остальная валидация БЕЗ ИЗМЕНЕНИЙ ...
📊 Сравнение подходов
| Критерий | Полная миграция | Опциональный календарь (РЕКОМЕНДУЕТСЯ) |
|---|---|---|
| Обратная совместимость | ❌ Требует миграции данных | ✅ Полная, ничего не ломается |
| Сложность реализации | Высокая (12-16 дней) | Средняя (6-8 дней) |
| Риск поломки | Высокий | Минимальный |
| Миграция данных | Обязательна | Не требуется |
| Старый код | Нужно переписать | Работает как раньше |
| Тесты | Все переписать | Добавить новые, старые не трогать |
| Откат | Сложный | Простой (удалить поле) |
| Гибкость | Все через Calendar | На выбор: legacy или modern |
| Постепенный переход | ❌ Нет | ✅ Да, по желанию |
🎯 Логика работы
Сценарий 1: Старое событие (календарь не указан)
# Создаём событие по-старому (как раньше)
event = CalendarEvent.objects.create(
department=hr_department, # Старая логика
title="Планёрка HR",
start_date=date(2026, 2, 15),
all_day=True
)
# ✅ Работает как раньше
# ✅ Отображается в календаре отдела HR
# ✅ Доступ через department_id фильтр
Сценарий 2: Новое событие (с календарём)
# Создаём настраиваемый календарь
training_calendar = Calendar.objects.create(
title="Обучающие курсы",
visibility=CalendarVisibility.PUBLIC,
auto_subscribe_new_users=True,
default_can_edit=False, # Только админы могут редактировать
created_by=admin
)
# Создаём событие в календаре
event = CalendarEvent.objects.create(
calendar=training_calendar, # НОВАЯ логика
title="Python Advanced",
start_date=date(2026, 3, 1),
all_day=True
)
# ✅ department и employee игнорируются
# ✅ Права доступа через CalendarSubscription
# ✅ Отображается только подписчикам
Сценарий 3: Смешанное использование
# В одной системе могут быть оба типа событий
legacy_events = CalendarEvent.objects.filter(calendar__isnull=True) # Старые
modern_events = CalendarEvent.objects.filter(calendar__isnull=False) # Новые
# API автоматически поддерживает оба типа
all_events = CalendarEvent.objects.all() # Все события работают
🔌 API (минимальные изменения)
Обновление ViewSet (обратная совместимость)
# backend/api/v1/calendar/views.py
class CalendarEventsViewSet(ModelViewSet):
"""CRUD событий с поддержкой legacy и modern режимов."""
def get_queryset(self):
"""Фильтрация с учётом новой и старой логики."""
qs = super().get_queryset()
if self.action != "list":
return qs
# НОВОЕ: Если передан calendar_id
calendar_id = self.request.query_params.get('calendar_id')
if calendar_id:
return qs.filter(calendar_id=calendar_id)
# СТАРОЕ: Логика department/employee (без изменений!)
dep = self._dept_id(required=False)
emp = self._employee_id(required=False)
if emp is not None:
# Legacy: личный календарь
return qs.filter(
models.Q(employee_id=emp, calendar__isnull=True) | # Старые события
models.Q(calendar__owner_user_id=emp) # Новые события в личном календаре
)
elif dep is not None:
# Legacy: календарь отдела
return qs.filter(
models.Q(department_id=dep, calendar__isnull=True) | # Старые
models.Q(calendar__owner_department_id=dep) # Новые
)
else:
# Legacy: календарь компании
return qs.filter(
department__isnull=True,
employee__isnull=True,
calendar__isnull=True # Только старые глобальные события
)
def perform_create(self, serializer):
"""Создание с поддержкой обоих режимов."""
# Если передан calendar_id — используем новую логику
calendar_id = self.request.data.get('calendar_id')
if calendar_id:
serializer.save(
calendar_id=calendar_id,
department=None, # Очищаем legacy поля
employee=None,
created_by=self.request.user
)
else:
# Старая логика (без изменений)
dep = self._dept_id(required=False)
emp = self._employee_id(required=False)
if emp is not None:
serializer.save(employee_id=emp, department=None, created_by=self.request.user)
else:
serializer.save(department_id=dep, employee=None, created_by=self.request.user)
cache.clear()
Новые эндпоинты (опциональные)
# backend/api/v1/calendar/urls.py
urlpatterns = [
# ✅ Старые эндпоинты РАБОТАЮТ БЕЗ ИЗМЕНЕНИЙ
path('events/', CalendarEventsViewSet.as_view({'get': 'list', 'post': 'create'})),
# ✨ Новые эндпоинты для календарей
path('calendars/', CalendarListCreateView.as_view()),
path('calendars/<int:pk>/', CalendarDetailView.as_view()),
path('calendars/<int:pk>/subscribe/', CalendarSubscribeView.as_view()),
path('calendars/<int:pk>/unsubscribe/', CalendarUnsubscribeView.as_view()),
path('calendars/my/', MyCalendarsView.as_view()),
]
🚀 План реализации (упрощённый)
Фаза 1: Модели (1-2 дня)
# 1. Добавить модели Calendar и CalendarSubscription
# В models.py, БЕЗ удаления существующих полей
# 2. Добавить поле calendar в CalendarEvent (null=True, blank=True)
# 3. Создать миграции
.venv/Scripts/python backend/manage.py makemigrations
.venv/Scripts/python backend/manage.py migrate
Фаза 2: API (2-3 дня)
# 1. Добавить сериализаторы для Calendar
# 2. Обновить CalendarEventsViewSet (обратная совместимость)
# 3. Добавить ViewSet для Calendar (CRUD)
# 4. Добавить эндпоинты подписок
Фаза 3: Frontend (2-3 дня)
// 1. Добавить UI управления календарями (опционально)
// 2. Добавить мультивыбор календарей в виджете
// 3. Старый код РАБОТАЕТ БЕЗ ИЗМЕНЕНИЙ
Фаза 4: Тестирование (1 день)
# 1. Тесты для новых моделей
# 2. Старые тесты НЕ ТРОГАЕМ (они должны пройти!)
# 3. Тесты смешанного режима
Итого: ~6-8 дней (вместо 12-16)
📝 Примеры использования
1. Создать календарь "Праздники" (админ)
from calendar_app.models import Calendar, CalendarVisibility
holidays = Calendar.objects.create(
title="Праздники компании",
description="Официальные праздники и выходные",
visibility=CalendarVisibility.PUBLIC,
auto_subscribe_new_users=True, # Все новые пользователи автоматически подписаны
color="#FF5733",
created_by=admin_user
)
# Создать событие
CalendarEvent.objects.create(
calendar=holidays, # Привязываем к календарю
title="Новый год",
start_date=date(2026, 1, 1),
recurrence=Recurrence.ANNUAL,
all_day=True
)
2. Создать календарь отдела с расшариванием
# HR создаёт календарь "Обучение"
training = Calendar.objects.create(
title="Обучающие курсы",
owner_department=hr_department,
visibility=CalendarVisibility.CUSTOM,
default_can_edit=False, # По умолчанию только просмотр
created_by=hr_manager
)
# Даём право редактирования конкретному сотруднику
from calendar_app.models import CalendarSubscription
CalendarSubscription.objects.create(
calendar=training,
user=training_coordinator,
is_visible=True,
can_edit=True, # Может создавать события
can_manage=False
)
# Разработчики подписываются на календарь
for dev in developers:
CalendarSubscription.objects.create(
calendar=training,
user=dev,
is_visible=True,
can_edit=False, # Только просмотр
notify_on_new_event=True
)
3. Получить все календари пользователя
def get_user_calendars(user):
"""Возвращает все доступные календари."""
from django.db.models import Q
# 1. Личные календари
owned = Calendar.objects.filter(
owner_user=user,
is_active=True
)
# 2. Календари отделов пользователя
user_depts = user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True)
dept_owned = Calendar.objects.filter(
owner_department_id__in=user_depts,
is_active=True
)
# 3. Публичные календари
public = Calendar.objects.filter(
visibility=CalendarVisibility.PUBLIC,
is_active=True
)
# 4. Подписки
subscribed = Calendar.objects.filter(
subscriptions__user=user,
subscriptions__is_visible=True,
is_active=True
)
return (owned | dept_owned | public | subscribed).distinct()
4. Миграция старого события в новый календарь (опционально)
# Создать календарь для старых событий компании
company_calendar = Calendar.objects.create(
title="Основной календарь компании",
visibility=CalendarVisibility.PUBLIC,
color="#0D6EFD"
)
# Перенести старые события (опционально, не обязательно!)
legacy_events = CalendarEvent.objects.filter(
department__isnull=True,
employee__isnull=True,
calendar__isnull=True # Только legacy события
)
for event in legacy_events:
event.calendar = company_calendar
event.save(update_fields=['calendar'])
# ✅ Теперь они управляются через calendar
# ✅ Но старые события могут продолжать работать по-старому
✨ Преимущества упрощённого подхода
1. Нулевой риск поломки
# ✅ Весь существующий код работает БЕЗ ИЗМЕНЕНИЙ
CalendarEvent.objects.filter(department=dept) # Работает
CalendarEvent.objects.create(employee=user, ...) # Работает
# ✨ Новый функционал добавляется опционально
CalendarEvent.objects.filter(calendar=calendar) # Тоже работает
2. Постепенный переход
Шаг 1: Выпустить функционал (все работает как раньше)
Шаг 2: Создать несколько календарей для тестирования
Шаг 3: Постепенно переводить события на новые календари
Шаг 4: В будущем (если нужно) убрать legacy поля
3. Гибкость выбора
Хотите простоту? → Используйте department/employee (как раньше)
Хотите гибкость? → Создавайте Calendar и управляйте правами
Нужны оба? → Система поддерживает смешанный режим
4. Минимум кода
Добавлено: 2 модели (~150 строк)
Изменено: 1 поле в CalendarEvent (~5 строк)
Обновлено: API ViewSet (~50 строк)
Старый код: 0 изменений ✅
🎯 Рекомендация
Использовать упрощённый подход:
- ✅ Безопасно — ничего не ломается
- ✅ Быстро — 6-8 дней вместо 12-16
- ✅ Гибко — постепенный переход
- ✅ Понятно — простая логика "есть calendar или нет"
- ✅ Масштабируемо — можно расширять по мере роста
Следующий шаг:
# Создать ветку
git checkout -b feature/optional-calendars
# Начать с моделей
# 1. Добавить Calendar и CalendarSubscription в models.py
# 2. Добавить поле calendar в CalendarEvent
# 3. Сделать миграции
Хотите, чтобы я начал реализацию с моделей?
Автор: GitHub Copilot
Дата: 11 февраля 2026 г.