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 django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from requests_app.enums import RequestStatus, RequestType
|
||||
from requests_app.models import Request, RequestComment
|
||||
from rest_framework import serializers
|
||||
@ -14,8 +15,54 @@ from .validators import (RequestApproverNotEmployeeValidator,
|
||||
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):
|
||||
"""Сериализатор чтения заявки.
|
||||
"""Сериализатор чтения заявки с получателями.
|
||||
|
||||
Включает краткую информацию о сотрудниках и вычисляемые поля.
|
||||
"""
|
||||
@ -24,6 +71,18 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
approver = EmployeeBriefSerializer(read_only=True)
|
||||
display_title = serializers.CharField(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:
|
||||
model = Request
|
||||
@ -31,7 +90,8 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"employee",
|
||||
"approver",
|
||||
"department",
|
||||
"department", # Старое поле для обратной совместимости
|
||||
"departments", # Новое поле
|
||||
"type",
|
||||
"title",
|
||||
"date_from",
|
||||
@ -44,6 +104,13 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
"decided_at",
|
||||
"display_title",
|
||||
"is_final",
|
||||
# Новые поля
|
||||
"recipients",
|
||||
"cc_users",
|
||||
"sent_to_all_department",
|
||||
"recipient_count",
|
||||
"cc_count",
|
||||
"is_recipient",
|
||||
)
|
||||
read_only_fields = (
|
||||
"employee",
|
||||
@ -54,7 +121,40 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
"decided_at",
|
||||
"display_title",
|
||||
"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):
|
||||
@ -80,6 +180,26 @@ class RequestWriteSerializer(serializers.ModelSerializer):
|
||||
approver = serializers.PrimaryKeyRelatedField(
|
||||
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:
|
||||
model = Request
|
||||
@ -87,7 +207,8 @@ class RequestWriteSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"employee",
|
||||
"approver",
|
||||
"department",
|
||||
"department", # Старое поле для обратной совместимости
|
||||
"department_ids", # Новое поле
|
||||
"type",
|
||||
"title",
|
||||
"date_from",
|
||||
@ -95,6 +216,10 @@ class RequestWriteSerializer(serializers.ModelSerializer):
|
||||
"comment",
|
||||
"status",
|
||||
"attachment",
|
||||
# Новые поля
|
||||
"recipient_ids",
|
||||
"cc_user_ids",
|
||||
"sent_to_all_department",
|
||||
)
|
||||
read_only_fields = ("id",)
|
||||
validators = [
|
||||
@ -135,6 +260,57 @@ class RequestWriteSerializer(serializers.ModelSerializer):
|
||||
data.pop("status", None)
|
||||
data.pop("approver", None)
|
||||
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:
|
||||
"""Создание заявки.
|
||||
@ -158,6 +334,11 @@ class RequestWriteSerializer(serializers.ModelSerializer):
|
||||
{"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():
|
||||
# обычный пользователь: запрещённые поля убираем/переписываем
|
||||
validated_data.pop("status", None)
|
||||
@ -172,7 +353,39 @@ class RequestWriteSerializer(serializers.ModelSerializer):
|
||||
{"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):
|
||||
|
||||
@ -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,
|
||||
verbose_name=_("Согласующий"),
|
||||
)
|
||||
|
||||
# Устаревшее поле, сохранено для обратной совместимости
|
||||
department = models.ForeignKey(
|
||||
"employees.Department",
|
||||
on_delete=models.SET_NULL,
|
||||
null=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(
|
||||
@ -170,6 +204,52 @@ class Request(models.Model):
|
||||
@property
|
||||
def is_final(self):
|
||||
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):
|
||||
self.status = RequestStatus.APPROVED
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user