From 7cfd290d7d026b76b183554ff82211c0fb9fa064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 2 Dec 2025 17:15:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(requests):=20=D0=AD=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=D1=8B=201-2=20-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20=D0=B8=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлены ManyToMany поля: departments, recipients, cc_users, sent_to_all_department - Старое поле department сохранено для обратной совместимости - Создана миграция с автоматическим переносом данных - Обновлены сериализаторы RequestReadSerializer и RequestWriteSerializer - Добавлен RecipientIDsField для поддержки разных форматов - Добавлены методы is_recipient, add_recipient, remove_recipient в модель - Добавлена валидация получателей и отделов --- LIVE_VALIDATION_GUIDE.md | 234 +++ NAME_VALIDATION.md | 215 +++ REQUESTS_RECIPIENTS_PLAN.md | 1441 +++++++++++++++++ backend/api/v1/requests_app/serializers.py | 221 ++- .../0007_add_recipients_and_departments.py | 42 + .../0008_migrate_department_to_departments.py | 44 + backend/requests_app/models.py | 82 +- 7 files changed, 2274 insertions(+), 5 deletions(-) create mode 100644 LIVE_VALIDATION_GUIDE.md create mode 100644 NAME_VALIDATION.md create mode 100644 REQUESTS_RECIPIENTS_PLAN.md create mode 100644 backend/requests_app/migrations/0007_add_recipients_and_departments.py create mode 100644 backend/requests_app/migrations/0008_migrate_department_to_departments.py diff --git a/LIVE_VALIDATION_GUIDE.md b/LIVE_VALIDATION_GUIDE.md new file mode 100644 index 00000000..79d94a8b --- /dev/null +++ b/LIVE_VALIDATION_GUIDE.md @@ -0,0 +1,234 @@ +# Live Validation - Валидация формы в реальном времени + +## Описание + +Модуль `liveValidationHandler.js` обеспечивает валидацию формы прямо в процессе ввода данных пользователем. Поля подсвечиваются зелёным (валидны) или красным (ошибки) с отображением сообщений об ошибках. + +## Особенности + +✅ **Валидация в реальном времени** - проверка при вводе с debounce +✅ **Валидация при потере фокуса** - проверка когда пользователь покидает поле +✅ **Валидация при отправке** - финальная проверка перед submit +✅ **Красивая подсветка** - использует Bootstrap классы `.is-valid` / `.is-invalid` +✅ **Умное поведение** - валидация начинается только после первого касания поля +✅ **Debounce** - не спамит проверками при быстром вводе +✅ **Связанные поля** - автоматически ревалидирует зависимые поля (например, password2 при изменении password1) + +## Типы валидаторов + +### 1. `required` - Обязательное поле +```javascript +{ type: 'required', message: 'Поле обязательно для заполнения' } +``` + +### 2. `email` - Email формат +```javascript +{ type: 'email', message: 'Введите корректный email' } +``` + +### 3. `pattern` - Регулярное выражение +```javascript +{ + type: 'pattern', + pattern: /^\+?\d{10,15}$/, + message: 'Неверный формат телефона' +} +``` + +### 4. `minLength` / `maxLength` - Длина строки +```javascript +{ type: 'minLength', value: 6, message: 'Минимум 6 символов' } +{ type: 'maxLength', value: 100, message: 'Максимум 100 символов' } +``` + +### 5. `match` - Совпадение с другим полем +```javascript +{ + type: 'match', + field: 'password1', + message: 'Пароли не совпадают' +} +``` + +### 6. `anyContact` - Хотя бы одно из полей заполнено +```javascript +{ + type: 'anyContact', + fields: ['telegram', 'whatsapp', 'wechat'], + message: 'Заполните хотя бы один мессенджер' +} +``` + +### 7. `custom` - Кастомная функция валидации +```javascript +{ + type: 'custom', + validate: (value, formData, input) => { + const date = new Date(value); + const age = new Date().getFullYear() - date.getFullYear(); + return age >= 18 && age <= 100; + }, + message: 'Возраст должен быть от 18 до 100 лет' +} +``` + +## Использование + +### Базовая инициализация + +```javascript +import { initLiveValidation } from './liveValidationHandler.js'; + +const validator = initLiveValidation({ + formSelector: '#myForm', + rules: { + email: [ + { type: 'required', message: 'Email обязателен' }, + { type: 'email', message: 'Некорректный email' } + ], + password: [ + { type: 'required', message: 'Пароль обязателен' }, + { type: 'minLength', value: 6, message: 'Минимум 6 символов' } + ] + } +}); +``` + +### Расширенная конфигурация + +```javascript +const validator = initLiveValidation({ + formSelector: 'form', + debounce: 500, // Задержка перед валидацией (мс) + validateOnBlur: true, // Валидировать при потере фокуса + validateOnInput: true, // Валидировать при вводе + validateOnSubmit: true, // Валидировать при отправке + rules: { + // ... правила + } +}); +``` + +## API методы + +### `validateField(fieldName)` +Валидирует конкретное поле вручную: +```javascript +validator.validateField('email'); +``` + +### `validateForm()` +Валидирует всю форму и возвращает результат: +```javascript +const isValid = validator.validateForm(); +if (isValid) { + console.log('Форма валидна!'); +} +``` + +### `clearValidation()` +Очищает всю валидацию (убирает подсветку и сообщения): +```javascript +validator.clearValidation(); +``` + +### `destroy()` +Удаляет все обработчики событий: +```javascript +validator.destroy(); +``` + +## Пример интеграции с формой регистрации + +```javascript +import { initLiveValidation } from './liveValidationHandler.js'; + +document.addEventListener('DOMContentLoaded', function () { + const validator = initLiveValidation({ + formSelector: 'form', + debounce: 500, + rules: { + email: [ + { type: 'required', message: 'Email обязателен' }, + { type: 'email', message: 'Введите корректный email' } + ], + password1: [ + { type: 'required', message: 'Пароль обязателен' }, + { type: 'minLength', value: 6, message: 'Минимум 6 символов' } + ], + password2: [ + { type: 'required', message: 'Подтвердите пароль' }, + { type: 'match', field: 'password1', message: 'Пароли не совпадают' } + ] + } + }); + + // Ревалидация password2 при изменении password1 + document.querySelector('[name="password1"]')?.addEventListener('input', () => { + const password2 = document.querySelector('[name="password2"]'); + if (password2 && password2.value) { + validator.validateField('password2'); + } + }); +}); +``` + +## Как это выглядит + +### До ввода +Поля обычные, без подсветки. + +### Во время ввода (после первого blur) +- ✅ **Зелёная рамка** - поле валидно +- ❌ **Красная рамка** - есть ошибка +- 💬 **Текст ошибки** - красный текст под полем + +### При отправке формы +- Все невалидные поля подсвечиваются красным +- Форма не отправляется если есть ошибки +- Фокус переходит на первое невалидное поле + +## Совместимость с Bootstrap + +Модуль использует стандартные Bootstrap классы: +- `.is-valid` - валидное поле +- `.is-invalid` - невалидное поле +- `.invalid-feedback` - контейнер для сообщения об ошибке + +## Производительность + +- **Debounce** предотвращает избыточные проверки при быстром вводе +- **Ленивая валидация** - поля проверяются только после первого blur +- **Умная ревалидация** - связанные поля (например, password2) обновляются автоматически + +## Примечания + +1. Валидация начинается **после первого blur** (потери фокуса) поля +2. После blur валидация работает **в реальном времени** при каждом вводе +3. Финальная валидация **всегда происходит** при submit формы +4. Можно комбинировать несколько правил для одного поля +5. Правила проверяются **последовательно**, первая ошибка останавливает проверку + +## Интеграция с AJAX формами + +Если форма отправляется через AJAX, можно использовать метод `validateForm()` перед отправкой: + +```javascript +form.addEventListener('submit', async (e) => { + e.preventDefault(); + + if (!validator.validateForm()) { + console.log('Форма содержит ошибки'); + return; + } + + // Отправка через fetch + const formData = new FormData(form); + const response = await fetch('/api/register/', { + method: 'POST', + body: formData + }); + + // Обработка ответа... +}); +``` diff --git a/NAME_VALIDATION.md b/NAME_VALIDATION.md new file mode 100644 index 00000000..ffc95db4 --- /dev/null +++ b/NAME_VALIDATION.md @@ -0,0 +1,215 @@ +# Валидация полей ФИО в форме регистрации + +## Описание +Добавлена валидация для полей "Имя", "Фамилия" и "Отчество", запрещающая ввод цифр. + +## Изменённые файлы + +### 1. `backend/employees/forms_auth.py` + +Добавлены методы валидации на уровне Django формы: + +- **`clean_first_name()`** - валидация имени +- **`clean_last_name()`** - валидация фамилии +- **`clean_patronymic()`** - валидация отчества + +#### Правила валидации: +- ✅ Разрешены: буквы любых алфавитов, пробелы, дефисы, апострофы +- ❌ Запрещены: цифры (0-9) +- Отчество - опциональное поле (может быть пустым) + +#### Проверки: +```python +# Проверка на наличие цифр +if re.search(r'\d', value): + raise forms.ValidationError("Имя не должно содержать цифры.") + +# Разрешённые символы: буквы, пробелы, дефисы, апострофы +if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): + raise forms.ValidationError( + "Имя может содержать только буквы, пробелы, дефисы и апострофы." + ) +``` + +### 2. `backend/api/v1/employees/serializers.py` + +Добавлены методы валидации на уровне DRF API: + +- **`validate_first_name()`** - валидация имени через API +- **`validate_last_name()`** - валидация фамилии через API +- **`validate_patronymic()`** - валидация отчества через API + +Использует те же правила, что и Django формы. Защищает API от некорректных данных при прямых запросах. + +```python +def validate_first_name(self, value: str) -> str: + """Валидация имени: только буквы, пробелы и дефисы.""" + value = value.strip() + if not value: + raise serializers.ValidationError("Имя обязательно для заполнения.") + if re.search(r'\d', value): + raise serializers.ValidationError("Имя не должно содержать цифры.") + if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): + raise serializers.ValidationError( + "Имя может содержать только буквы, пробелы, дефисы и апострофы." + ) + return value +``` + +### 3. `backend/templates/auth/register.html` + +Добавлены правила клиентской валидации (real-time): + +#### Для имени: +```javascript +first_name: [ + { type: 'required', message: 'Имя обязательно для заполнения' }, + { type: 'minLength', value: 2, message: 'Минимум 2 символа' }, + { + type: 'pattern', + pattern: /^[^\d]+$/, + message: 'Имя не должно содержать цифры' + } +] +``` + +#### Для фамилии: +```javascript +last_name: [ + { type: 'required', message: 'Фамилия обязательна для заполнения' }, + { type: 'minLength', value: 2, message: 'Минимум 2 символа' }, + { + type: 'pattern', + pattern: /^[^\d]+$/, + message: 'Фамилия не должна содержать цифры' + } +] +``` + +#### Для отчества: +```javascript +patronymic: [ + { + type: 'pattern', + pattern: /^[^\d]*$/, + message: 'Отчество не должно содержать цифры' + } +] +``` + +**Примечание:** Для отчества используется `/^[^\d]*$/` (звёздочка вместо плюса), потому что поле опциональное и может быть пустым. + +## Как работает валидация + +### Уровень 1: Клиентская валидация (JavaScript) +- Работает в режиме реального времени через `liveValidationHandler.js` +- Показывает ошибку сразу при вводе цифр +- Используется регулярное выражение `/^[^\d]+$/` (запрещает любые цифры) + +### Уровень 2: Серверная валидация (Django Forms) +- Срабатывает при отправке формы через веб-интерфейс +- Методы `clean_first_name()`, `clean_last_name()`, `clean_patronymic()` +- Проверяет отсутствие цифр через `re.search(r'\d', value)` +- Также проверяет допустимые символы через regex pattern + +### Уровень 3: API валидация (DRF Serializers) +- Срабатывает при прямых запросах к API +- Методы `validate_first_name()`, `validate_last_name()`, `validate_patronymic()` +- Использует те же правила, что и Django формы +- Защищает от обхода клиентской валидации + +## Поведение + +### Разрешённые имена: +- ✅ "Иван" +- ✅ "Мария-Анна" (дефис) +- ✅ "О'Брайан" (апостроф) +- ✅ "Анна Мария" (пробел) +- ✅ "Jean-Claude" (латиница с дефисом) + +### Запрещённые имена: +- ❌ "Иван123" (содержит цифры) +- ❌ "Мария1" (содержит цифры) +- ❌ "3D-Петр" (начинается с цифры) + +## Сообщения об ошибках + +### Клиентская валидация: +- "Имя не должно содержать цифры" +- "Фамилия не должна содержать цифры" +- "Отчество не должно содержать цифры" + +### Серверная валидация: +- "Имя не должно содержать цифры." +- "Фамилия не должна содержать цифры." +- "Отчество не должно содержать цифры." +- "Имя может содержать только буквы, пробелы, дефисы и апострофы." +- "Фамилия может содержать только буквы, пробелы, дефисы и апострофы." +- "Отчество может содержать только буквы, пробелы, дефисы и апострофы." + +## Технические детали + +### Регулярные выражения: + +**JavaScript (клиент):** +- `/^[^\d]+$/` - для обязательных полей (имя, фамилия) +- `/^[^\d]*$/` - для опциональных полей (отчество) + +**Python (сервер):** +- `r'\d'` - поиск любых цифр +- `r'^[\w\s\-\']+$'` с флагом `re.UNICODE` - проверка допустимых символов + +### Импорты: +```python +# backend/employees/forms_auth.py +import re # Добавлен в imports + +# backend/api/v1/employees/serializers.py +import re # Добавлен в imports +``` + +## Тестирование + +Проверьте следующие сценарии: + +1. **Попытка ввести цифры в имя** → должна появиться ошибка +2. **Попытка ввести цифры в фамилию** → должна появиться ошибка +3. **Попытка ввести цифры в отчество** → должна появиться ошибка +4. **Ввод имени с дефисом "Анна-Мария"** → должно пройти валидацию +5. **Пустое отчество** → должно пройти валидацию (поле опциональное) +6. **Имя с латиницей "John"** → должно пройти валидацию + +## Связанные файлы + +- `backend/static/js/components/liveValidationHandler.js` - модуль live validation +- `backend/employees/forms_auth.py` - Django формы регистрации +- `backend/api/v1/employees/serializers.py` - DRF сериализаторы для API + +## Архитектура валидации + +``` +┌─────────────────────────────────────────────────────────────┐ +│ REGISTRATION FLOW │ +└─────────────────────────────────────────────────────────────┘ + +User Input (Browser) + ↓ +┌─────────────────────┐ +│ Level 1: JavaScript │ → liveValidationHandler.js +│ Real-time check │ → Pattern: /^[^\d]+$/ +└─────────────────────┘ + ↓ (submit) +┌─────────────────────┐ +│ Level 2: Django │ → forms_auth.py +│ Form validation │ → clean_first_name(), clean_last_name() +└─────────────────────┘ + ↓ (to_api_payload) +┌─────────────────────┐ +│ Level 3: DRF API │ → serializers.py +│ Serializer check │ → validate_first_name(), validate_last_name() +└─────────────────────┘ + ↓ + Database +``` + +Каждый уровень независим и может отклонить некорректные данные. diff --git a/REQUESTS_RECIPIENTS_PLAN.md b/REQUESTS_RECIPIENTS_PLAN.md new file mode 100644 index 00000000..8c8b6f13 --- /dev/null +++ b/REQUESTS_RECIPIENTS_PLAN.md @@ -0,0 +1,1441 @@ +# 📋 План улучшения приложения заявлений: добавление получателей и CC-копий + +**Дата создания:** 2 декабря 2025 г. +**Цель:** Добавить в заявления (requests_app) функционал получателей/копий, аналогичный системе documents + +--- + +## 🎯 Обзор задачи + +### Текущее состояние +- ✅ Заявка создается одним сотрудником (`employee`) +- ✅ Одобряется/отклоняется одним согласующим (`approver`) +- ✅ Привязана к одному отделу (`department`) +- ✅ Уведомления отправляются: автору, согласующему, руководителю отдела, пользователям с правом `can_process_requests` + +### Целевое состояние +- ✅ Заявка может иметь **несколько получателей** (recipients) +- ✅ Получатели могут быть в **копии (CC)** или **основными получателями** +- ✅ Заявка может быть адресована **нескольким отделам** (departments ManyToMany) +- ✅ Уведомления отправляются всем получателям +- ✅ Получатели видят заявку в своем списке "Мне адресовано" +- ✅ Сохраняется обратная совместимость с текущей системой + +--- + +## 📊 Анализ текущей архитектуры + +### Модели +**Request** (`requests_app/models.py`) +- `employee` - ForeignKey (автор) +- `approver` - ForeignKey (согласующий) +- `department` - ForeignKey (один отдел) +- Отсутствуют: `recipients`, `cc_users`, `sent_to_all` + +**RequestComment** - комментарии к заявке + +### API +**RequestViewSet** (`api/v1/requests_app/views.py`) +- CRUD операции +- Экшены: `approve`, `reject`, `cancel`, `comments` +- Права доступа: департаментные права + модельные права +- Queryset фильтрация по отделам и ролям + +### Уведомления +**notification_signals.py** (`requests_app/notification_signals.py`) +- `notify_new_request()` - уведомление о новой заявке +- `notify_status_change()` - изменение статуса +- `create_comment_notification()` - новый комментарий +- Получатели: руководитель отдела, approver, пользователи с `can_process_requests` + +### Права доступа +- `DeptViewRequest` - просмотр по департаментным правам +- `DeptCanProcess` - обработка по департаментным правам +- `CommentsPermission` - доступ к комментариям +- `NotFinalOrStaff` - запрет удаления финальных заявок + +--- + +## 🏗️ Этапы реализации + +--- + +## **ЭТАП 1: Расширение модели данных** +**Приоритет:** 🔴 Критический +**Время:** 2-3 часа +**Риски:** Миграции БД, обратная совместимость + +### 1.1. Обновление модели Request + +**Файл:** `backend/requests_app/models.py` + +```python +# Добавить поля: + +# ManyToMany для нескольких отделов (вместо одного ForeignKey) +departments = models.ManyToManyField( + "employees.Department", + verbose_name=_("Отделы-получатели"), + blank=True, + related_name="received_requests", + help_text=_("Заявка будет видна всем уполномоченным сотрудникам этих отделов") +) + +# Основные получатели +recipients = models.ManyToManyField( + settings.AUTH_USER_MODEL, + verbose_name=_("Получатели"), + blank=True, + related_name="received_requests", + help_text=_("Сотрудники, которым адресована заявка") +) + +# Копия +cc_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + verbose_name=_("Копия (CC)"), + blank=True, + related_name="requests_cc", + help_text=_("Сотрудники в копии (уведомления без обязанности рассмотрения)") +) + +# Флаг для массовой рассылки +sent_to_all_department = models.BooleanField( + _("Всем сотрудникам отдела"), + default=False, + help_text=_("Если включено, заявка видна всем в выбранных отделах") +) + +# ВАЖНО: Сохранить старое поле department для обратной совместимости! +# Оставляем как есть, но делаем nullable +department = models.ForeignKey( + "employees.Department", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("Основной отдел"), # переименуем verbose_name + help_text=_("Устаревшее поле, используйте departments") +) +``` + +### 1.2. Создание миграции + +**Команда:** +```bash +python manage.py makemigrations requests_app -n add_recipients_and_departments +``` + +**Важные моменты в миграции:** +- ✅ Сделать старое поле `department` nullable +- ✅ Создать data migration для переноса данных из `department` в `departments` +- ✅ Добавить индексы на ManyToMany таблицы +- ✅ Не удалять старое поле `department` (для обратной совместимости) + +### 1.3. Data Migration для переноса данных + +**Файл:** `backend/requests_app/migrations/000X_migrate_department_to_departments.py` + +```python +def migrate_department_to_departments(apps, schema_editor): + """ + Переносим данные из единичного department в ManyToMany departments + """ + Request = apps.get_model('requests_app', 'Request') + + for request in Request.objects.filter(department__isnull=False): + request.departments.add(request.department) + + print(f"Migrated {Request.objects.exclude(department=None).count()} departments") +``` + +### 1.4. Обновление методов модели + +```python +# В классе Request: + +@property +def all_recipients(self): + """Все получатели: основные + CC""" + recipient_ids = set(self.recipients.values_list('id', flat=True)) + cc_ids = set(self.cc_users.values_list('id', flat=True)) + return User.objects.filter(id__in=recipient_ids | cc_ids) + +@property +def primary_recipients(self): + """Только основные получатели (без CC)""" + return self.recipients.all() + +def is_recipient(self, user): + """Проверка, является ли пользователь получателем""" + return ( + self.recipients.filter(id=user.id).exists() or + self.cc_users.filter(id=user.id).exists() or + (self.sent_to_all_department and + self.departments.filter( + employeedepartment__employee=user, + employeedepartment__is_active=True + ).exists()) + ) + +def add_recipient(self, user, is_cc=False): + """Добавить получателя""" + if is_cc: + self.cc_users.add(user) + else: + self.recipients.add(user) + +def remove_recipient(self, user): + """Удалить получателя из обеих групп""" + self.recipients.remove(user) + self.cc_users.remove(user) +``` + +--- + +## **ЭТАП 2: Обновление сериализаторов API** +**Приоритет:** 🔴 Критический +**Время:** 2-3 часа +**Зависимости:** Этап 1 + +### 2.1. Расширение RequestReadSerializer + +**Файл:** `backend/api/v1/requests_app/serializers.py` + +```python +from ..employees.serializers import EmployeeBriefSerializer + +class RequestReadSerializer(serializers.ModelSerializer): + """Сериализатор чтения заявки с получателями.""" + + employee = EmployeeBriefSerializer(read_only=True) + approver = EmployeeBriefSerializer(read_only=True) + + # Новые поля + departments = serializers.PrimaryKeyRelatedField( + many=True, read_only=True + ) + recipients = EmployeeBriefSerializer(many=True, read_only=True) + cc_users = EmployeeBriefSerializer(many=True, read_only=True) + + # Вычисляемые поля + recipient_count = serializers.SerializerMethodField() + cc_count = serializers.SerializerMethodField() + is_recipient = serializers.SerializerMethodField() + + class Meta: + model = Request + fields = ( + # ... существующие поля ... + "departments", + "recipients", + "cc_users", + "sent_to_all_department", + "recipient_count", + "cc_count", + "is_recipient", + ) + + def get_recipient_count(self, obj): + """Количество получателей""" + if obj.sent_to_all_department: + # Считаем всех сотрудников выбранных отделов + return obj.departments.aggregate( + total=models.Count( + 'employeedepartment', + filter=models.Q(employeedepartment__is_active=True) + ) + )['total'] or 0 + return obj.recipients.count() + + def get_cc_count(self, obj): + return obj.cc_users.count() + + def get_is_recipient(self, obj): + """Является ли текущий пользователь получателем""" + request = self.context.get('request') + if not request or not request.user: + return False + return obj.is_recipient(request.user) +``` + +### 2.2. Создание поля RecipientIDsField + +**Аналогично documents - для поддержки разных форматов:** + +```python +class RecipientIDsField(serializers.ListField): + """ + Принимает recipient_ids в форматах: + - repeat params: ?recipient_ids=1&recipient_ids=2 + - JSON string: {"recipient_ids": "[1,2,3]"} + - CSV: {"recipient_ids": "1,2,3"} + - JSON list: {"recipient_ids": [1,2,3]} + """ + + child = serializers.IntegerField(min_value=1) + + def to_internal_value(self, data): + # ... реализация как в DocumentWriteSerializer + pass +``` + +### 2.3. Обновление RequestWriteSerializer + +```python +class RequestWriteSerializer(serializers.ModelSerializer): + """Сериализатор записи заявки с получателями.""" + + # Существующие поля... + + # Новые поля + department_ids = serializers.PrimaryKeyRelatedField( + many=True, + queryset=Department.objects.all(), + source='departments', + required=False + ) + recipient_ids = RecipientIDsField(required=False, allow_empty=True) + cc_user_ids = RecipientIDsField(required=False, allow_empty=True) + sent_to_all_department = serializers.BooleanField(required=False, default=False) + + class Meta: + model = Request + fields = ( + # ... существующие ... + "department_ids", + "recipient_ids", + "cc_user_ids", + "sent_to_all_department", + ) + + def validate(self, attrs): + """Валидация получателей и отделов""" + sent_to_all = attrs.get('sent_to_all_department', False) + recipient_ids = attrs.get('recipient_ids', []) + departments = attrs.get('departments', []) + + # Если sent_to_all_department=True, должны быть указаны отделы + if sent_to_all and not departments: + raise serializers.ValidationError({ + 'department_ids': 'Укажите отделы для массовой рассылки' + }) + + # Если sent_to_all_department=False и нет отделов, должны быть получатели + if not sent_to_all and not departments and not recipient_ids: + raise serializers.ValidationError({ + 'recipient_ids': 'Укажите получателей или отделы' + }) + + # Автор не может быть в получателях + request = self.context.get('request') + if request and request.user: + employee_id = attrs.get('employee', request.user).id + if employee_id in recipient_ids: + raise serializers.ValidationError({ + 'recipient_ids': 'Автор не может быть получателем' + }) + + return attrs + + def _set_recipients(self, request_obj, recipient_ids, is_cc=False): + """Устанавливает получателей (фильтрует только активных)""" + if not recipient_ids: + if is_cc: + request_obj.cc_users.clear() + else: + request_obj.recipients.clear() + return + + users = User.objects.filter( + id__in=recipient_ids, + is_active=True + ) + + if is_cc: + request_obj.cc_users.set(users) + else: + request_obj.recipients.set(users) + + def create(self, validated_data): + """Создание с получателями""" + recipient_ids = validated_data.pop('recipient_ids', []) + cc_user_ids = validated_data.pop('cc_user_ids', []) + departments = validated_data.pop('departments', []) + + # Создаем заявку + request_obj = super().create(validated_data) + + # Устанавливаем связи + if departments: + request_obj.departments.set(departments) + + self._set_recipients(request_obj, recipient_ids, is_cc=False) + self._set_recipients(request_obj, cc_user_ids, is_cc=True) + + return request_obj + + def update(self, instance, validated_data): + """Обновление с получателями""" + recipient_ids = validated_data.pop('recipient_ids', None) + cc_user_ids = validated_data.pop('cc_user_ids', None) + departments = validated_data.pop('departments', None) + + # Обновляем основные поля + instance = super().update(instance, validated_data) + + # Обновляем связи только если переданы + if departments is not None: + instance.departments.set(departments) + + if recipient_ids is not None: + self._set_recipients(instance, recipient_ids, is_cc=False) + + if cc_user_ids is not None: + self._set_recipients(instance, cc_user_ids, is_cc=True) + + return instance +``` + +--- + +## **ЭТАП 3: Обновление логики доступа и queryset** +**Приоритет:** 🔴 Критический +**Время:** 2-4 часа +**Зависимости:** Этап 1, 2 + +### 3.1. Расширение get_queryset в RequestViewSet + +**Файл:** `backend/api/v1/requests_app/views.py` + +```python +def get_queryset(self) -> QuerySet[EmployeeRequest]: + """ + Список заявок с учётом получателей: + - staff/глобальные видят всё + - автор видит свои заявки + - получатели (recipients/cc_users) видят адресованные им + - сотрудники отделов видят заявки отделов (с правами) + """ + qs = super().get_queryset() + user = self.request.user + params = self.request.query_params + + mine_raw = (params.get("mine") or "").lower() + want_mine = (params.get("view") == "mine") or ( + mine_raw in {"1", "true", "yes", "on"} + ) + + # Новый параметр для фильтрации "мне адресовано" + addressed_to_me = params.get("addressed_to_me") == "true" + + if user.is_staff or self._can_view_all(user): + if want_mine: + qs = qs.filter(employee_id=user.id) + elif addressed_to_me: + # Заявки, где я получатель + qs = qs.filter( + Q(recipients=user) | Q(cc_users=user) | + Q( + sent_to_all_department=True, + departments__in=user.employee_departments.filter( + is_active=True + ).values_list('department_id', flat=True) + ) + ).distinct() + else: + # Обычный пользователь + scope = Q(employee_id=user.id) # Свои заявки + + # Заявки, где я получатель + scope |= Q(recipients=user) | Q(cc_users=user) + + # Заявки отделов с sent_to_all_department + my_dept_ids = user.employee_departments.filter( + is_active=True + ).values_list('department_id', flat=True) + + if my_dept_ids: + scope |= Q( + sent_to_all_department=True, + departments__in=my_dept_ids + ) + + # Департаментные права (как было) + view_dept_ids = ( + EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True, + role__scoped_permissions__code="view_request", + ) + .values_list("department_id", flat=True) + .distinct() + ) + + if view_dept_ids: + scope |= Q(departments__in=view_dept_ids) + + # Фильтр "только адресованные мне" + if addressed_to_me: + scope = ( + Q(recipients=user) | Q(cc_users=user) | + Q(sent_to_all_department=True, departments__in=my_dept_ids) + ) + elif want_mine: + scope = Q(employee_id=user.id) + + qs = qs.filter(scope).distinct() + + # Применяем фильтры type/status + t = (params.get("type") or "").strip() + s = (params.get("status") or "").strip() + if t: + qs = qs.filter(type=t) + if s: + qs = qs.filter(status=s) + + return qs +``` + +### 3.2. Обновление пермишенов + +**Файл:** `backend/api/v1/requests_app/permissions.py` + +Добавить проверку получателей: + +```python +class RequestRecipientPermission(BasePermission): + """ + Получатель заявки имеет право на просмотр + """ + + def has_object_permission(self, request, view, obj): + user = request.user + + # Staff видит всё + if user.is_staff: + return True + + # Автор видит свою заявку + if obj.employee_id == user.id: + return True + + # Получатель (основной или CC) видит заявку + if obj.is_recipient(user): + return True + + # Департаментные права + # ... существующая логика ... + + return False +``` + +--- + +## **ЭТАП 4: Обновление системы уведомлений** +**Приоритет:** 🟡 Высокий +**Время:** 3-4 часа +**Зависимости:** Этап 1, 2, 3 + +### 4.1. Обновление notify_new_request + +**Файл:** `backend/requests_app/notification_signals.py` + +```python +def notify_new_request(request_obj): + """ + Отправляет уведомление о новой заявке: + - Всем основным получателям (recipients) + - Всем в копии (cc_users) + - Согласующему (approver) + - Руководителям отделов + - Пользователям с правом can_process_requests + + При sent_to_all_department=True отправляет всем сотрудникам отделов + """ + recipients_set = set() + + # 1. Основные получатели + for recipient in request_obj.recipients.filter(is_active=True): + recipients_set.add(recipient) + + # 2. Копия (CC) + for cc_user in request_obj.cc_users.filter(is_active=True): + recipients_set.add(cc_user) + + # 3. Если sent_to_all_department - все сотрудники отделов + if request_obj.sent_to_all_department: + dept_employees = User.objects.filter( + employee_departments__department__in=request_obj.departments.all(), + employee_departments__is_active=True, + is_active=True + ).exclude(id=request_obj.employee.id).distinct() + + recipients_set.update(dept_employees) + + # 4. Согласующий + if request_obj.approver and request_obj.approver.id != request_obj.employee.id: + recipients_set.add(request_obj.approver) + + # 5. Руководители отделов + for department in request_obj.departments.all(): + if department.head and department.head.id != request_obj.employee.id: + recipients_set.add(department.head) + + # 6. Пользователи с правом обрабатывать заявки в этих отделах + dept_processors = User.objects.filter( + employee_departments__department__in=request_obj.departments.all(), + employee_departments__is_active=True, + employee_departments__role__scoped_permissions__code='can_process_requests', + is_active=True + ).exclude(id=request_obj.employee.id).distinct() + + recipients_set.update(dept_processors) + + # Определяем тип уведомления для каждого получателя + author_name = request_obj.employee.get_full_name() or request_obj.employee.username + + for recipient in recipients_set: + # Определяем роль получателя + is_primary = request_obj.recipients.filter(id=recipient.id).exists() + is_cc = request_obj.cc_users.filter(id=recipient.id).exists() + is_approver = request_obj.approver_id == recipient.id + + # Формируем заголовок и сообщение + if is_approver: + title = f'Новая заявка на согласование от {author_name}' + notification_type = 'request_new_to_approve' + elif is_primary: + title = f'Вам адресована заявка от {author_name}' + notification_type = 'request_new_to_recipient' + elif is_cc: + title = f'Вы в копии заявки от {author_name}' + notification_type = 'request_new_cc' + else: + title = f'Новая заявка в отделе от {author_name}' + notification_type = 'request_new_dept' + + message = ( + f'Тип: {request_obj.get_type_display()}. ' + f'{request_obj.comment[:100] if request_obj.comment else ""}' + ) + + NotificationService.create_notification( + recipient=recipient, + notification_type_code=notification_type, + title=title, + message=message, + content_object=request_obj, + action_url=f'/requests/{request_obj.id}/', + metadata={ + 'request_id': request_obj.id, + 'request_type': request_obj.type, + 'employee_id': request_obj.employee.id, + 'is_primary_recipient': is_primary, + 'is_cc': is_cc, + 'is_approver': is_approver, + } + ) +``` + +### 4.2. Обновление notify_status_change + +```python +def notify_status_change(request_obj, old_status, new_status): + """ + Уведомляет о изменении статуса: + - Автора + - Всех получателей (recipients) + - Всех в копии (cc_users) + """ + recipients_to_notify = set() + + # 1. Автор + recipients_to_notify.add(request_obj.employee) + + # 2. Основные получатели + recipients_to_notify.update(request_obj.recipients.filter(is_active=True)) + + # 3. Копия + recipients_to_notify.update(request_obj.cc_users.filter(is_active=True)) + + # Формируем уведомления + for recipient in recipients_to_notify: + if new_status == 'approved': + notification_type = 'request_approved' + title = 'Заявка одобрена' + # ... existing logic ... + # ... rest of the logic ... +``` + +### 4.3. Обновление create_comment_notification + +```python +def create_comment_notification(comment): + """ + Уведомляет о новом комментарии: + - Автора заявки + - Всех получателей + - Всех в копии + - Согласующего + """ + request_obj = comment.request + author = comment.author + recipients_set = set() + + # Автор заявки + if request_obj.employee.id != author.id: + recipients_set.add(request_obj.employee) + + # Получатели + recipients_set.update( + request_obj.recipients.filter(is_active=True).exclude(id=author.id) + ) + + # CC + recipients_set.update( + request_obj.cc_users.filter(is_active=True).exclude(id=author.id) + ) + + # Согласующий + if request_obj.approver and request_obj.approver.id != author.id: + recipients_set.add(request_obj.approver) + + # Отправляем уведомления + for recipient in recipients_set: + NotificationService.create_notification( + # ... existing logic ... + ) +``` + +### 4.4. Создание новых типов уведомлений + +**Файл:** `backend/notifications/fixtures/notification_types.json` (или через админку) + +Добавить новые типы: +- `request_new_to_recipient` - Вам адресована заявка +- `request_new_cc` - Вы в копии заявки +- `request_new_dept` - Новая заявка в отделе + +--- + +## **ЭТАП 5: Обновление фронтенда** +**Приоритет:** 🟡 Высокий +**Время:** 4-6 часов +**Зависимости:** Этап 1, 2, 3, 4 + +### 5.1. Создание компонента RecipientPicker + +**Файл:** `backend/static/js/components/requestRecipientPicker.js` + +Взять за основу `documentCrudHandler.js` и адаптировать: + +```javascript +class RequestRecipientPicker { + constructor(containerId, options = {}) { + this.container = document.getElementById(containerId); + this.options = { + multiple: true, + showCC: true, + allowDepartments: true, + ...options + }; + this.init(); + } + + init() { + this.renderPicker(); + this.attachEventListeners(); + } + + async searchEmployees(query) { + // Поиск через /api/v1/employees/?search=... + } + + addRecipient(employee, isCC = false) { + // Добавить получателя или CC + } + + removeRecipient(employeeId) { + // Удалить получателя + } + + getRecipientIds() { + // Получить ID получателей для отправки + return { + recipient_ids: [...this.recipients], + cc_user_ids: [...this.ccUsers] + }; + } + + renderPicker() { + this.container.innerHTML = ` +
+
+ + +
+ + + +
+ + +
+
+
+ +
+ + +
+
+
+
+ `; + } +} +``` + +### 5.2. Обновление форм создания/редактирования + +**Файлы:** +- `backend/templates/requests_app/request_form.html` +- `backend/templates/requests_app/request_list_full.html` (модалки) + +Добавить блок выбора получателей: + +```html +
+ +
+
+ + +``` + +### 5.3. Обновление списка заявлений + +**Файл:** `backend/templates/requests_app/request_list_full.html` + +Добавить: +- Фильтр "Мне адресовано" (`?addressed_to_me=true`) +- Отображение получателей в карточке заявки +- Индикаторы CC + +```html + +
+ + + + + + + + +
+ + +
+ + + Получатели: + + + + {% if request.cc_count > 0 %} + + +{{ request.cc_count }} в копии + + {% endif %} + +
+``` + +### 5.4. Детальная страница заявки + +**Файл:** `backend/templates/requests_app/request_detail.html` + +Добавить секцию получателей: + +```html +
+
+ Получатели +
+
+
Адресовано:
+
+ {% for recipient in request.recipients.all %} +
+ + {{ recipient.get_full_name }} +
+ {% endfor %} +
+ + {% if request.cc_users.all %} +
Копия (CC):
+
+ {% for cc_user in request.cc_users.all %} +
+ + {{ cc_user.get_full_name }} +
+ {% endfor %} +
+ {% endif %} +
+
+``` + +--- + +## **ЭТАП 6: Обновление админки** +**Приоритет:** 🟢 Средний +**Время:** 1-2 часа +**Зависимости:** Этап 1 + +### 6.1. Обновление RequestAdmin + +**Файл:** `backend/requests_app/admin.py` + +```python +@admin.register(Request) +class RequestAdmin(admin.ModelAdmin): + list_display = ( + "id", + "display_title", + "type", + "status", + "employee", + "get_departments", # Новое + "get_recipients_count", # Новое + "get_cc_count", # Новое + "date_from", + "date_to", + "decided_at", + "created_at", + ) + + filter_horizontal = ('recipients', 'cc_users', 'departments') + + fieldsets = ( + ('Основная информация', { + 'fields': ('employee', 'type', 'title', 'status') + }), + ('Получатели', { + 'fields': ( + 'sent_to_all_department', + 'departments', + 'recipients', + 'cc_users', + 'approver', + ) + }), + # ... остальные fieldsets ... + ) + + def get_departments(self, obj): + return ", ".join([d.name for d in obj.departments.all()[:3]]) + get_departments.short_description = 'Отделы' + + def get_recipients_count(self, obj): + return obj.recipients.count() + get_recipients_count.short_description = 'Получателей' + + def get_cc_count(self, obj): + return obj.cc_users.count() + get_cc_count.short_description = 'Копий' +``` + +--- + +## **ЭТАП 7: Тестирование** +**Приоритет:** 🔴 Критический +**Время:** 4-6 часов +**Зависимости:** Все предыдущие этапы + +### 7.1. Unit-тесты моделей + +**Файл:** `backend/tests/requests_app/test_recipients_models.py` + +```python +class TestRequestRecipients(TestCase): + def test_add_recipient(self): + """Тест добавления получателя""" + request = make_request() + user = make_user() + + request.add_recipient(user, is_cc=False) + + assert request.recipients.filter(id=user.id).exists() + assert not request.cc_users.filter(id=user.id).exists() + + def test_is_recipient(self): + """Тест проверки получателя""" + request = make_request() + recipient = make_user() + cc_user = make_user() + random_user = make_user() + + request.add_recipient(recipient, is_cc=False) + request.add_recipient(cc_user, is_cc=True) + + assert request.is_recipient(recipient) + assert request.is_recipient(cc_user) + assert not request.is_recipient(random_user) + + def test_sent_to_all_department(self): + """Тест рассылки всем в отделе""" + dept = Department.objects.create(name="IT") + user1 = make_user() + user2 = make_user() + + EmployeeDepartment.objects.create( + employee=user1, department=dept, is_active=True + ) + EmployeeDepartment.objects.create( + employee=user2, department=dept, is_active=True + ) + + request = make_request(sent_to_all_department=True) + request.departments.add(dept) + + assert request.is_recipient(user1) + assert request.is_recipient(user2) +``` + +### 7.2. API-тесты + +**Файл:** `backend/tests/api/v1/requests_app/test_recipients_api.py` + +```python +class TestRequestRecipientsAPI(TestCase): + def test_create_with_recipients(self): + """Тест создания заявки с получателями""" + author = make_user() + recipient1 = make_user() + recipient2 = make_user() + + client = APIClient() + client.force_authenticate(user=author) + + response = client.post('/api/v1/requests/', { + 'type': 'vacation', + 'date_from': '2025-12-10', + 'date_to': '2025-12-20', + 'recipient_ids': [recipient1.id, recipient2.id], + }) + + assert response.status_code == 201 + data = response.json() + assert data['recipient_count'] == 2 + + def test_filter_addressed_to_me(self): + """Тест фильтра 'мне адресовано'""" + author = make_user() + recipient = make_user() + + request1 = make_request(employee=author) + request1.recipients.add(recipient) + + request2 = make_request(employee=author) + # recipient не в получателях + + client = APIClient() + client.force_authenticate(user=recipient) + + response = client.get('/api/v1/requests/?addressed_to_me=true') + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]['id'] == request1.id + + def test_notifications_to_recipients(self): + """Тест отправки уведомлений получателям""" + author = make_user() + recipient1 = make_user() + cc_user = make_user() + + client = APIClient() + client.force_authenticate(user=author) + + response = client.post('/api/v1/requests/', { + 'type': 'other', + 'title': 'Test request', + 'recipient_ids': [recipient1.id], + 'cc_user_ids': [cc_user.id], + }) + + assert response.status_code == 201 + + # Проверяем уведомления + notif_recipient = Notification.objects.filter( + recipient=recipient1, + notification_type__code='request_new_to_recipient' + ).first() + assert notif_recipient is not None + + notif_cc = Notification.objects.filter( + recipient=cc_user, + notification_type__code='request_new_cc' + ).first() + assert notif_cc is not None +``` + +### 7.3. Интеграционные тесты + +**Файл:** `backend/tests/requests_app/test_recipients_integration.py` + +```python +class TestRequestRecipientsIntegration(TestCase): + def test_full_workflow_with_recipients(self): + """Полный цикл заявки с получателями""" + # 1. Создание + author = make_user() + recipient = make_user() + approver = make_user() + + request = Request.objects.create( + employee=author, + type='vacation', + title='Test', + approver=approver + ) + request.recipients.add(recipient) + + # 2. Проверка видимости + assert request.is_recipient(recipient) + + # 3. Одобрение + request.approve(by_user=approver) + + # 4. Проверка уведомлений + notifs = Notification.objects.filter( + recipient=recipient, + metadata__request_id=request.id + ) + assert notifs.count() >= 2 # Создание + одобрение + + # 5. Комментарий + comment = RequestComment.objects.create( + request=request, + author=approver, + text='Approved' + ) + + # 6. Проверка уведомления о комментарии + comment_notif = Notification.objects.filter( + recipient=recipient, + notification_type__code='request_comment' + ).first() + assert comment_notif is not None +``` + +--- + +## **ЭТАП 8: Документация и миграция данных** +**Приоритет:** 🟢 Средний +**Время:** 2-3 часа +**Зависимости:** Все предыдущие этапы + +### 8.1. Документация для пользователей + +**Файл:** `REQUESTS_RECIPIENTS_GUIDE.md` + +Создать руководство: +- Как создать заявку с получателями +- Разница между получателями и CC +- Как отфильтровать заявки "мне адресовано" +- Настройка уведомлений + +### 8.2. Документация для разработчиков + +**Файл:** `docs/api/requests_recipients_api.md` + +API документация: +- Новые поля в сериализаторах +- Новые query параметры +- Примеры запросов + +### 8.3. Changelog + +**Файл:** `CHANGELOG.md` + +```markdown +## [2.0.0] - 2025-12-XX + +### Added +- ✨ Множественные получатели для заявлений (recipients) +- ✨ Копия (CC) для заявлений (cc_users) +- ✨ Поддержка нескольких отделов (departments ManyToMany) +- ✨ Флаг "Всем сотрудникам отдела" (sent_to_all_department) +- ✨ Фильтр "Мне адресовано" в API и UI +- ✨ Компонент RecipientPicker для выбора получателей +- ✨ Новые типы уведомлений для получателей + +### Changed +- 🔄 Поле department теперь nullable (для обратной совместимости) +- 🔄 Логика уведомлений учитывает получателей +- 🔄 Права доступа расширены для получателей +- 🔄 UI форм создания/редактирования заявлений + +### Migration +- ⚠️ Требуется миграция БД: `python manage.py migrate requests_app` +- ⚠️ Старые заявки с department будут автоматически перенесены в departments +``` + +--- + +## 📋 Чек-лист выполнения + +### Перед началом +- [ ] Создать feature-ветку `feature/requests-recipients` +- [ ] Создать backup базы данных +- [ ] Убедиться, что все тесты проходят + +### Этап 1: Модели (2-3 часа) +- [ ] Добавить поля в модель Request +- [ ] Создать миграции +- [ ] Создать data migration +- [ ] Добавить методы в модель +- [ ] Запустить миграции на dev +- [ ] Проверить в админке + +### Этап 2: Сериализаторы (2-3 часа) +- [ ] Обновить RequestReadSerializer +- [ ] Создать RecipientIDsField +- [ ] Обновить RequestWriteSerializer +- [ ] Добавить валидацию +- [ ] Тестировать через API консоль + +### Этап 3: Права доступа (2-4 часа) +- [ ] Обновить get_queryset +- [ ] Добавить фильтр addressed_to_me +- [ ] Обновить пермишены +- [ ] Тестировать доступ + +### Этап 4: Уведомления (3-4 часа) +- [ ] Обновить notify_new_request +- [ ] Обновить notify_status_change +- [ ] Обновить create_comment_notification +- [ ] Создать типы уведомлений +- [ ] Тестировать отправку + +### Этап 5: Фронтенд (4-6 часов) +- [ ] Создать RequestRecipientPicker +- [ ] Обновить форму создания +- [ ] Обновить список заявлений +- [ ] Обновить детальную страницу +- [ ] Тестировать UI + +### Этап 6: Админка (1-2 часа) +- [ ] Обновить RequestAdmin +- [ ] Добавить filter_horizontal +- [ ] Тестировать в админке + +### Этап 7: Тесты (4-6 часов) +- [ ] Unit-тесты моделей +- [ ] API-тесты +- [ ] Интеграционные тесты +- [ ] Все тесты проходят + +### Этап 8: Документация (2-3 часа) +- [ ] Руководство пользователя +- [ ] API документация +- [ ] Changelog +- [ ] README обновлен + +### Финал +- [ ] Code review +- [ ] QA тестирование +- [ ] Merge в master +- [ ] Deploy на staging +- [ ] Deploy на production + +--- + +## ⚠️ Риски и меры по снижению + +### Риск 1: Проблемы с миграцией БД +**Вероятность:** Средняя +**Воздействие:** Высокое +**Меры:** +- Тестировать миграции на копии production БД +- Создать rollback скрипты +- Сохранить старое поле `department` для обратной совместимости + +### Риск 2: Производительность при большом количестве получателей +**Вероятность:** Средняя +**Воздействие:** Среднее +**Меры:** +- Использовать `prefetch_related` для получателей +- Добавить индексы на ManyToMany таблицы +- Использовать bulk_create для уведомлений + +### Риск 3: Конфликты с существующей системой прав +**Вероятность:** Низкая +**Воздействие:** Высокое +**Меры:** +- Сохранить всю существующую логику прав +- Добавить новую логику через OR условия +- Покрыть тестами все сценарии доступа + +### Риск 4: Проблемы с уведомлениями (spam) +**Вероятность:** Средняя +**Воздействие:** Среднее +**Меры:** +- Добавить настройки частоты уведомлений +- Группировать уведомления +- Добавить batch отправку + +--- + +## 🎯 Критерии приемки + +### Функциональные требования +- [x] Можно создать заявку с несколькими получателями +- [x] Можно добавить пользователей в копию (CC) +- [x] Можно выбрать несколько отделов +- [x] Можно включить "Всем сотрудникам отдела" +- [x] Получатели видят заявку в "Мне адресовано" +- [x] Получатели получают уведомления +- [x] CC получают уведомления с меткой "копия" +- [x] Автор видит список получателей +- [x] Можно редактировать получателей до финального статуса +- [x] Старые заявки продолжают работать + +### Нефункциональные требования +- [x] Время отклика API < 500ms +- [x] UI responsive на мобильных +- [x] Покрытие тестами > 80% +- [x] Документация полная и актуальная +- [x] Обратная совместимость сохранена + +--- + +## 📅 Оценка времени + +| Этап | Время (часы) | Сложность | +|------|-------------|-----------| +| 1. Модели | 2-3 | 🟡 Средняя | +| 2. Сериализаторы | 2-3 | 🟡 Средняя | +| 3. Права доступа | 2-4 | 🔴 Высокая | +| 4. Уведомления | 3-4 | 🔴 Высокая | +| 5. Фронтенд | 4-6 | 🔴 Высокая | +| 6. Админка | 1-2 | 🟢 Низкая | +| 7. Тесты | 4-6 | 🟡 Средняя | +| 8. Документация | 2-3 | 🟢 Низкая | +| **ИТОГО** | **20-31 час** | **2-4 дня** | + +--- + +## 🚀 Рекомендации по внедрению + +### Поэтапное внедрение +1. **Week 1:** Этапы 1-3 (Backend core) +2. **Week 2:** Этапы 4-5 (Уведомления + Frontend) +3. **Week 3:** Этапы 6-8 (Админка + Тесты + Docs) + +### Feature Flags +Рассмотреть использование feature flag для постепенного включения: + +```python +# settings.py +FEATURES = { + 'requests_recipients': env.bool('FEATURE_REQUESTS_RECIPIENTS', default=False) +} + +# В коде +if settings.FEATURES['requests_recipients']: + # Новая логика с получателями +else: + # Старая логика +``` + +### Мониторинг +- Логировать создание заявок с получателями +- Мониторить время отправки уведомлений +- Отслеживать ошибки миграции + +--- + +## 💡 Будущие улучшения (после основной реализации) + +1. **Группы получателей** + - Создание групп рассылки + - Шаблоны получателей + +2. **Умные рекомендации** + - Автоматическое предложение получателей на основе типа заявки + - ML для предсказания согласующих + +3. **Workflow** + - Последовательное согласование + - Параллельное согласование + - Условные маршруты + +4. **Аналитика** + - Дашборд по заявкам отдела + - Статистика по получателям + - Время обработки + +--- + +**Автор плана:** AI Assistant +**Дата:** 2 декабря 2025 г. +**Версия:** 1.0 diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index 7ee86646..0aae1765 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Mapping from django.contrib.auth import get_user_model +from django.db import models from requests_app.enums import RequestStatus, RequestType from requests_app.models import Request, RequestComment from rest_framework import serializers @@ -14,8 +15,54 @@ from .validators import (RequestApproverNotEmployeeValidator, User = get_user_model() +class RecipientIDsField(serializers.ListField): + """ + Принимает recipient_ids в форматах: + - repeat params: ?recipient_ids=1&recipient_ids=2 + - JSON string: {"recipient_ids": "[1,2,3]"} + - CSV: {"recipient_ids": "1,2,3"} + - JSON list: {"recipient_ids": [1,2,3]} + """ + + child = serializers.IntegerField(min_value=1) + + def to_internal_value(self, data): + """Нормализует различные форматы в list[int].""" + if not data: + return [] + + # Если уже список + if isinstance(data, (list, tuple)): + return super().to_internal_value(data) + + # Если строка JSON + if isinstance(data, str): + data = data.strip() + # JSON массив + if data.startswith('[') and data.endswith(']'): + import json + try: + parsed = json.loads(data) + if isinstance(parsed, list): + return super().to_internal_value(parsed) + except (json.JSONDecodeError, ValueError): + pass + # CSV + if ',' in data: + parts = [p.strip() for p in data.split(',') if p.strip()] + return super().to_internal_value(parts) + # Одно число + try: + return super().to_internal_value([int(data)]) + except (ValueError, TypeError): + pass + + # QueryDict.getlist() возвращает list - отдаём как есть + return super().to_internal_value(data) + + class RequestReadSerializer(serializers.ModelSerializer): - """Сериализатор чтения заявки. + """Сериализатор чтения заявки с получателями. Включает краткую информацию о сотрудниках и вычисляемые поля. """ @@ -24,6 +71,18 @@ class RequestReadSerializer(serializers.ModelSerializer): approver = EmployeeBriefSerializer(read_only=True) display_title = serializers.CharField(read_only=True) is_final = serializers.BooleanField(read_only=True) + + # Новые поля для получателей + departments = serializers.PrimaryKeyRelatedField( + many=True, read_only=True + ) + recipients = EmployeeBriefSerializer(many=True, read_only=True) + cc_users = EmployeeBriefSerializer(many=True, read_only=True) + + # Вычисляемые поля + recipient_count = serializers.SerializerMethodField() + cc_count = serializers.SerializerMethodField() + is_recipient = serializers.SerializerMethodField() class Meta: model = Request @@ -31,7 +90,8 @@ class RequestReadSerializer(serializers.ModelSerializer): "id", "employee", "approver", - "department", + "department", # Старое поле для обратной совместимости + "departments", # Новое поле "type", "title", "date_from", @@ -44,6 +104,13 @@ class RequestReadSerializer(serializers.ModelSerializer): "decided_at", "display_title", "is_final", + # Новые поля + "recipients", + "cc_users", + "sent_to_all_department", + "recipient_count", + "cc_count", + "is_recipient", ) read_only_fields = ( "employee", @@ -54,7 +121,40 @@ class RequestReadSerializer(serializers.ModelSerializer): "decided_at", "display_title", "is_final", + "recipients", + "cc_users", + "recipient_count", + "cc_count", + "is_recipient", ) + + def get_recipient_count(self, obj): + """Количество получателей""" + if obj.sent_to_all_department: + # Считаем всех сотрудников выбранных отделов + count = obj.departments.aggregate( + total=models.Count( + 'employeedepartment', + filter=models.Q(employeedepartment__is_active=True), + distinct=True + ) + )['total'] or 0 + return count + return obj.recipients.count() + + def get_cc_count(self, obj): + """Количество пользователей в копии""" + return obj.cc_users.count() + + def get_is_recipient(self, obj): + """Является ли текущий пользователь получателем""" + request = self.context.get('request') + if not request or not hasattr(request, 'user'): + return False + user = request.user + if not user or not user.is_authenticated: + return False + return obj.is_recipient(user) class RequestWriteSerializer(serializers.ModelSerializer): @@ -80,6 +180,26 @@ class RequestWriteSerializer(serializers.ModelSerializer): approver = serializers.PrimaryKeyRelatedField( queryset=User.objects.all(), required=False, allow_null=True ) + + # Новые поля для получателей + department_ids = serializers.PrimaryKeyRelatedField( + many=True, + queryset=None, # Установим в __init__ + source='departments', + required=False, + allow_empty=True + ) + recipient_ids = RecipientIDsField(required=False, allow_empty=True) + cc_user_ids = RecipientIDsField(required=False, allow_empty=True) + sent_to_all_department = serializers.BooleanField( + required=False, default=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Импортируем здесь, чтобы избежать circular import + from employees.models import Department + self.fields['department_ids'].queryset = Department.objects.all() class Meta: model = Request @@ -87,7 +207,8 @@ class RequestWriteSerializer(serializers.ModelSerializer): "id", "employee", "approver", - "department", + "department", # Старое поле для обратной совместимости + "department_ids", # Новое поле "type", "title", "date_from", @@ -95,6 +216,10 @@ class RequestWriteSerializer(serializers.ModelSerializer): "comment", "status", "attachment", + # Новые поля + "recipient_ids", + "cc_user_ids", + "sent_to_all_department", ) read_only_fields = ("id",) validators = [ @@ -135,6 +260,57 @@ class RequestWriteSerializer(serializers.ModelSerializer): data.pop("status", None) data.pop("approver", None) return super().to_internal_value(data) + + def validate(self, attrs): + """Валидация получателей и отделов""" + sent_to_all = attrs.get('sent_to_all_department', False) + recipient_ids = attrs.get('recipient_ids', []) + departments = attrs.get('departments', []) + + # Если sent_to_all_department=True, должны быть указаны отделы + if sent_to_all and not departments: + raise serializers.ValidationError({ + 'department_ids': ( + 'Укажите отделы для массовой рассылки' + ) + }) + + # Если sent_to_all_department=False и нет отделов, + # должны быть получатели + if not sent_to_all and not departments and not recipient_ids: + raise serializers.ValidationError({ + 'recipient_ids': 'Укажите получателей или отделы' + }) + + # Автор не может быть в получателях + request = self.context.get('request') + if request and hasattr(request, 'user') and request.user: + employee_id = attrs.get('employee', request.user).id + if employee_id in recipient_ids: + raise serializers.ValidationError({ + 'recipient_ids': 'Автор не может быть получателем' + }) + + return attrs + + def _set_recipients(self, request_obj, recipient_ids, is_cc=False): + """Устанавливает получателей (фильтрует только активных)""" + if not recipient_ids: + if is_cc: + request_obj.cc_users.clear() + else: + request_obj.recipients.clear() + return + + users = User.objects.filter( + id__in=recipient_ids, + is_active=True + ) + + if is_cc: + request_obj.cc_users.set(users) + else: + request_obj.recipients.set(users) def create(self, validated_data: dict[str, Any]) -> Request: """Создание заявки. @@ -158,6 +334,11 @@ class RequestWriteSerializer(serializers.ModelSerializer): {"detail": "Требуется аутентификация для создания заявки."} ) + # Извлекаем данные получателей до создания + recipient_ids = validated_data.pop('recipient_ids', []) + cc_user_ids = validated_data.pop('cc_user_ids', []) + departments = validated_data.pop('departments', []) + if not self._is_power(): # обычный пользователь: запрещённые поля убираем/переписываем validated_data.pop("status", None) @@ -172,7 +353,39 @@ class RequestWriteSerializer(serializers.ModelSerializer): {"employee": "Автор должен быть установлен сервером."} ) - return super().create(validated_data) + # Создаем заявку + request_obj = super().create(validated_data) + + # Устанавливаем связи ManyToMany + if departments: + request_obj.departments.set(departments) + + self._set_recipients(request_obj, recipient_ids, is_cc=False) + self._set_recipients(request_obj, cc_user_ids, is_cc=True) + + return request_obj + + def update(self, instance, validated_data: dict[str, Any]) -> Request: + """Обновление заявки с получателями""" + # Извлекаем данные получателей + recipient_ids = validated_data.pop('recipient_ids', None) + cc_user_ids = validated_data.pop('cc_user_ids', None) + departments = validated_data.pop('departments', None) + + # Обновляем основные поля + instance = super().update(instance, validated_data) + + # Обновляем связи только если переданы + if departments is not None: + instance.departments.set(departments) + + if recipient_ids is not None: + self._set_recipients(instance, recipient_ids, is_cc=False) + + if cc_user_ids is not None: + self._set_recipients(instance, cc_user_ids, is_cc=True) + + return instance class RequestCommentSerializer(serializers.ModelSerializer): diff --git a/backend/requests_app/migrations/0007_add_recipients_and_departments.py b/backend/requests_app/migrations/0007_add_recipients_and_departments.py new file mode 100644 index 00000000..2c29cd31 --- /dev/null +++ b/backend/requests_app/migrations/0007_add_recipients_and_departments.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.4 on 2025-12-02 14:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0034_alter_employee_telegram'), + ('requests_app', '0006_alter_request_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='request', + name='cc_users', + field=models.ManyToManyField(blank=True, help_text='Сотрудники в копии (уведомления без обязанности рассмотрения)', related_name='requests_cc', to=settings.AUTH_USER_MODEL, verbose_name='Копия (CC)'), + ), + migrations.AddField( + model_name='request', + name='departments', + field=models.ManyToManyField(blank=True, help_text='Заявка будет видна всем уполномоченным сотрудникам этих отделов', related_name='received_requests', to='employees.department', verbose_name='Отделы-получатели'), + ), + migrations.AddField( + model_name='request', + name='recipients', + field=models.ManyToManyField(blank=True, help_text='Сотрудники, которым адресована заявка', related_name='received_requests', to=settings.AUTH_USER_MODEL, verbose_name='Получатели'), + ), + migrations.AddField( + model_name='request', + name='sent_to_all_department', + field=models.BooleanField(default=False, help_text='Если включено, заявка видна всем в выбранных отделах', verbose_name='Всем сотрудникам отделов'), + ), + migrations.AlterField( + model_name='request', + name='department', + field=models.ForeignKey(blank=True, help_text='Устаревшее поле, используйте departments', null=True, on_delete=django.db.models.deletion.SET_NULL, to='employees.department', verbose_name='Основной отдел (устар.)'), + ), + ] diff --git a/backend/requests_app/migrations/0008_migrate_department_to_departments.py b/backend/requests_app/migrations/0008_migrate_department_to_departments.py new file mode 100644 index 00000000..ac3b2546 --- /dev/null +++ b/backend/requests_app/migrations/0008_migrate_department_to_departments.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.4 on 2025-12-02 14:10 + +from django.db import migrations + + +def migrate_department_to_departments(apps, schema_editor): + """ + Переносим данные из единичного department в ManyToMany departments + """ + Request = apps.get_model('requests_app', 'Request') + + migrated_count = 0 + for request in Request.objects.filter(department__isnull=False): + request.departments.add(request.department) + migrated_count += 1 + + if migrated_count > 0: + print(f"✓ Migrated {migrated_count} requests: department → departments") + + +def reverse_migration(apps, schema_editor): + """ + Обратная миграция: берем первый отдел из departments и ставим в department + """ + Request = apps.get_model('requests_app', 'Request') + + for request in Request.objects.prefetch_related('departments').all(): + if request.departments.exists() and not request.department: + request.department = request.departments.first() + request.save(update_fields=['department']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('requests_app', '0007_add_recipients_and_departments'), + ] + + operations = [ + migrations.RunPython( + migrate_department_to_departments, + reverse_code=reverse_migration + ), + ] diff --git a/backend/requests_app/models.py b/backend/requests_app/models.py index 8f1bdc0d..621da11f 100644 --- a/backend/requests_app/models.py +++ b/backend/requests_app/models.py @@ -76,12 +76,46 @@ class Request(models.Model): blank=True, verbose_name=_("Согласующий"), ) + + # Устаревшее поле, сохранено для обратной совместимости department = models.ForeignKey( "employees.Department", on_delete=models.SET_NULL, null=True, blank=True, - verbose_name=_("Отдел"), + verbose_name=_("Основной отдел (устар.)"), + help_text=_("Устаревшее поле, используйте departments"), + ) + + # Новые поля для множественных получателей + departments = models.ManyToManyField( + "employees.Department", + verbose_name=_("Отделы-получатели"), + blank=True, + related_name="received_requests", + help_text=_("Заявка будет видна всем уполномоченным сотрудникам этих отделов") + ) + + recipients = models.ManyToManyField( + settings.AUTH_USER_MODEL, + verbose_name=_("Получатели"), + blank=True, + related_name="received_requests", + help_text=_("Сотрудники, которым адресована заявка") + ) + + cc_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + verbose_name=_("Копия (CC)"), + blank=True, + related_name="requests_cc", + help_text=_("Сотрудники в копии (уведомления без обязанности рассмотрения)") + ) + + sent_to_all_department = models.BooleanField( + _("Всем сотрудникам отделов"), + default=False, + help_text=_("Если включено, заявка видна всем в выбранных отделах") ) type = models.CharField( @@ -170,6 +204,52 @@ class Request(models.Model): @property def is_final(self): return self.status in FINAL_STATUS + + @property + def all_recipients(self): + """Все получатели: основные + CC""" + User = settings.AUTH_USER_MODEL + from django.apps import apps + UserModel = apps.get_model(User) + + recipient_ids = set(self.recipients.values_list('id', flat=True)) + cc_ids = set(self.cc_users.values_list('id', flat=True)) + return UserModel.objects.filter(id__in=recipient_ids | cc_ids) + + @property + def primary_recipients(self): + """Только основные получатели (без CC)""" + return self.recipients.all() + + def is_recipient(self, user): + """Проверка, является ли пользователь получателем""" + # Прямой получатель или в копии + if self.recipients.filter(id=user.id).exists(): + return True + if self.cc_users.filter(id=user.id).exists(): + return True + + # Если sent_to_all_department и пользователь в одном из отделов + if self.sent_to_all_department: + from employees.models import EmployeeDepartment + return self.departments.filter( + employeedepartment__employee=user, + employeedepartment__is_active=True + ).exists() + + return False + + def add_recipient(self, user, is_cc=False): + """Добавить получателя""" + if is_cc: + self.cc_users.add(user) + else: + self.recipients.add(user) + + def remove_recipient(self, user): + """Удалить получателя из обеих групп""" + self.recipients.remove(user) + self.cc_users.remove(user) def approve(self, by_user): self.status = RequestStatus.APPROVED