diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index cedd4487..2d95079b 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -1,6 +1,7 @@ from __future__ import annotations import calendar +from datetime import date from typing import Any # было: Any, Dict, List, Type from django.contrib.auth import get_user_model @@ -88,6 +89,42 @@ def _resolve_stats_window(period: str) -> tuple[timezone.datetime.date, timezone return None, None +def _parse_iso_date(raw_value: str, field_name: str) -> date: + """Парсит ISO-даты для custom period.""" + try: + return date.fromisoformat(raw_value) + except ValueError as exc: + raise ValidationError( + {field_name: ["Ожидается дата в формате YYYY-MM-DD."]} + ) from exc + + +def _resolve_stats_window_from_query( + period: str, request: DRFRequest +) -> tuple[date | None, date | None]: + """Возвращает окно статистики, включая custom range.""" + if period != "custom": + return _resolve_stats_window(period) + + date_from_raw = (request.query_params.get("date_from") or "").strip() + date_to_raw = (request.query_params.get("date_to") or "").strip() + if not date_from_raw or not date_to_raw: + raise ValidationError( + { + "date_from": ["Обязательное поле для custom периода."], + "date_to": ["Обязательное поле для custom периода."], + } + ) + + start_date = _parse_iso_date(date_from_raw, "date_from") + end_date = _parse_iso_date(date_to_raw, "date_to") + if start_date > end_date: + raise ValidationError( + {"date_to": ["Дата окончания не может быть раньше даты начала."]} + ) + return start_date, end_date + + def _request_duration_days_in_window( req: EmployeeRequest, start_date, @@ -119,8 +156,15 @@ def _can_view_request_statistics(user) -> bool: ) -def _build_employee_statistics(employee_id: int, employee_name: str, period: str): - start_date, end_date = _resolve_stats_window(period) +def _build_employee_statistics( + employee_id: int, + employee_name: str, + period: str, + start_date: date | None = None, + end_date: date | None = None, +): + if period != "custom": + start_date, end_date = _resolve_stats_window(period) base_qs = EmployeeRequest.objects.filter(employee_id=employee_id).exclude( status=RequestStatus.DRAFT @@ -163,6 +207,8 @@ def _build_employee_statistics(employee_id: int, employee_name: str, period: str "employee_id": employee_id, "employee_name": employee_name, "period": period, + "date_from": start_date.isoformat() if start_date else None, + "date_to": end_date.isoformat() if end_date else None, "total_submitted_requests": base_qs.count(), "sick_leave_requests_count": base_qs.filter( type=RequestType.SICK_LEAVE @@ -639,16 +685,19 @@ class RequestViewSet(viewsets.ModelViewSet): ) period = (request.query_params.get("period") or "all").strip().lower() - if period not in {"all", "year", "month"}: + if period not in {"all", "year", "month", "custom"}: raise ValidationError( - "Параметр period должен быть one of: all, year, month." + "Параметр period должен быть one of: all, year, month, custom." ) + start_date, end_date = _resolve_stats_window_from_query(period, request) return Response( _build_employee_statistics( employee_id=obj.employee_id, employee_name=str(obj.employee), period=period, + start_date=start_date, + end_date=end_date, ) ) @@ -665,10 +714,11 @@ class RequestViewSet(viewsets.ModelViewSet): raise ValidationError("Параметр employee_id обязателен.") period = (request.query_params.get("period") or "all").strip().lower() - if period not in {"all", "year", "month"}: + if period not in {"all", "year", "month", "custom"}: raise ValidationError( - "Параметр period должен быть one of: all, year, month." + "Параметр period должен быть one of: all, year, month, custom." ) + start_date, end_date = _resolve_stats_window_from_query(period, request) UserModel = get_user_model() employee = get_object_or_404(UserModel, pk=int(employee_id_raw)) @@ -677,6 +727,8 @@ class RequestViewSet(viewsets.ModelViewSet): employee_id=employee.id, employee_name=str(employee), period=period, + start_date=start_date, + end_date=end_date, ) ) diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 11d16e9e..7563bbe2 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -1,9 +1,11 @@ from __future__ import annotations +from datetime import datetime from typing import Any import pytest from django.db import models +from django.utils import timezone from notifications.models import Notification from rest_framework.test import APIClient @@ -31,6 +33,122 @@ def test_list_unauth_401(api_client: APIClient) -> None: assert resp.status_code == 401 +def test_statistics_requires_privileged_access( + auth_client, regular_user: models.Model, make_user +) -> None: + """Статистика недоступна обычному пользователю без спецправ.""" + employee = make_user(email="stats-target@example.com") + + resp = auth_client(regular_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=all" + ) + + assert resp.status_code == 403 + + +def test_statistics_counts_all_employee_requests_for_admin( + auth_client, + admin_user: models.Model, + make_user, + make_request, +) -> None: + """Админ получает полную статистику по сотруднику, а не только видимые заявки.""" + employee = make_user(email="stats-admin-target@example.com") + make_request( + employee=employee, + type_=RequestType.SICK_LEAVE, + status=RequestStatus.APPROVED, + ) + make_request( + employee=employee, + type_=RequestType.DAY_OFF, + status=RequestStatus.PENDING, + ) + + resp = auth_client(admin_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=all" + ) + + assert resp.status_code == 200 + payload = resp.json() + assert payload["employee_id"] == employee.id + assert payload["period"] == "all" + assert payload["date_from"] is None + assert payload["date_to"] is None + assert payload["total_submitted_requests"] == 2 + assert payload["sick_leave_requests_count"] == 1 + assert payload["day_off_requests_count"] == 1 + + +def test_statistics_custom_period_filters_request_counts_and_absence_days( + auth_client, + admin_user: models.Model, + make_user, + make_request, +) -> None: + """Custom период фильтрует заявки по дате отправки и дни по пересечению периода.""" + employee = make_user(email="stats-custom-target@example.com") + + matching = make_request( + employee=employee, + type_=RequestType.VACATION, + status=RequestStatus.APPROVED, + comment="отпуск за свой счет", + ) + matching.created_at = datetime( + 2026, 4, 10, 12, 0, tzinfo=timezone.get_current_timezone() + ) + matching.date_from = datetime(2026, 4, 5).date() + matching.date_to = datetime(2026, 4, 12).date() + matching.save( + update_fields=["created_at", "date_from", "date_to", "comment"] + ) + + outside_count = make_request( + employee=employee, + type_=RequestType.DAY_OFF, + status=RequestStatus.APPROVED, + ) + outside_count.created_at = datetime( + 2026, 3, 1, 12, 0, tzinfo=timezone.get_current_timezone() + ) + outside_count.date_from = datetime(2026, 4, 11).date() + outside_count.date_to = datetime(2026, 4, 11).date() + outside_count.save(update_fields=["created_at", "date_from", "date_to"]) + + resp = auth_client(admin_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=custom" + "&date_from=2026-04-10&date_to=2026-04-15" + ) + + assert resp.status_code == 200 + payload = resp.json() + assert payload["period"] == "custom" + assert payload["date_from"] == "2026-04-10" + assert payload["date_to"] == "2026-04-15" + assert payload["total_submitted_requests"] == 1 + assert payload["day_off_requests_count"] == 0 + assert payload["unpaid_vacation_days"] == 3 + + +def test_statistics_custom_period_requires_valid_dates( + auth_client, admin_user: models.Model, make_user +) -> None: + """Custom период требует обе даты и валидный диапазон.""" + employee = make_user(email="stats-custom-validation@example.com") + + missing_resp = auth_client(admin_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=custom" + ) + invalid_resp = auth_client(admin_user).get( + f"{API_BASE}statistics/?employee_id={employee.id}&period=custom" + "&date_from=2026-04-20&date_to=2026-04-10" + ) + + assert missing_resp.status_code == 400 + assert invalid_resp.status_code == 400 + + def test_list_only_participants_see_requests( auth_client, regular_user: models.Model, make_user, make_request ) -> None: diff --git a/frontend/src/components/requests/RequestStatisticsPanel.tsx b/frontend/src/components/requests/RequestStatisticsPanel.tsx index 8f6e7565..21ae4759 100644 --- a/frontend/src/components/requests/RequestStatisticsPanel.tsx +++ b/frontend/src/components/requests/RequestStatisticsPanel.tsx @@ -11,6 +11,7 @@ const statsPeriods = [ { value: "all", label: "За всё время" }, { value: "year", label: "За год" }, { value: "month", label: "За месяц" }, + { value: "custom", label: "Свой период" }, ] as const; type StatsPeriod = (typeof statsPeriods)[number]["value"]; @@ -28,12 +29,19 @@ export function RequestStatisticsPanel({ const [searchQuery, setSearchQuery] = useState(""); const [employeeId, setEmployeeId] = useState(null); const [period, setPeriod] = useState("all"); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { - if (!canView || !open || !employeeId) { + if ( + !canView + || !open + || !employeeId + || (period === "custom" && (!dateFrom || !dateTo)) + ) { return; } @@ -41,8 +49,17 @@ export function RequestStatisticsPanel({ setLoading(true); setError(null); + const query = new URLSearchParams({ + employee_id: String(employeeId), + period, + }); + if (period === "custom") { + query.set("date_from", dateFrom); + query.set("date_to", dateTo); + } + apiClient.request( - `/api/v1/requests/statistics/?employee_id=${employeeId}&period=${period}` + `/api/v1/requests/statistics/?${query.toString()}` ) .then((response) => { if (!cancelled) { @@ -63,7 +80,7 @@ export function RequestStatisticsPanel({ return () => { cancelled = true; }; - }, [canView, employeeId, open, period]); + }, [canView, dateFrom, dateTo, employeeId, open, period]); if (!canView) return null; @@ -216,12 +233,47 @@ export function RequestStatisticsPanel({ ); })} + + {period === "custom" ? ( +
+ + +
+ ) : null} {!employeeId ? (

Выберите сотрудника, чтобы посмотреть статистику.

+ ) : period === "custom" && (!dateFrom || !dateTo) ? ( +

+ Укажите даты начала и окончания для выбранного периода. +

) : loading || (!stats && !error) ? (
@@ -238,7 +290,9 @@ export function RequestStatisticsPanel({ ? "Период: за всё время" : period === "year" ? "Период: текущий календарный год" - : "Период: текущий календарный месяц"} + : period === "month" + ? "Период: текущий календарный месяц" + : `Период: ${stats?.date_from || dateFrom} — ${stats?.date_to || dateTo}`}

{statsItems.map((item) => ( diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 5b8f409a..21be7364 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -372,7 +372,9 @@ export interface RequestComment { export interface RequestEmployeeStatistics { employee_id: number; employee_name: string; - period: "all" | "year" | "month"; + period: "all" | "year" | "month" | "custom"; + date_from: string | null; + date_to: string | null; total_submitted_requests: number; sick_leave_requests_count: number; day_off_requests_count: number;