feat(requests): Этапы 1-2 - добавлены поля получателей в модель и сериализаторы

- Добавлены ManyToMany поля: departments, recipients, cc_users, sent_to_all_department
- Старое поле department сохранено для обратной совместимости
- Создана миграция с автоматическим переносом данных
- Обновлены сериализаторы RequestReadSerializer и RequestWriteSerializer
- Добавлен RecipientIDsField для поддержки разных форматов
- Добавлены методы is_recipient, add_recipient, remove_recipient в модель
- Добавлена валидация получателей и отделов
This commit is contained in:
admin i 2025-12-02 17:15:04 +03:00
parent 21e2972c11
commit 7cfd290d7d
7 changed files with 2274 additions and 5 deletions

234
LIVE_VALIDATION_GUIDE.md Normal file
View File

@ -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
});
// Обработка ответа...
});
```

215
NAME_VALIDATION.md Normal file
View File

@ -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
```
Каждый уровень независим и может отклонить некорректные данные.

1441
REQUESTS_RECIPIENTS_PLAN.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any, Mapping from typing import Any, Mapping
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models
from requests_app.enums import RequestStatus, RequestType from requests_app.enums import RequestStatus, RequestType
from requests_app.models import Request, RequestComment from requests_app.models import Request, RequestComment
from rest_framework import serializers from rest_framework import serializers
@ -14,8 +15,54 @@ from .validators import (RequestApproverNotEmployeeValidator,
User = get_user_model() 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): class RequestReadSerializer(serializers.ModelSerializer):
"""Сериализатор чтения заявки. """Сериализатор чтения заявки с получателями.
Включает краткую информацию о сотрудниках и вычисляемые поля. Включает краткую информацию о сотрудниках и вычисляемые поля.
""" """
@ -25,13 +72,26 @@ class RequestReadSerializer(serializers.ModelSerializer):
display_title = serializers.CharField(read_only=True) display_title = serializers.CharField(read_only=True)
is_final = serializers.BooleanField(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: class Meta:
model = Request model = Request
fields = ( fields = (
"id", "id",
"employee", "employee",
"approver", "approver",
"department", "department", # Старое поле для обратной совместимости
"departments", # Новое поле
"type", "type",
"title", "title",
"date_from", "date_from",
@ -44,6 +104,13 @@ class RequestReadSerializer(serializers.ModelSerializer):
"decided_at", "decided_at",
"display_title", "display_title",
"is_final", "is_final",
# Новые поля
"recipients",
"cc_users",
"sent_to_all_department",
"recipient_count",
"cc_count",
"is_recipient",
) )
read_only_fields = ( read_only_fields = (
"employee", "employee",
@ -54,8 +121,41 @@ class RequestReadSerializer(serializers.ModelSerializer):
"decided_at", "decided_at",
"display_title", "display_title",
"is_final", "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): class RequestWriteSerializer(serializers.ModelSerializer):
"""Сериализатор записи заявки. """Сериализатор записи заявки.
@ -81,13 +181,34 @@ class RequestWriteSerializer(serializers.ModelSerializer):
queryset=User.objects.all(), required=False, allow_null=True 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: class Meta:
model = Request model = Request
fields = ( fields = (
"id", "id",
"employee", "employee",
"approver", "approver",
"department", "department", # Старое поле для обратной совместимости
"department_ids", # Новое поле
"type", "type",
"title", "title",
"date_from", "date_from",
@ -95,6 +216,10 @@ class RequestWriteSerializer(serializers.ModelSerializer):
"comment", "comment",
"status", "status",
"attachment", "attachment",
# Новые поля
"recipient_ids",
"cc_user_ids",
"sent_to_all_department",
) )
read_only_fields = ("id",) read_only_fields = ("id",)
validators = [ validators = [
@ -136,6 +261,57 @@ class RequestWriteSerializer(serializers.ModelSerializer):
data.pop("approver", None) data.pop("approver", None)
return super().to_internal_value(data) 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: def create(self, validated_data: dict[str, Any]) -> Request:
"""Создание заявки. """Создание заявки.
@ -158,6 +334,11 @@ class RequestWriteSerializer(serializers.ModelSerializer):
{"detail": "Требуется аутентификация для создания заявки."} {"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(): if not self._is_power():
# обычный пользователь: запрещённые поля убираем/переписываем # обычный пользователь: запрещённые поля убираем/переписываем
validated_data.pop("status", None) validated_data.pop("status", None)
@ -172,7 +353,39 @@ class RequestWriteSerializer(serializers.ModelSerializer):
{"employee": "Автор должен быть установлен сервером."} {"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): class RequestCommentSerializer(serializers.ModelSerializer):

View File

@ -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='Основной отдел (устар.)'),
),
]

View File

@ -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
),
]

View File

@ -76,12 +76,46 @@ class Request(models.Model):
blank=True, blank=True,
verbose_name=_("Согласующий"), verbose_name=_("Согласующий"),
) )
# Устаревшее поле, сохранено для обратной совместимости
department = models.ForeignKey( department = models.ForeignKey(
"employees.Department", "employees.Department",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=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( type = models.CharField(
@ -171,6 +205,52 @@ class Request(models.Model):
def is_final(self): def is_final(self):
return self.status in FINAL_STATUS 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): def approve(self, by_user):
self.status = RequestStatus.APPROVED self.status = RequestStatus.APPROVED
self.approver = by_user self.approver = by_user