- Created calendarWidgetIntegration.js as integration layer - Modified calendarWidget.js to use new calendar filtering system - Added fallback to legacy mode for backward compatibility - Updated calendar_scripts.html to initialize integration - Implemented fetchEventsForVisibleCalendars with calendar_id filtering - Added event deduplication and color override from calendars - Exposed window.calendarIntegration for global access - Added comprehensive logging for debugging Technical decisions: - Non-invasive integration via optional chaining - Callback-based synchronization (onCalendarToggle, onCalendarsChange) - Parallel event loading with Promise.all() - Automatic legacy mode when no calendars configured
34 KiB
34 KiB
Упрощённая архитектура: Опциональные настраиваемые календари
Дата: 11 февраля 2026 г.
Подход: Обратно совместимое расширение без поломки существующего функционала
Статус: ✅ Phase 2 (API + Testing) завершена
📊 Прогресс реализации
✅ Phase 1: Модели и миграции (Завершено)
- ✅ Создана модель
Calendarс настройками видимости - ✅ Создана модель
CalendarSubscriptionс правами доступа - ✅ Добавлено опциональное поле
calendarвCalendarEvent - ✅ Миграция 0011 создана и применена
- ✅ Admin интерфейсы настроены
- ✅ Коммит:
feat(calendar): add optional Calendar and CalendarSubscription models
✅ Phase 2: API Layer + Testing (Завершено)
- ✅
CalendarEventWriteSerializerподдерживаетcalendar_id - ✅ Созданы сериализаторы для
Calendar(read + write) - ✅ Созданы сериализаторы для
CalendarSubscription(read + write) - ✅
CalendarViewSetс CRUD операциями - ✅ Эндпоинты subscribe/unsubscribe для календарей
- ✅
CalendarSubscriptionViewSetдля управления подписками - ✅
CalendarEventsViewSetобновлен для поддержкиcalendar_id - ✅
CalendarManager.get_available_for_user()метод - ✅ Методы проверки прав в модели
Calendar - ✅ URL маршруты зарегистрированы
- ✅ Коммит API:
feat(calendar): add API layer for optional calendars
🧪 Тестирование (Завершено)
- ✅ Создана фикстура
make_eventдля тестов событий - ✅ Миграция 0012:
CalendarSubscription.color_overridenullable - ✅ Добавлены computed-поля в
CalendarSerializer:is_personal,is_global,is_department - ✅ Исправлен
CalendarViewSet.create()для корректного ответа - ✅ Создан
test_new_calendar_api.pyс 29 тестами:- 11 CRUD тестов (list, create, retrieve, update, delete)
- 6 subscription тестов (subscribe, unsubscribe, my-subscriptions)
- 3 event filtering тестов (calendar_id filtering, legacy separation)
- 3 visibility тестов (public, private, department)
- 6 permission тестов (is_owner, can_user_view, can_user_edit)
- ✅ Результаты тестов:
- ✅ 34 legacy тестов пройдено (обратная совместимость)
- ✅ 29 новых тестов пройдено (новый функционал)
- ✅ Всего: 63 теста успешно!
- ✅ Коммит тестов:
feat(calendar): add comprehensive test suite for new Calendar API
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 (В процессе)
✅ Part 1: UI Components (Завершено)
- ✅
calendarsApi.js- API wrapper для работы с календарями - ✅
calendarManager.js- Компонент списка календарей - ✅
calendarManageModal.js- Модальное окно создания/редактирования - ✅
calendar-manager.scss- BEM-стили для компонентов - ✅
calendar_desktop.html- Обновлённый UI с collapsible списком - ✅
calendar_modal_manage.html- Шаблон модального окна - ✅ Коммит:
feat(calendar): add frontend components for calendar management (Phase 3 - Part 1)
✅ Part 2: Integration (Завершено)
- ✅
calendarWidgetIntegration.js- Интеграция менеджера с виджетом - ✅
calendarWidget.js- Модифицирован для поддержки множественных календарей - ✅ Фильтрация событий по выбранным календарям (
fetchEventsForVisibleCalendars) - ✅ Интеграция с FullCalendar.js через
window.calendarIntegration - ✅ Обновление цветов событий по календарям
- ✅ Синхронизация состояния видимости через callbacks
- ✅ Fallback на legacy режим для обратной совместимости
- ✅ Дедупликация событий по ID
- ✅ Логирование для debugging
- ✅
calendar_scripts.htmlобновлён для инициализации интеграции
Архитектурные решения:
- Неинвазивная интеграция:
calendarWidget.jsпроверяет наличиеwindow.calendarIntegration - Legacy режим автоматически активируется если новая система недоступна
- События загружаются с параметром
calendar_idдля каждого видимого календаря - Цвет календаря переопределяет цвет события для визуальной группировки
⏳ Phase 4: Дополнительное тестирование (В планах)
- ✅ Unit тесты для моделей (через API тесты)
- ✅ API тесты для новых эндпоинтов (29 тестов)
- ✅ Тесты прав доступа (6 тестов permissions)
- ✅ Тесты обратной совместимости (34 legacy теста)
- ⏳ Integration тесты frontend + backend
- ⏳ E2E тесты пользовательских сценариев
⏳ 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 г.