docs: добавлена архитектура опциональных календарей

This commit is contained in:
admin i 2026-02-11 07:53:04 +03:00
parent d5929e846c
commit c6c0adbd31
3 changed files with 2424 additions and 0 deletions

View File

@ -0,0 +1,658 @@
# Анализ приложения календаря EUSRR
**Дата анализа:** 11 февраля 2026 г.
**Задача:** Изучить приложение календаря и оценить возможность создания пользовательских календарей (глобального, локального или календаря отдела)
---
## 📋 Содержание
1. [Текущая архитектура](#текущая-архитектура)
2. [Модель данных](#модель-данных)
3. [API и права доступа](#api-и-права-доступа)
4. [Frontend реализация](#frontend-реализация)
5. [Оценка возможностей](#оценка-возможностей)
6. [Рекомендации](#рекомендации)
---
## 🏗️ Текущая архитектура
### Структура приложения
```
backend/calendar_app/
├── models.py # Модель CalendarEvent
├── admin.py # Админка Django
├── signals.py # Автосигналы (дни рождения)
├── notification_signals.py # Уведомления о событиях
├── apps.py # Конфигурация приложения
└── services/
└── recurrence.py # Логика повторяющихся событий
backend/api/v1/calendar/
├── views.py # CalendarEventsViewSet (REST API)
└── serializers.py # Сериализаторы событий
backend/templates/includes/components/
├── calendar_desktop.html # Десктопный виджет
├── calendar_mobile.html # Мобильный виджет
├── calendar_modal_create.html # Модалка создания
└── calendar_modal_details.html # Модалка деталей
backend/static/js/
├── api/calendarApi.js # API клиент
└── components/calendarWidget.js # Логика виджета FullCalendar
```
---
## 📊 Модель данных
### CalendarEvent
**Основная модель события с тремя типами области видимости:**
```python
class CalendarEvent(models.Model):
# Область видимости (взаимоисключающие)
department = ForeignKey("employees.Department", null=True, blank=True)
employee = ForeignKey(User, null=True, blank=True) # ✅ УЖЕ РЕАЛИЗОВАНО
# Основные поля
title = CharField(max_length=200)
description = TextField(blank=True)
# Даты и время
start_date = DateField()
end_date = DateField(null=True, blank=True)
start_time = TimeField(null=True, blank=True)
end_time = TimeField(null=True, blank=True)
all_day = BooleanField(default=True)
# Повторяемость
recurrence = CharField(choices=Recurrence.choices) # one_time, hourly, daily, weekly, monthly, annual
recurrence_interval = PositiveSmallIntegerField(default=1)
recurrence_count = PositiveIntegerField(null=True, blank=True)
recurrence_until = DateField(null=True, blank=True)
weekdays_mask = PositiveSmallIntegerField(default=0) # Для weekly: битовая маска дней
# Отображение
color = CharField(max_length=7, blank=True) # HEX цвет
location = CharField(max_length=200, blank=True)
# Служебные
created_by = ForeignKey(User, on_delete=SET_NULL)
created_at = DateTimeField(auto_now_add=True)
source = CharField(max_length=120, blank=True, db_index=True) # Для системных событий
```
### Типы календарей
#### 1. 🌐 Календарь компании (Глобальный)
- `department = NULL` и `employee = NULL`
- Видны всем сотрудникам
- Редактируют только администраторы (`is_staff=True` или `is_superuser=True`)
- **Примеры:** Праздники, корпоративы, общие собрания
#### 2. 🏢 Календарь отдела (Локальный)
- `department = <PK отдела>` и `employee = NULL`
- Видны всем авторизованным пользователям
- Редактируют: администраторы или пользователи с правом `MANAGE_CALENDAR` на отдел
- **Примеры:** Планёрки отдела, дедлайны проектов
#### 3. 👤 Личный календарь (Персональный) ✅
- `department = NULL` и `employee = <PK сотрудника>`
- **СТАТУС:** Полностью реализован в коде!
- Видны всем авторизованным (для просмотра)
- Редактирует только владелец (`employee_id == request.user.id`)
- **Примеры:** Личные встречи, отпуска, напоминания
### Валидация модели
```python
def clean(self):
# ✅ Взаимоисключение: нельзя одновременно задавать отдел И сотрудника
if self.department_id and self.employee_id:
raise ValidationError("Событие не может одновременно принадлежать отделу и сотруднику.")
# Валидация дат/времени
# Валидация повторяемости
# Автоматическая настройка all_day
```
---
## 🔌 API и права доступа
### REST API Endpoint
```
/api/v1/calendar/events/
```
### Поддерживаемые методы
| Метод | Действие | Права доступа |
|-------|----------|---------------|
| `GET /events/` | Список событий (материализованные повторы) | Авторизованные пользователи |
| `GET /events/{id}/` | Детали события | Авторизованные пользователи |
| `POST /events/` | Создание события | Зависит от типа* |
| `PATCH /events/{id}/` | Обновление события | Зависит от типа* |
| `DELETE /events/{id}/` | Удаление события | Зависит от типа* |
| `GET /events/{id}/permissions/` | Проверка прав на событие | Авторизованные пользователи |
**\*Права на изменение:**
```python
def get_permissions(self):
# Просмотр: всем авторизованным
if action in ["list", "retrieve"]:
return [IsAuthenticated()]
# Личный календарь: только владелец
if emp is not None:
if request.user.id == emp:
return [IsAuthenticated()]
else:
return [DenyAll()] # Другие не могут редактировать
# Календарь компании: только администраторы
if dep is None:
return [IsAdminUser()]
# Календарь отдела: право MANAGE_CALENDAR
return [ManageCalendarPerm()] # AdminOrDeptAllowed с required_code=MANAGE_CALENDAR
```
### Фильтрация событий
#### GET параметры:
```
?start=2025-01-01 # Обязательный: начало диапазона (YYYY-MM-DD)
?end=2025-01-31 # Обязательный: конец диапазона (YYYY-MM-DD)
?department_id=5 # Опциональный: календарь отдела
?employee_id=123 # Опциональный: личный календарь сотрудника
```
**Приоритет фильтрации:** `employee_id` > `department_id` > company
#### Примеры запросов:
```http
# Календарь компании
GET /api/v1/calendar/events/?start=2025-11-01&end=2025-11-30
# Календарь отдела HR
GET /api/v1/calendar/events/?start=2025-11-01&end=2025-11-30&department_id=5
# Личный календарь сотрудника
GET /api/v1/calendar/events/?start=2025-11-01&end=2025-11-30&employee_id=123
```
### Создание событий
```http
POST /api/v1/calendar/events/
Content-Type: application/json
Authorization: Bearer <token>
# Компания (только админы)
{
"title": "Новогодний корпоратив",
"start_date": "2025-12-31",
"all_day": true,
"recurrence": "one_time"
}
# Отдел (с правом MANAGE_CALENDAR)
{
"department_id": 5,
"title": "Планёрка отдела",
"start_date": "2025-11-15",
"start_time": "10:00",
"end_time": "11:00",
"all_day": false,
"recurrence": "weekly",
"weekdays": [0, 2, 4] # Пн, Ср, Пт
}
# Личный (только владелец)
{
"employee_id": 123,
"title": "Встреча с клиентом",
"start_date": "2025-11-20",
"start_time": "14:00",
"end_time": "15:00",
"all_day": false,
"recurrence": "one_time"
}
```
---
## 🎨 Frontend реализация
### Виджет календаря (FullCalendar)
**Файл:** `backend/static/js/components/calendarWidget.js`
#### State управление:
```javascript
const state = {
type: 'company', // 'company' | 'dept' | 'personal'
deptId: null, // PK отдела (если type='dept')
employeeId: null // PK сотрудника (если type='personal')
};
```
#### URL формирование:
```javascript
const eventsUrl = (deptId = null, employeeId = null) => {
const u = new URL(API_EVENTS, location.origin);
if (employeeId != null) {
u.searchParams.set('employee_id', String(employeeId));
} else if (deptId != null) {
u.searchParams.set('department_id', String(deptId));
}
return u.pathname + u.search;
};
```
#### UI компоненты:
**Десктоп:** `calendar_desktop.html`
```html
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle">
Компания
</button>
<ul class="dropdown-menu" id="calendarChooserMenu">
<li><button data-cal="personal">Личный</button></li>
<li><hr class="dropdown-divider"></li>
<li><button data-cal="company">Компания</button></li>
<li><hr class="dropdown-divider"></li>
{# Пункты отделов добавляются динамически из JS #}
</ul>
</div>
```
**Мобильный:** `calendar_mobile.html` (аналогичная структура в offcanvas)
#### Загрузка событий:
```javascript
// ✅ Поддержка всех трёх типов календарей
async function fetchEventsAllCalendars(start, end) {
const sources = [
// Компания
{ params: { start, end }, label: 'Компания', type: 'company', id: null },
// Все отделы пользователя
...departments.map(d => ({
params: { start, end, department_id: d.id },
label: d.name,
type: 'dept',
id: d.id
})),
// Личный календарь (если есть employee_id)
{
params: { start, end, employee_id: currentEmployeeId },
label: 'Личный',
type: 'personal',
id: currentEmployeeId
}
];
// Параллельная загрузка всех источников
const results = await Promise.allSettled(
sources.map(s => getCalendarEvents(s.params))
);
return results.flatMap(/* ... */);
}
```
### Модалки
#### Создание события: `calendar_modal_create.html`
- Форма с полями: title, description, start_date, end_date, start_time, end_time
- Выбор повторяемости (recurrence)
- Выбор цвета (color picker)
- Автоматическая подстановка department_id/employee_id из текущего state
#### Просмотр/редактирование: `calendar_modal_details.html`
- Отображение всех деталей события
- Кнопки редактирования/удаления (если есть права)
- Проверка прав через `GET /events/{id}/permissions/`
---
## ✅ Оценка возможностей
### Что УЖЕ реализовано:
#### ✅ 1. Календарь компании (Глобальный)
- **Backend:** Полностью реализован
- **API:** `/api/v1/calendar/events/` без параметров
- **Frontend:** Работает в виджете (кнопка "Компания")
- **Права:** Просмотр — все, редактирование — только админы
- **Статус:** 🟢 Готово к использованию
#### ✅ 2. Календарь отдела (Локальный)
- **Backend:** Полностью реализован
- **API:** `/api/v1/calendar/events/?department_id=X`
- **Frontend:** Динамическое меню отделов в виджете
- **Права:** Просмотр — все, редактирование — с правом `MANAGE_CALENDAR`
- **Статус:** 🟢 Готово к использованию
#### ✅ 3. Личный календарь (Персональный)
- **Backend:** Полностью реализован (поле `employee` в модели)
- **API:** `/api/v1/calendar/events/?employee_id=X`
- **Frontend:** ⚠️ Кнопка в меню есть, но **требуется тестирование**
- **Права:** Просмотр — все, редактирование — только владелец
- **Статус:** 🟡 Реализовано, требует проверки работы UI
### Что работает из коробки:
1. ✅ **Создание событий** всех трёх типов через API
2. ✅ **Повторяющиеся события** (hourly, daily, weekly, monthly, annual)
3. ✅ **Материализация повторов** в диапазоне дат
4. ✅ **Права доступа** с автоматической проверкой
5. ✅ **Валидация** дат, времени, повторяемости
6. ✅ **Уведомления** при создании/изменении/удалении событий
7. ✅ **Автосоздание событий** (например, дни рождения через signals)
8. ✅ **FullCalendar интеграция** на фронте
9. ✅ **Адаптивный UI** (desktop + mobile)
### Что может потребовать доработки:
#### 🟡 1. UI личного календаря
**Что есть:**
- Кнопка "Личный" в выпадающем меню
- Логика переключения state
- API запросы с `employee_id`
**Что проверить:**
- Корректное получение `employee_id` текущего пользователя
- Отображение событий в FullCalendar
- Создание личных событий через модалку
- Проверка прав доступа (только владелец может редактировать)
**Код для проверки:**
```javascript
// В calendarWidget.js, строка ~535
const userMeta = document.querySelector('meta[name="user-id"]');
const currentEmployeeId = userMeta ? userMeta.content : null;
```
**Требуется:**
- Убедиться, что в шаблонах есть `<meta name="user-id" content="{{ user.id }}">`
- Протестировать переключение на личный календарь
- Протестировать создание/редактирование личных событий
#### 🟡 2. Модалка создания
**Текущее поведение:**
- При создании события подставляется текущий `department_id` или `employee_id` из state
**Что может понадобиться:**
- Явный выбор типа календаря в форме создания (если нужен)
- Визуальная индикация, для какого календаря создаётся событие
- Валидация на фронте перед отправкой
#### 🟡 3. Фильтры и поиск
**Что отсутствует:**
- Поиск событий по названию
- Фильтрация по типу повторяемости
- Экспорт в iCal/Google Calendar
**Приоритет:** Низкий (можно добавить позже)
---
## 🎯 Рекомендации
### Немедленные действия (Priority: High)
#### 1. Проверка meta-тега user-id
**Файлы для проверки:**
- `backend/templates/base.html` или аналогичный base-шаблон
- Любые страницы, где используется календарь
**Добавить, если отсутствует:**
```django
{% if user.is_authenticated %}
<meta name="user-id" content="{{ user.id }}">
{% endif %}
```
#### 2. Тестирование личного календаря
**Сценарии:**
```python
# 1. GET личных событий
GET /api/v1/calendar/events/?employee_id=<user_id>&start=2025-11-01&end=2025-11-30
# 2. POST личного события
POST /api/v1/calendar/events/
{"employee_id": <user_id>, "title": "Тест", "start_date": "2025-11-15", "all_day": true}
# 3. PATCH личного события (владелец)
PATCH /api/v1/calendar/events/<event_id>/
{"title": "Обновлённое название"}
# 4. PATCH личного события (НЕ владелец) — должно вернуть 403
```
#### 3. Проверка прав доступа
**Матрица прав:**
| Тип календаря | Просмотр | Создание | Редактирование | Удаление |
|---------------|----------|----------|----------------|----------|
| Компания | Все авторизованные | Только админы | Только админы | Только админы |
| Отдел | Все авторизованные | С правом MANAGE_CALENDAR | С правом MANAGE_CALENDAR | С правом MANAGE_CALENDAR |
| Личный | Все авторизованные | Только владелец | Только владелец | Только владелец |
**Код тестов:** Уже существует в `backend/tests/api/v1/calendar_app/test_calendar_api.py`
### Краткосрочные улучшения (Priority: Medium)
#### 1. UI индикаторы
```html
<!-- В модалке создания события -->
<div class="alert alert-info">
<i class="bi-info-circle"></i>
Создаётся событие для: <strong id="eventScope">Компания</strong>
</div>
```
```javascript
// Обновление при переключении календаря
function updateScopeIndicator() {
const scope = state.type === 'personal' ? 'Личного календаря'
: state.type === 'dept' ? departments.find(d => d.id === state.deptId)?.name
: 'Компании';
document.getElementById('eventScope').textContent = scope;
}
```
#### 2. Цветовая дифференциация
```javascript
// В calendarWidget.js
function getEventColor(event) {
if (event.color) return event.color;
// Дефолтные цвета по типу
if (event.employee_id) return '#6C757D'; // Серый для личных
if (event.department_id) return '#0D6EFD'; // Синий для отдела
return '#198754'; // Зелёный для компании
}
```
#### 3. Уведомления на фронте
```javascript
// После успешного создания
showToast('Событие создано', `"${title}" добавлено в ${scopeName}`, 'success');
```
### Долгосрочные улучшения (Priority: Low)
#### 1. Расширенные фильтры
- Поиск по названию/описанию
- Фильтр по цвету
- Фильтр по типу повторяемости
- Группировка по отделам
#### 2. Интеграции
- Экспорт в .ics (iCal)
- Синхронизация с Google Calendar
- Outlook интеграция
- Email-напоминания
#### 3. Аналитика
- Статистика по событиям отдела
- Загруженность календарей
- Отчёты по посещаемости
---
## 📝 Примеры использования
### Создание глобального события (админ)
```python
import requests
url = "https://example.com/api/v1/calendar/events/"
headers = {"Authorization": "Bearer <admin_token>"}
data = {
"title": "Корпоративный хакатон",
"description": "Командный хакатон по разработке новых фич",
"start_date": "2025-12-15",
"end_date": "2025-12-16",
"all_day": true,
"color": "#FF5733",
"location": "Офис, конференц-зал",
"recurrence": "one_time"
}
response = requests.post(url, headers=headers, json=data)
print(response.json())
```
### Создание события отдела (с правом MANAGE_CALENDAR)
```python
data = {
"department_id": 5, # HR отдел
"title": "Планёрка HR",
"start_date": "2025-11-18",
"start_time": "10:00",
"end_time": "10:30",
"all_day": false,
"recurrence": "weekly",
"weekdays": [0], # Каждый понедельник
"recurrence_until": "2026-01-01"
}
response = requests.post(url, headers=headers, json=data)
```
### Создание личного события
```python
data = {
"employee_id": 123,
"title": "Встреча с заказчиком",
"start_date": "2025-11-20",
"start_time": "14:00",
"end_time": "15:30",
"all_day": false,
"color": "#6C757D",
"location": "Online, Zoom",
"recurrence": "one_time"
}
response = requests.post(url, headers=headers, json=data)
```
---
## 🔍 Тестирование
### Checklist для проверки личного календаря:
- [ ] **Meta-тег:** `<meta name="user-id">` присутствует на страницах с календарём
- [ ] **UI:** Кнопка "Личный" появляется в меню выбора календаря
- [ ] **API GET:** `/api/v1/calendar/events/?employee_id=<user_id>` возвращает события
- [ ] **API POST:** Создание личного события работает
- [ ] **Права:** Только владелец может редактировать свои события
- [ ] **Права:** Другие пользователи НЕ могут редактировать чужие личные события
- [ ] **FullCalendar:** События отображаются корректно
- [ ] **Переключение:** Смена календаря через dropdown работает
- [ ] **Модалка:** Создание события попадает в нужный календарь
- [ ] **Цвета:** Личные события визуально отличаются от других
### Запуск тестов:
```bash
# Backend тесты
.venv/Scripts/python backend/manage.py test calendar_app
# API тесты
.venv/Scripts/python -m pytest backend/tests/api/v1/calendar_app/ -v
# Конкретный тест личного календаря
.venv/Scripts/python -m pytest backend/tests/api/v1/calendar_app/test_calendar_api.py::TestPersonalCalendar -v
```
---
## 🎉 Итоговая оценка
### Глобальный календарь: 🟢 100% готов
- Backend: ✅
- API: ✅
- Frontend: ✅
- Права: ✅
### Календарь отдела: 🟢 100% готов
- Backend: ✅
- API: ✅
- Frontend: ✅
- Права: ✅
### Личный календарь: 🟡 95% готов
- Backend: ✅
- API: ✅
- Frontend: ✅ (требует проверки)
- Права: ✅
- **Осталось:** Проверить UI и meta-теги
---
## 📚 Дополнительная документация
### Связанные файлы:
- [Реализация личного календаря](./PERSONAL_CALENDAR_FEATURE.md)
- [Модель CalendarEvent](../../backend/calendar_app/models.py)
- [API ViewSet](../../backend/api/v1/calendar/views.py)
- [Сериализаторы](../../backend/api/v1/calendar/serializers.py)
- [Виджет FullCalendar](../../backend/static/js/components/calendarWidget.js)
- [API клиент](../../backend/static/js/api/calendarApi.js)
### Тесты:
- [Тесты API календаря](../../backend/tests/api/v1/calendar_app/test_calendar_api.py)
- [Конфигурация тестов](../../backend/tests/api/v1/calendar_app/conftest.py)
---
**Выводы:**
1. ✅ Все три типа календарей (глобальный, отдела, личный) **полностью реализованы** на backend
2. ✅ API работает корректно с автоматической проверкой прав
3. ✅ Frontend виджет поддерживает все три типа
4. 🟡 Требуется **проверка UI** для личного календаря (добавить meta-тег, протестировать)
5. ✅ Система **готова к продакшену** после финальной проверки
**Следующий шаг:** Тестирование личного календаря в браузере и добавление meta-тега `user-id` в base-шаблон.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,709 @@
# Упрощённая архитектура: Опциональные настраиваемые календари
**Дата:** 11 февраля 2026 г.
**Подход:** Обратно совместимое расширение без поломки существующего функционала
---
## 💡 Ключевая идея
### Принцип работы:
```
CalendarEvent.calendar = NULL → Работает как раньше (department/employee)
CalendarEvent.calendar = FK → Игнорирует department/employee, использует только calendar
```
**Преимущества:**
- ✅ Существующий код продолжает работать без изменений
- ✅ Новый функционал добавляется опционально
- ✅ Миграция данных НЕ требуется
- ✅ Постепенный переход по желанию
---
## 🏗️ Архитектура
### 1. Добавить модель Calendar (опциональная)
```python
# 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. Добавить подписки (упрощённая модель)
```python
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 (БЕЗ удаления существующих полей!)
```python
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: Старое событие (календарь не указан)
```python
# Создаём событие по-старому (как раньше)
event = CalendarEvent.objects.create(
department=hr_department, # Старая логика
title="Планёрка HR",
start_date=date(2026, 2, 15),
all_day=True
)
# ✅ Работает как раньше
# ✅ Отображается в календаре отдела HR
# ✅ Доступ через department_id фильтр
```
### Сценарий 2: Новое событие (с календарём)
```python
# Создаём настраиваемый календарь
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: Смешанное использование
```python
# В одной системе могут быть оба типа событий
legacy_events = CalendarEvent.objects.filter(calendar__isnull=True) # Старые
modern_events = CalendarEvent.objects.filter(calendar__isnull=False) # Новые
# API автоматически поддерживает оба типа
all_events = CalendarEvent.objects.all() # Все события работают
```
---
## 🔌 API (минимальные изменения)
### Обновление ViewSet (обратная совместимость)
```python
# 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()
```
### Новые эндпоинты (опциональные)
```python
# 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 дня)
```bash
# 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 дня)
```python
# 1. Добавить сериализаторы для Calendar
# 2. Обновить CalendarEventsViewSet (обратная совместимость)
# 3. Добавить ViewSet для Calendar (CRUD)
# 4. Добавить эндпоинты подписок
```
### Фаза 3: Frontend (2-3 дня)
```javascript
// 1. Добавить UI управления календарями (опционально)
// 2. Добавить мультивыбор календарей в виджете
// 3. Старый код РАБОТАЕТ БЕЗ ИЗМЕНЕНИЙ
```
### Фаза 4: Тестирование (1 день)
```python
# 1. Тесты для новых моделей
# 2. Старые тесты НЕ ТРОГАЕМ (они должны пройти!)
# 3. Тесты смешанного режима
```
**Итого: ~6-8 дней** (вместо 12-16)
---
## 📝 Примеры использования
### 1. Создать календарь "Праздники" (админ)
```python
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. Создать календарь отдела с расшариванием
```python
# 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. Получить все календари пользователя
```python
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. Миграция старого события в новый календарь (опционально)
```python
# Создать календарь для старых событий компании
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. Нулевой риск поломки
```python
# ✅ Весь существующий код работает БЕЗ ИЗМЕНЕНИЙ
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 изменений ✅
```
---
## 🎯 Рекомендация
**Использовать упрощённый подход:**
1. ✅ **Безопасно** — ничего не ломается
2. ✅ **Быстро** — 6-8 дней вместо 12-16
3. ✅ **Гибко** — постепенный переход
4. ✅ **Понятно** — простая логика "есть calendar или нет"
5. ✅ **Масштабируемо** — можно расширять по мере роста
### Следующий шаг:
```bash
# Создать ветку
git checkout -b feature/optional-calendars
# Начать с моделей
# 1. Добавить Calendar и CalendarSubscription в models.py
# 2. Добавить поле calendar в CalendarEvent
# 3. Сделать миграции
```
Хотите, чтобы я начал реализацию с моделей?
---
**Автор:** GitHub Copilot
**Дата:** 11 февраля 2026 г.