feat(requests): улучшены фильтры по датам, исправлена логика фильтрации, добавлена инструкция по тестированию

This commit is contained in:
lizerk 2026-03-27 14:57:31 +03:00
parent ca4f7c2f86
commit 3a2d05a64d
5 changed files with 144 additions and 215 deletions

View File

@ -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

View File

@ -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

View 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) - заявка должна удовлетворять всем активным фильтрам

View File

@ -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"
>

View File

@ -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);