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 = ` +