- Добавлены ManyToMany поля: departments, recipients, cc_users, sent_to_all_department - Старое поле department сохранено для обратной совместимости - Создана миграция с автоматическим переносом данных - Обновлены сериализаторы RequestReadSerializer и RequestWriteSerializer - Добавлен RecipientIDsField для поддержки разных форматов - Добавлены методы is_recipient, add_recipient, remove_recipient в модель - Добавлена валидация получателей и отделов
50 KiB
📋 План улучшения приложения заявлений: добавление получателей и 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
# Добавить поля:
# 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. Создание миграции
Команда:
python manage.py makemigrations requests_app -n add_recipients_and_departments
Важные моменты в миграции:
- ✅ Сделать старое поле
departmentnullable - ✅ Создать data migration для переноса данных из
departmentвdepartments - ✅ Добавить индексы на ManyToMany таблицы
- ✅ Не удалять старое поле
department(для обратной совместимости)
1.3. Data Migration для переноса данных
Файл: backend/requests_app/migrations/000X_migrate_department_to_departments.py
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. Обновление методов модели
# В классе 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
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 - для поддержки разных форматов:
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
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
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
Добавить проверку получателей:
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
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
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
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 и адаптировать:
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 = `
<div class="recipient-picker">
<div class="form-check mb-2">
<input type="checkbox" id="sentToAllDept"
class="form-check-input">
<label class="form-check-label" for="sentToAllDept">
Всем сотрудникам отделов
</label>
</div>
<div id="departmentSelect" style="display:none">
<label>Выберите отделы:</label>
<select id="deptMultiSelect" multiple class="form-select">
<!-- Загружаем через API -->
</select>
</div>
<div id="recipientSelect">
<label>Получатели:</label>
<input type="text" id="recipientSearch"
class="form-control"
placeholder="Поиск сотрудников...">
<div id="recipientResults" class="list-group mt-2"></div>
<div id="selectedRecipients" class="mt-2"></div>
</div>
<div id="ccSelect" class="mt-3">
<label>Копия (CC):</label>
<input type="text" id="ccSearch"
class="form-control"
placeholder="Поиск для копии...">
<div id="ccResults" class="list-group mt-2"></div>
<div id="selectedCC" class="mt-2"></div>
</div>
</div>
`;
}
}
5.2. Обновление форм создания/редактирования
Файлы:
backend/templates/requests_app/request_form.htmlbackend/templates/requests_app/request_list_full.html(модалки)
Добавить блок выбора получателей:
<div class="mb-3">
<label class="form-label">Кому адресована заявка</label>
<div id="recipientPicker"></div>
</div>
<script>
const picker = new RequestRecipientPicker('recipientPicker', {
showCC: true,
allowDepartments: true
});
// При сабмите формы
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const recipientData = picker.getRecipientIds();
// Добавляем recipient_ids как повторяющиеся параметры
recipientData.recipient_ids.forEach(id => {
formData.append('recipient_ids', id);
});
recipientData.cc_user_ids.forEach(id => {
formData.append('cc_user_ids', id);
});
// Отправка через API
const response = await fetch('/api/v1/requests/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
},
body: formData
});
// ...
});
</script>
5.3. Обновление списка заявлений
Файл: backend/templates/requests_app/request_list_full.html
Добавить:
- Фильтр "Мне адресовано" (
?addressed_to_me=true) - Отображение получателей в карточке заявки
- Индикаторы CC
<!-- Фильтры -->
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="viewMode"
id="viewMy" value="mine" checked>
<label class="btn btn-outline-primary" for="viewMy">Мои</label>
<input type="radio" class="btn-check" name="viewMode"
id="viewAddressed" value="addressed">
<label class="btn btn-outline-primary" for="viewAddressed">
Мне адресовано
</label>
<input type="radio" class="btn-check" name="viewMode"
id="viewAll" value="all">
<label class="btn btn-outline-primary" for="viewAll">Все</label>
</div>
<!-- В карточке заявки -->
<div class="request-recipients mt-2">
<small class="text-muted">
<i class="bi-people"></i>
Получатели:
<span class="recipients-list">
<!-- Аватарки получателей -->
</span>
{% if request.cc_count > 0 %}
<span class="badge bg-secondary ms-2">
+{{ request.cc_count }} в копии
</span>
{% endif %}
</small>
</div>
5.4. Детальная страница заявки
Файл: backend/templates/requests_app/request_detail.html
Добавить секцию получателей:
<div class="card mb-3">
<div class="card-header">
<i class="bi-people"></i> Получатели
</div>
<div class="card-body">
<h6>Адресовано:</h6>
<div class="recipients-grid">
{% for recipient in request.recipients.all %}
<div class="recipient-item">
<img src="{{ recipient.avatar.url }}"
class="avatar-sm rounded-circle">
<span>{{ recipient.get_full_name }}</span>
</div>
{% endfor %}
</div>
{% if request.cc_users.all %}
<h6 class="mt-3">Копия (CC):</h6>
<div class="cc-grid">
{% for cc_user in request.cc_users.all %}
<div class="cc-item">
<img src="{{ cc_user.avatar.url }}"
class="avatar-sm rounded-circle opacity-75">
<span class="text-muted">{{ cc_user.get_full_name }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
ЭТАП 6: Обновление админки
Приоритет: 🟢 Средний
Время: 1-2 часа
Зависимости: Этап 1
6.1. Обновление RequestAdmin
Файл: backend/requests_app/admin.py
@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
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
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
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
## [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 отправку
🎯 Критерии приемки
Функциональные требования
- Можно создать заявку с несколькими получателями
- Можно добавить пользователей в копию (CC)
- Можно выбрать несколько отделов
- Можно включить "Всем сотрудникам отдела"
- Получатели видят заявку в "Мне адресовано"
- Получатели получают уведомления
- CC получают уведомления с меткой "копия"
- Автор видит список получателей
- Можно редактировать получателей до финального статуса
- Старые заявки продолжают работать
Нефункциональные требования
- Время отклика API < 500ms
- UI responsive на мобильных
- Покрытие тестами > 80%
- Документация полная и актуальная
- Обратная совместимость сохранена
📅 Оценка времени
| Этап | Время (часы) | Сложность |
|---|---|---|
| 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 дня |
🚀 Рекомендации по внедрению
Поэтапное внедрение
- Week 1: Этапы 1-3 (Backend core)
- Week 2: Этапы 4-5 (Уведомления + Frontend)
- Week 3: Этапы 6-8 (Админка + Тесты + Docs)
Feature Flags
Рассмотреть использование feature flag для постепенного включения:
# settings.py
FEATURES = {
'requests_recipients': env.bool('FEATURE_REQUESTS_RECIPIENTS', default=False)
}
# В коде
if settings.FEATURES['requests_recipients']:
# Новая логика с получателями
else:
# Старая логика
Мониторинг
- Логировать создание заявок с получателями
- Мониторить время отправки уведомлений
- Отслеживать ошибки миграции
💡 Будущие улучшения (после основной реализации)
-
Группы получателей
- Создание групп рассылки
- Шаблоны получателей
-
Умные рекомендации
- Автоматическое предложение получателей на основе типа заявки
- ML для предсказания согласующих
-
Workflow
- Последовательное согласование
- Параллельное согласование
- Условные маршруты
-
Аналитика
- Дашборд по заявкам отдела
- Статистика по получателям
- Время обработки
Автор плана: AI Assistant
Дата: 2 декабря 2025 г.
Версия: 1.0