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:
parent
21e2972c11
commit
7cfd290d7d
234
LIVE_VALIDATION_GUIDE.md
Normal file
234
LIVE_VALIDATION_GUIDE.md
Normal 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
215
NAME_VALIDATION.md
Normal 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
1441
REQUESTS_RECIPIENTS_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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):
|
||||||
"""Сериализатор чтения заявки.
|
"""Сериализатор чтения заявки с получателями.
|
||||||
|
|
||||||
Включает краткую информацию о сотрудниках и вычисляемые поля.
|
Включает краткую информацию о сотрудниках и вычисляемые поля.
|
||||||
"""
|
"""
|
||||||
@ -24,6 +71,18 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
|||||||
approver = EmployeeBriefSerializer(read_only=True)
|
approver = EmployeeBriefSerializer(read_only=True)
|
||||||
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
|
||||||
@ -31,7 +90,8 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
|||||||
"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,7 +121,40 @@ 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):
|
||||||
@ -80,6 +180,26 @@ class RequestWriteSerializer(serializers.ModelSerializer):
|
|||||||
approver = serializers.PrimaryKeyRelatedField(
|
approver = serializers.PrimaryKeyRelatedField(
|
||||||
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
|
||||||
@ -87,7 +207,8 @@ class RequestWriteSerializer(serializers.ModelSerializer):
|
|||||||
"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 = [
|
||||||
@ -135,6 +260,57 @@ class RequestWriteSerializer(serializers.ModelSerializer):
|
|||||||
data.pop("status", None)
|
data.pop("status", None)
|
||||||
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):
|
||||||
|
|||||||
@ -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='Основной отдел (устар.)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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(
|
||||||
@ -170,6 +204,52 @@ class Request(models.Model):
|
|||||||
@property
|
@property
|
||||||
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user