Add custom request statistics period

This commit is contained in:
lizerk 2026-04-20 12:53:45 +03:00
parent fd28a58879
commit 9719b65b58
4 changed files with 237 additions and 11 deletions

View File

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

View File

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

View File

@ -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<number | null>(null);
const [period, setPeriod] = useState<StatsPeriod>("all");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [stats, setStats] = useState<RequestEmployeeStatistics | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<RequestEmployeeStatistics>(
`/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({
);
})}
</div>
{period === "custom" ? (
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Дата начала</span>
<input
type="date"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
setStats(null);
setError(null);
}}
className="app-input w-full rounded-xl px-3 py-2.5 text-sm"
/>
</label>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Дата окончания</span>
<input
type="date"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setStats(null);
setError(null);
}}
className="app-input w-full rounded-xl px-3 py-2.5 text-sm"
/>
</label>
</div>
) : null}
</div>
{!employeeId ? (
<p className="app-text-muted mt-4 text-sm">
Выберите сотрудника, чтобы посмотреть статистику.
</p>
) : period === "custom" && (!dateFrom || !dateTo) ? (
<p className="app-text-muted mt-4 text-sm">
Укажите даты начала и окончания для выбранного периода.
</p>
) : loading || (!stats && !error) ? (
<div className="app-text-muted mt-4 flex items-center gap-2 text-sm">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
@ -238,7 +290,9 @@ export function RequestStatisticsPanel({
? "Период: за всё время"
: period === "year"
? "Период: текущий календарный год"
: "Период: текущий календарный месяц"}
: period === "month"
? "Период: текущий календарный месяц"
: `Период: ${stats?.date_from || dateFrom} — ${stats?.date_to || dateTo}`}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{statsItems.map((item) => (

View File

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