diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index c3e69280..bd9f5e35 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -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 diff --git a/backend/backend/docs/guides/ldap-password-mixin.md b/backend/backend/docs/guides/ldap-password-mixin.md deleted file mode 100644 index 881bde42..00000000 --- a/backend/backend/docs/guides/ldap-password-mixin.md +++ /dev/null @@ -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 diff --git a/docs/guides/testing-request-filters.md b/docs/guides/testing-request-filters.md new file mode 100644 index 00000000..8e014a2a --- /dev/null +++ b/docs/guides/testing-request-filters.md @@ -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) - заявка должна удовлетворять всем активным фильтрам diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 33a326e2..9443791e 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -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(fetcher: (params: any) => Promise): Promise { @@ -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() { }`} > - {(view || typeFilter || statusFilter || employeeFilter || dateFromFilter || dateToFilter) && ( + {(view || typeFilter || statusFilter || employeeFilter || createdFromFilter || createdToFilter || periodFromFilter || periodToFilter) && ( - {[view, typeFilter, statusFilter, employeeFilter, dateFromFilter, dateToFilter].filter(Boolean).length} + {[view, typeFilter, statusFilter, employeeFilter, createdFromFilter, createdToFilter, periodFromFilter, periodToFilter].filter(Boolean).length} )} @@ -636,10 +640,47 @@ export default function RequestsPage() { ))} - setDateFromFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" /> - setDateToFilter(e.target.value)} className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800" /> +
+ +
+ 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" + /> + 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" + /> +
+
- {(view || typeFilter || statusFilter || employeeFilter || dateFromFilter || dateToFilter) && ( +
+ +
+ 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" + /> + 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" + /> +
+
+ + {(view || typeFilter || statusFilter || employeeFilter || createdFromFilter || createdToFilter || periodFromFilter || periodToFilter) && (