feat(requests): улучшены фильтры по датам, исправлена логика фильтрации, добавлена инструкция по тестированию
This commit is contained in:
parent
ca4f7c2f86
commit
3a2d05a64d
@ -230,10 +230,12 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
qs = qs.filter(scope).distinct()
|
||||
|
||||
# Применяем фильтры type/status/employee_id/date_from/date_to для всех пользователей
|
||||
# Применяем фильтры type/status/employee_id/created_from/created_to/date_from/date_to для всех пользователей
|
||||
t = (params.get("type") or "").strip()
|
||||
s = (params.get("status") or "").strip()
|
||||
employee_id = (params.get("employee_id") or "").strip()
|
||||
created_from = (params.get("created_from") or "").strip()
|
||||
created_to = (params.get("created_to") or "").strip()
|
||||
date_from = (params.get("date_from") or "").strip()
|
||||
date_to = (params.get("date_to") or "").strip()
|
||||
|
||||
@ -243,12 +245,20 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
qs = qs.filter(status=s)
|
||||
if employee_id and employee_id.isdigit():
|
||||
qs = qs.filter(employee_id=int(employee_id))
|
||||
|
||||
# Фильтры по дате создания заявления
|
||||
if created_from:
|
||||
qs = qs.filter(created_at__date__gte=created_from)
|
||||
if created_to:
|
||||
qs = qs.filter(created_at__date__lte=created_to)
|
||||
|
||||
# Фильтры по периоду заявления (date_from/date_to в самой заявке)
|
||||
if date_from:
|
||||
# Фильтр по дате создания (created_at >= указанной даты)
|
||||
qs = qs.filter(created_at__date__gte=date_from)
|
||||
# Заявки, у которых период не закончился до указанной даты
|
||||
qs = qs.filter(Q(date_to__isnull=True) | Q(date_to__gte=date_from))
|
||||
if date_to:
|
||||
# Фильтр по дате создания (created_at <= указанной даты)
|
||||
qs = qs.filter(created_at__date__lte=date_to)
|
||||
# Заявки, у которых период не начался после указанной даты
|
||||
qs = qs.filter(Q(date_from__isnull=True) | Q(date_from__lte=date_to))
|
||||
|
||||
# Аннотируем счётчик комментариев через communications.Chat
|
||||
from communications.models import Chat, Message
|
||||
|
||||
@ -1,197 +0,0 @@
|
||||
# LDAP Password Mixin - Руководство
|
||||
|
||||
## Обзор
|
||||
|
||||
`LdapPasswordMixin` - миксин для API endpoint'ов, которые работают с паролями пользователей.
|
||||
|
||||
Автоматически синхронизирует изменения паролей с LDAP после обновления в БД.
|
||||
|
||||
## Расположение
|
||||
|
||||
`backend/common/ldap_password_mixin.py`
|
||||
|
||||
## Использование
|
||||
|
||||
### Пример 1: Endpoint смены пароля
|
||||
|
||||
```python
|
||||
from common.ldap_password_mixin import LdapPasswordMixin
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class ChangePasswordAPIView(LdapPasswordMixin, APIView):
|
||||
"""Смена пароля текущего пользователя."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
user = request.user
|
||||
old_password = request.data.get('old_password')
|
||||
new_password = request.data.get('new_password')
|
||||
|
||||
# Проверяем старый пароль
|
||||
if not user.check_password(old_password):
|
||||
return Response(
|
||||
{'error': 'invalid_old_password'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Меняем пароль в БД
|
||||
user.set_password(new_password)
|
||||
user.save(update_fields=['password'])
|
||||
|
||||
# Синхронизируем с LDAP
|
||||
success, error = self.sync_password_to_ldap(user, new_password)
|
||||
|
||||
if not success:
|
||||
return Response(
|
||||
{
|
||||
'ok': True,
|
||||
'warning': f'Password changed in DB, but LDAP sync failed: {error}'
|
||||
},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response({'ok': True}, status=status.HTTP_200_OK)
|
||||
```
|
||||
|
||||
### Пример 2: Сброс пароля администратором
|
||||
|
||||
```python
|
||||
from common.ldap_password_mixin import LdapPasswordMixin
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from employees.models import Employee
|
||||
|
||||
|
||||
class EmployeeViewSet(LdapPasswordMixin, viewsets.ModelViewSet):
|
||||
"""ViewSet для управления сотрудниками."""
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[IsAdminUser])
|
||||
def reset_password(self, request, pk=None):
|
||||
"""Сброс пароля администратором."""
|
||||
employee = self.get_object()
|
||||
new_password = request.data.get('new_password')
|
||||
|
||||
if not new_password:
|
||||
return Response(
|
||||
{'error': 'new_password is required'},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Меняем пароль в БД
|
||||
employee.set_password(new_password)
|
||||
employee.save(update_fields=['password'])
|
||||
|
||||
# Синхронизируем с LDAP
|
||||
success, error = self.sync_password_to_ldap(employee, new_password)
|
||||
|
||||
return Response({
|
||||
'ok': True,
|
||||
'ldap_synced': success,
|
||||
'ldap_error': error
|
||||
})
|
||||
```
|
||||
|
||||
## Метод `sync_password_to_ldap`
|
||||
|
||||
### Сигнатура
|
||||
|
||||
```python
|
||||
def sync_password_to_ldap(self, employee_instance, new_password):
|
||||
"""Синхронизирует пароль с LDAP для данного Employee.
|
||||
|
||||
Args:
|
||||
employee_instance: инстанс Employee
|
||||
new_password: новый пароль (plaintext)
|
||||
|
||||
Returns:
|
||||
tuple: (success: bool, error: str or None)
|
||||
"""
|
||||
```
|
||||
|
||||
### Возвращаемые значения
|
||||
|
||||
- `(True, None)` - успешная синхронизация
|
||||
- `(True, None)` - LDAP отключен (ок, синхронизация не нужна)
|
||||
- `(False, "no_ldap_user")` - Employee не имеет LDAP пользователя
|
||||
- `(False, "ldap_error: ...")` - ошибка LDAP
|
||||
- `(False, "service_error: ...")` - ошибка DirectoryService
|
||||
- `(False, "db_error: ...")` - ошибка БД
|
||||
|
||||
### Логика работы
|
||||
|
||||
1. Проверяет `LDAP_ENABLED` в settings
|
||||
2. Проверяет `employee_instance.is_ldap_managed`
|
||||
3. Проверяет наличие `LdapSyncState` для пользователя
|
||||
4. Вызывает `DirectoryService.update_user()` с изменениями пароля
|
||||
5. Логирует успех/ошибки
|
||||
|
||||
## Когда ИСПОЛЬЗОВАТЬ миксин
|
||||
|
||||
✅ **Используйте** для endpoint'ов:
|
||||
- Смена пароля пользователем
|
||||
- Сброс пароля администратором
|
||||
- Первичная установка пароля
|
||||
- Восстановление пароля
|
||||
|
||||
## Когда НЕ использовать миксин
|
||||
|
||||
❌ **НЕ используйте** для:
|
||||
- Регистрации новых пользователей (используйте `RegisterAPIView` напрямую с `DirectoryService.create_user`)
|
||||
- CRUD операций через `EmployeeViewSet` (сигналы автоматически синхронизируют)
|
||||
- Административного создания Employee без LDAP (просто создавайте в БД)
|
||||
|
||||
## Текущая архитектура паролей
|
||||
|
||||
### Регистрация (RegisterAPIView)
|
||||
```python
|
||||
# Создает Employee + LDAP user с паролем
|
||||
svc = DirectoryService()
|
||||
dto = DirectoryUserDTO(
|
||||
...
|
||||
initial_password=password, # пароль только в LDAP
|
||||
is_active=False,
|
||||
)
|
||||
emp = svc.create_user(dto)
|
||||
```
|
||||
|
||||
### Обычное создание через API (EmployeeViewSet.perform_create)
|
||||
```python
|
||||
# Создает только Employee в БД (без пароля, без LDAP)
|
||||
instance = serializer.save(is_ldap_managed=ldap_enabled)
|
||||
instance.set_unusable_password()
|
||||
instance.save()
|
||||
```
|
||||
|
||||
### Обновление через API (EmployeeViewSet.perform_update)
|
||||
```python
|
||||
# Обновляет Employee в БД
|
||||
# Сигналы автоматически синхронизируют с LDAP (если пользователь существует)
|
||||
instance._ldap_changes = dict(self.request.data)
|
||||
serializer.save()
|
||||
```
|
||||
|
||||
### Смена пароля (используйте LdapPasswordMixin)
|
||||
```python
|
||||
# Меняем в БД
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
# Синхронизируем с LDAP через миксин
|
||||
success, error = self.sync_password_to_ldap(user, new_password)
|
||||
```
|
||||
|
||||
## Файлы
|
||||
|
||||
- `backend/common/ldap_password_mixin.py` - миксин
|
||||
- `backend/api/v1/employees/views/auth.py` - примеры RegisterAPIView, VerifyEmailAPIView
|
||||
- `backend/employees/signals_ldap.py` - сигналы для автоматической синхронизации
|
||||
|
||||
## Связанные коммиты
|
||||
|
||||
- `f43761fd` - Создание LdapPasswordMixin
|
||||
- `9764eb1a` - Переход на сигналы
|
||||
- `0192c590` - Удаление ExternalSystemSyncMixin
|
||||
69
docs/guides/testing-request-filters.md
Normal file
69
docs/guides/testing-request-filters.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Тестирование фильтров заявок
|
||||
|
||||
## ✅ Что было реализовано
|
||||
|
||||
### Frontend
|
||||
- Добавлены 2 строки фильтров по датам с подписями:
|
||||
1. **"Дата создания заявления"** - фильтрует по дате создания заявки (поля "от" и "до" в одной строке)
|
||||
2. **"Период заявления"** - фильтрует по периоду самой заявки (поля "от" и "до" в другой строке)
|
||||
|
||||
### Backend API
|
||||
- Добавлена поддержка 4 параметров фильтрации:
|
||||
- `created_from` - начало периода создания заявления
|
||||
- `created_to` - конец периода создания заявления
|
||||
- `date_from` - начало периода заявления (пересечение)
|
||||
- `date_to` - конец периода заявления (пересечение)
|
||||
|
||||
## 🧪 Как протестировать
|
||||
|
||||
### Шаг 1: Откройте страницу заявок
|
||||
```
|
||||
http://localhost:3000/requests
|
||||
```
|
||||
|
||||
### Шаг 2: Откройте панель фильтров
|
||||
Нажмите на иконку фильтра (воронка) в правом верхнем углу
|
||||
|
||||
### Шаг 3: Протестируйте фильтры
|
||||
|
||||
#### Тест 1: Фильтр по дате создания
|
||||
1. В блоке "Дата создания заявления"установите:
|
||||
- От: сегодняшняя дата
|
||||
- До: сегодняшняя дата
|
||||
2. Должны отобразиться только заявки, созданные сегодня
|
||||
|
||||
#### Тест 2: Фильтр по периоду заявления
|
||||
1. В блоке "Период заявления" установите:
|
||||
- От: сегодняшняя дата
|
||||
- До: дата через неделю
|
||||
2. Должны отобразиться заявки, период которых пересекается с указанным диапазоном
|
||||
(например, отпуска, которые начинаются или заканчиваются в этот период)
|
||||
|
||||
#### Тест 3: Комбинированный фильтр
|
||||
1. Установите оба фильтра одновременно
|
||||
2. Должны отобразиться заявки, удовлетворяющие обоим условиям
|
||||
|
||||
### Шаг 4: Проверка работы
|
||||
- Убедитесь, что счетчик активных фильтров обновляется правильно
|
||||
- Нажмите "Очистить фильтры" - все фильтры должны сброситься
|
||||
- Проверьте, что фильтры сохраняются при переключении страниц (пагинация)
|
||||
|
||||
## 📊 Тестовые данные
|
||||
|
||||
Текущее состояние базы данных:
|
||||
- Всего заявок: 43
|
||||
- Заявок созданных сегодня (2026-03-27): 1
|
||||
- Заявок с указанным периодом: 41
|
||||
|
||||
## 🐛 Что проверили
|
||||
|
||||
✅ Frontend UI - фильтры отображаются с подписями
|
||||
✅ API метод `getRequests` - принимает новые параметры
|
||||
✅ Backend ViewSet - правильно фильтрует запросы
|
||||
✅ Логика фильтрации базы данных - работает корректно
|
||||
|
||||
## 📝 Примечания
|
||||
|
||||
- **Фильтр по дате создания** (`created_from`/`created_to`) - фильтрует по полю `created_at` заявки
|
||||
- **Фильтр по периоду** (`date_from`/`date_to`) - фильтрует по полям `date_from` и `date_to` заявки
|
||||
- Фильтры применяются как логическое И (AND) - заявка должна удовлетворять всем активным фильтрам
|
||||
@ -109,8 +109,10 @@ export default function RequestsPage() {
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [employeeFilter, setEmployeeFilter] = useState("");
|
||||
const [dateFromFilter, setDateFromFilter] = useState("");
|
||||
const [dateToFilter, setDateToFilter] = useState("");
|
||||
const [createdFromFilter, setCreatedFromFilter] = useState("");
|
||||
const [createdToFilter, setCreatedToFilter] = useState("");
|
||||
const [periodFromFilter, setPeriodFromFilter] = useState("");
|
||||
const [periodToFilter, setPeriodToFilter] = useState("");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [attachmentPreview, setAttachmentPreview] = useState<{ url: string; name: string } | null>(null);
|
||||
|
||||
@ -162,8 +164,10 @@ export default function RequestsPage() {
|
||||
if (typeFilter) params.type = typeFilter;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (employeeFilter) params.employee_id = employeeFilter;
|
||||
if (dateFromFilter) params.date_from = dateFromFilter;
|
||||
if (dateToFilter) params.date_to = dateToFilter;
|
||||
if (createdFromFilter) params.created_from = createdFromFilter;
|
||||
if (createdToFilter) params.created_to = createdToFilter;
|
||||
if (periodFromFilter) params.date_from = periodFromFilter;
|
||||
if (periodToFilter) params.date_to = periodToFilter;
|
||||
return params;
|
||||
};
|
||||
|
||||
@ -184,7 +188,7 @@ export default function RequestsPage() {
|
||||
}
|
||||
|
||||
loadRequests();
|
||||
}, [view, typeFilter, statusFilter, employeeFilter, dateFromFilter, dateToFilter]);
|
||||
}, [view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAllPages<T extends { id: number }>(fetcher: (params: any) => Promise<any>): Promise<T[]> {
|
||||
@ -331,7 +335,7 @@ export default function RequestsPage() {
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [nextPage, loadingMore, view, typeFilter, statusFilter, employeeFilter, dateFromFilter, dateToFilter]);
|
||||
}, [nextPage, loadingMore, view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter]);
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
try {
|
||||
@ -593,9 +597,9 @@ export default function RequestsPage() {
|
||||
}`}
|
||||
>
|
||||
<Filter size={16} />
|
||||
{(view || typeFilter || statusFilter || employeeFilter || dateFromFilter || dateToFilter) && (
|
||||
{(view || typeFilter || statusFilter || employeeFilter || createdFromFilter || createdToFilter || periodFromFilter || periodToFilter) && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">
|
||||
{[view, typeFilter, statusFilter, employeeFilter, dateFromFilter, dateToFilter].filter(Boolean).length}
|
||||
{[view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@ -636,10 +640,47 @@ export default function RequestsPage() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input type="date" value={dateFromFilter} onChange={(e) => setDateFromFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" />
|
||||
<input type="date" value={dateToFilter} onChange={(e) => setDateToFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" />
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-gray-600 px-1">Дата создания заявления</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={createdFromFilter}
|
||||
onChange={(e) => setCreatedFromFilter(e.target.value)}
|
||||
placeholder="от"
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={createdToFilter}
|
||||
onChange={(e) => setCreatedToFilter(e.target.value)}
|
||||
placeholder="до"
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(view || typeFilter || statusFilter || employeeFilter || dateFromFilter || dateToFilter) && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-gray-600 px-1">Период заявления</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={periodFromFilter}
|
||||
onChange={(e) => setPeriodFromFilter(e.target.value)}
|
||||
placeholder="от"
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={periodToFilter}
|
||||
onChange={(e) => setPeriodToFilter(e.target.value)}
|
||||
placeholder="до"
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(view || typeFilter || statusFilter || employeeFilter || createdFromFilter || createdToFilter || periodFromFilter || periodToFilter) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@ -647,8 +688,10 @@ export default function RequestsPage() {
|
||||
setTypeFilter("");
|
||||
setStatusFilter("");
|
||||
setEmployeeFilter("");
|
||||
setDateFromFilter("");
|
||||
setDateToFilter("");
|
||||
setCreatedFromFilter("");
|
||||
setCreatedToFilter("");
|
||||
setPeriodFromFilter("");
|
||||
setPeriodToFilter("");
|
||||
}}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 transition"
|
||||
>
|
||||
|
||||
@ -629,6 +629,8 @@ class ApiClient {
|
||||
view?: string;
|
||||
addressed_to_me?: string;
|
||||
employee_id?: string | number;
|
||||
created_from?: string;
|
||||
created_to?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
[key: string]: any;
|
||||
@ -642,6 +644,8 @@ class ApiClient {
|
||||
if (params?.view) queryParams.append('view', params.view);
|
||||
if (params?.addressed_to_me) queryParams.append('addressed_to_me', params.addressed_to_me);
|
||||
if (params?.employee_id) queryParams.append('employee_id', params.employee_id.toString());
|
||||
if (params?.created_from) queryParams.append('created_from', params.created_from);
|
||||
if (params?.created_to) queryParams.append('created_to', params.created_to);
|
||||
if (params?.date_from) queryParams.append('date_from', params.date_from);
|
||||
if (params?.date_to) queryParams.append('date_to', params.date_to);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user