Add custom request statistics period
This commit is contained in:
parent
fd28a58879
commit
9719b65b58
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user