# 📋 План улучшения приложения заявлений: добавление получателей и 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 = `