docs: добавлена архитектура опциональных календарей
This commit is contained in:
parent
d5929e846c
commit
c6c0adbd31
658
docs/in-progress/CALENDAR_APP_ANALYSIS.md
Normal file
658
docs/in-progress/CALENDAR_APP_ANALYSIS.md
Normal 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-шаблон.
|
||||
1057
docs/in-progress/CALENDAR_ENTITIES_ARCHITECTURE.md
Normal file
1057
docs/in-progress/CALENDAR_ENTITIES_ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
709
docs/in-progress/CALENDAR_OPTIONAL_ARCHITECTURE.md
Normal file
709
docs/in-progress/CALENDAR_OPTIONAL_ARCHITECTURE.md
Normal 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 г.
|
||||
Loading…
x
Reference in New Issue
Block a user