Fix request date filters and employee attendance status

This commit is contained in:
lizerk 2026-07-06 23:39:15 +03:00
parent c0cff00e82
commit 98096a7af4
9 changed files with 370 additions and 20 deletions

View File

@ -89,6 +89,75 @@ def _employee_personnel_state_payload(obj):
}
def _normalize_attendance_time(value) -> str | None:
if not value:
return None
text = str(value).strip()
if not text:
return None
if "T" in text:
text = text.split("T", 1)[1]
if len(text) >= 5 and text[2] == ":":
return text[:5]
return text
def _employee_attendance_status_payload(obj):
has_attendance_annotation = hasattr(obj, "latest_attendance_record_id")
record_id = getattr(obj, "latest_attendance_record_id", None)
record_date = getattr(obj, "latest_attendance_date", None)
arrival_time = getattr(obj, "latest_attendance_arrival_time", None)
departure_time = getattr(obj, "latest_attendance_departure_time", None)
if (
record_id is None
and not has_attendance_annotation
and hasattr(obj, "attendance_records")
):
latest_record = obj.attendance_records.order_by("-date", "-id").first()
if latest_record is not None:
record_id = latest_record.id
record_date = latest_record.date
arrival_time = latest_record.arrival_time
departure_time = latest_record.departure_time
if record_id is None or record_date is None:
return None
event = "none"
label = "Без отметок"
time_value = None
departure = _normalize_attendance_time(departure_time)
arrival = _normalize_attendance_time(arrival_time)
if departure:
event = "departure"
label = "Уход"
time_value = departure
elif arrival:
event = "arrival"
label = "Приход"
time_value = arrival
if hasattr(record_date, "strftime"):
display_date = record_date.strftime("%d.%m.%Y")
else:
display_date = str(record_date)
display = f"{label} · {display_date}"
if time_value:
display = f"{display}, {time_value}"
return {
"record_id": record_id,
"date": record_date,
"time": time_value,
"event": event,
"label": label,
"display": display,
}
class EmployeeDepartmentSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField(allow_null=True)
@ -114,6 +183,15 @@ class EmployeePersonnelStateSerializer(serializers.Serializer):
expects_attendance = serializers.BooleanField()
class EmployeeAttendanceStatusSerializer(serializers.Serializer):
record_id = serializers.IntegerField()
date = serializers.DateField()
time = serializers.CharField(allow_null=True)
event = serializers.CharField()
label = serializers.CharField()
display = serializers.CharField()
class EmployeeSerializer(serializers.ModelSerializer):
"""Полная версия сотрудника для /employees/."""
@ -144,6 +222,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
departments = serializers.SerializerMethodField()
auth = serializers.SerializerMethodField(read_only=True)
personnel_state = serializers.SerializerMethodField(read_only=True)
attendance_status = serializers.SerializerMethodField(read_only=True)
linked_tasks = serializers.SerializerMethodField()
class Meta:
@ -179,6 +258,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
"date_joined",
"auth",
"personnel_state",
"attendance_status",
"linked_tasks",
)
read_only_fields = (
@ -192,6 +272,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
"date_joined",
"auth",
"personnel_state",
"attendance_status",
"linked_tasks",
)
extra_kwargs = {
@ -346,6 +427,10 @@ class EmployeeSerializer(serializers.ModelSerializer):
def get_personnel_state(self, obj):
return _employee_personnel_state_payload(obj)
@extend_schema_field(EmployeeAttendanceStatusSerializer(allow_null=True))
def get_attendance_status(self, obj):
return _employee_attendance_status_payload(obj)
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_linked_tasks(self, obj):
prefetched = getattr(obj, "_linked_task_payloads", None)
@ -415,6 +500,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
skills = SkillSerializer(many=True, read_only=True)
department_relation = serializers.SerializerMethodField()
personnel_state = serializers.SerializerMethodField(read_only=True)
attendance_status = serializers.SerializerMethodField(read_only=True)
linked_tasks = serializers.SerializerMethodField()
class Meta:
@ -435,6 +521,7 @@ class EmployeeListSerializer(serializers.ModelSerializer):
"display_name",
"department_relation",
"personnel_state",
"attendance_status",
"linked_tasks",
)
@ -442,6 +529,10 @@ class EmployeeListSerializer(serializers.ModelSerializer):
def get_personnel_state(self, obj):
return _employee_personnel_state_payload(obj)
@extend_schema_field(EmployeeAttendanceStatusSerializer(allow_null=True))
def get_attendance_status(self, obj):
return _employee_attendance_status_payload(obj)
@extend_schema_field(serializers.ListField(child=serializers.DictField()))
def get_linked_tasks(self, obj):
prefetched = getattr(obj, "_linked_task_payloads", None)

View File

@ -8,6 +8,7 @@ from __future__ import annotations
import logging
from common.emails import send_templated_mail
from attendance.models import AttendanceRecord
from django.contrib.contenttypes.models import ContentType
from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery
from django.utils.crypto import get_random_string
@ -76,6 +77,26 @@ class EmployeeViewSet(viewsets.ModelViewSet):
)
)
@staticmethod
def _with_last_attendance_status(queryset):
latest_attendance = AttendanceRecord.objects.filter(
employee_id=OuterRef("pk")
).order_by("-date", "-id")
return queryset.annotate(
latest_attendance_record_id=Subquery(
latest_attendance.values("id")[:1]
),
latest_attendance_date=Subquery(
latest_attendance.values("date")[:1]
),
latest_attendance_arrival_time=Subquery(
latest_attendance.values("arrival_time")[:1]
),
latest_attendance_departure_time=Subquery(
latest_attendance.values("departure_time")[:1]
),
)
def get_permissions(self):
if self.action == "create":
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
@ -112,11 +133,13 @@ class EmployeeViewSet(viewsets.ModelViewSet):
),
]
qs = self._with_last_activity(
Employee.objects.select_related("position")
.prefetch_related(*prefetches)
.annotate(last_action_code=last_action_code_sq)
.order_by(*self.ordering)
qs = self._with_last_attendance_status(
self._with_last_activity(
Employee.objects.select_related("position")
.prefetch_related(*prefetches)
.annotate(last_action_code=last_action_code_sq)
.order_by(*self.ordering)
)
)
qp = self.request.query_params

View File

@ -341,15 +341,20 @@ class RequestViewSet(viewsets.ModelViewSet):
if created_to:
qs = qs.filter(created_at__date__lte=created_to)
# Фильтры по периоду заявления (date_from/date_to в самой заявке)
# Фильтры по периоду заявления (date_from/date_to в самой заявке).
# Пустой date_to означает однодневное заявление на date_from.
# Заявления без date_from не имеют периода и не участвуют в period-фильтре.
if date_from or date_to:
qs = qs.filter(date_from__isnull=False)
if date_from:
# Заявки, у которых период не закончился до указанной даты
qs = qs.filter(Q(date_to__isnull=True) | Q(date_to__gte=date_from))
if date_to:
# Заявки, у которых период не начался после указанной даты
# effective_date_to >= date_from, где date_to=NULL считается date_from.
qs = qs.filter(
Q(date_from__isnull=True) | Q(date_from__lte=date_to)
Q(date_to__gte=date_from)
| Q(date_to__isnull=True, date_from__gte=date_from)
)
if date_to:
# effective_date_from <= date_to.
qs = qs.filter(date_from__lte=date_to)
# Аннотируем счётчик комментариев через communications.Chat
from communications.models import Message

View File

@ -3,6 +3,7 @@ import itertools
import datetime as dt
import pytest
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
@ -189,6 +190,75 @@ def test_retrieve_ok(api_client: APIClient):
assert data["email"] == e.email
def test_retrieve_exposes_last_attendance_status_to_authenticated_users(
api_client: APIClient,
):
viewer = make_user("attendance-viewer@example.com")
target = make_user("attendance-target@example.com")
run = AttendanceAnalysisRun.objects.create(
employee=target,
period_start=dt.date(2026, 4, 1),
period_end=dt.date(2026, 4, 30),
)
AttendanceRecord.objects.create(
analysis_run=run,
employee=target,
date=dt.date(2026, 4, 10),
arrival_time="08:35",
)
latest = AttendanceRecord.objects.create(
analysis_run=run,
employee=target,
date=dt.date(2026, 4, 11),
arrival_time="08:20",
departure_time="17:45",
)
api_client.force_authenticate(user=viewer)
response = api_client.get(reverse("api:v1:employees-detail", args=[target.pk]))
assert response.status_code == status.HTTP_200_OK
payload = response.json()["attendance_status"]
assert payload == {
"record_id": latest.id,
"date": "2026-04-11",
"time": "17:45",
"event": "departure",
"label": "Уход",
"display": "Уход · 11.04.2026, 17:45",
}
def test_retrieve_last_attendance_status_uses_arrival_when_no_departure(
api_client: APIClient,
):
viewer = make_user("attendance-arrival-viewer@example.com")
target = make_user("attendance-arrival-target@example.com")
run = AttendanceAnalysisRun.objects.create(
employee=target,
period_start=dt.date(2026, 4, 1),
period_end=dt.date(2026, 4, 30),
)
record = AttendanceRecord.objects.create(
analysis_run=run,
employee=target,
date=dt.date(2026, 4, 12),
arrival_time="09:05",
departure_time=None,
)
api_client.force_authenticate(user=viewer)
response = api_client.get(reverse("api:v1:employees-detail", args=[target.pk]))
assert response.status_code == status.HTTP_200_OK
payload = response.json()["attendance_status"]
assert payload["record_id"] == record.id
assert payload["date"] == "2026-04-12"
assert payload["time"] == "09:05"
assert payload["event"] == "arrival"
assert payload["label"] == "Приход"
def test_update_attendance_aliases_normalizes_values(api_client: APIClient):
staff = make_user("staff@example.com", staff=True)
employee = make_user("alias@example.com")

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from typing import Any
import pytest
@ -36,6 +36,10 @@ def _results(payload: Any) -> list[dict[str, Any]]:
return []
def _result_ids(payload: Any) -> set[int]:
return {item["id"] for item in _results(payload)}
def test_list_unauth_401(api_client: APIClient) -> None:
"""Неаутентифицированный доступ к списку → 401."""
resp = api_client.get(API_BASE)
@ -222,6 +226,117 @@ def test_list_addressed_to_me_shows_only_recipient_and_cc_requests(
assert cc_request.id in ids
def test_list_period_filter_matches_overlap_and_excludes_missing_period(
auth_client, regular_user: models.Model, make_request
) -> None:
"""Фильтр периода ищет пересечение интервалов и не подтягивает заявки без дат."""
no_period = make_request(employee=regular_user, type_=RequestType.OTHER)
single_day = make_request(employee=regular_user, type_=RequestType.OTHER)
single_day.date_from = date(2026, 4, 10)
single_day.date_to = None
single_day.save(update_fields=["date_from", "date_to"])
spanning = make_request(employee=regular_user, type_=RequestType.OTHER)
spanning.date_from = date(2026, 4, 5)
spanning.date_to = date(2026, 4, 12)
spanning.save(update_fields=["date_from", "date_to"])
before = make_request(employee=regular_user, type_=RequestType.OTHER)
before.date_from = date(2026, 4, 1)
before.date_to = date(2026, 4, 3)
before.save(update_fields=["date_from", "date_to"])
after = make_request(employee=regular_user, type_=RequestType.OTHER)
after.date_from = date(2026, 4, 20)
after.date_to = None
after.save(update_fields=["date_from", "date_to"])
resp = auth_client(regular_user).get(
f"{API_BASE}?date_from=2026-04-10&date_to=2026-04-10"
)
assert resp.status_code == 200
ids = _result_ids(resp.json())
assert single_day.id in ids
assert spanning.id in ids
assert no_period.id not in ids
assert before.id not in ids
assert after.id not in ids
def test_list_period_filter_with_single_boundary_handles_single_day_requests(
auth_client, regular_user: models.Model, make_request
) -> None:
"""Одна граница периода корректно работает для date_from без date_to."""
no_period = make_request(employee=regular_user, type_=RequestType.OTHER)
old_single_day = make_request(employee=regular_user, type_=RequestType.OTHER)
old_single_day.date_from = date(2026, 4, 1)
old_single_day.date_to = None
old_single_day.save(update_fields=["date_from", "date_to"])
target_single_day = make_request(employee=regular_user, type_=RequestType.OTHER)
target_single_day.date_from = date(2026, 4, 10)
target_single_day.date_to = None
target_single_day.save(update_fields=["date_from", "date_to"])
future_single_day = make_request(employee=regular_user, type_=RequestType.OTHER)
future_single_day.date_from = date(2026, 4, 20)
future_single_day.date_to = None
future_single_day.save(update_fields=["date_from", "date_to"])
from_resp = auth_client(regular_user).get(f"{API_BASE}?date_from=2026-04-10")
to_resp = auth_client(regular_user).get(f"{API_BASE}?date_to=2026-04-10")
assert from_resp.status_code == 200
from_ids = _result_ids(from_resp.json())
assert target_single_day.id in from_ids
assert future_single_day.id in from_ids
assert old_single_day.id not in from_ids
assert no_period.id not in from_ids
assert to_resp.status_code == 200
to_ids = _result_ids(to_resp.json())
assert old_single_day.id in to_ids
assert target_single_day.id in to_ids
assert future_single_day.id not in to_ids
assert no_period.id not in to_ids
def test_list_created_date_filter_includes_same_day_boundaries(
auth_client, regular_user: models.Model, make_request
) -> None:
"""Одинаковые created_from/created_to выбирают весь календарный день."""
tz = timezone.get_current_timezone()
previous = make_request(employee=regular_user, type_=RequestType.OTHER)
previous.created_at = datetime(2026, 4, 9, 23, 59, tzinfo=tz)
previous.save(update_fields=["created_at"])
morning = make_request(employee=regular_user, type_=RequestType.OTHER)
morning.created_at = datetime(2026, 4, 10, 0, 1, tzinfo=tz)
morning.save(update_fields=["created_at"])
evening = make_request(employee=regular_user, type_=RequestType.OTHER)
evening.created_at = datetime(2026, 4, 10, 23, 59, tzinfo=tz)
evening.save(update_fields=["created_at"])
next_day = make_request(employee=regular_user, type_=RequestType.OTHER)
next_day.created_at = datetime(2026, 4, 11, 0, 1, tzinfo=tz)
next_day.save(update_fields=["created_at"])
resp = auth_client(regular_user).get(
f"{API_BASE}?created_from=2026-04-10&created_to=2026-04-10"
)
assert resp.status_code == 200
ids = _result_ids(resp.json())
assert morning.id in ids
assert evening.id in ids
assert previous.id not in ids
assert next_day.id not in ids
def test_list_includes_accessible_linked_tasks(
auth_client, regular_user: models.Model, make_user, make_request
) -> None:

View File

@ -8,6 +8,7 @@ import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCa
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
import {
ProfileContactsPanel,
ProfileAttendanceStatusPill,
ProfileDepartmentBadge,
ProfileDepartmentsCard,
ProfileHeroCard,
@ -116,12 +117,17 @@ export default function ProfilePage() {
<ProfileHeroCard
caption="Мой профиль"
statusBadge={
user.personnel_state ? (
<span
className={`app-status-pill ${getEmployeeActionTone(user.personnel_state.status).badgeClass}`}
>
{user.personnel_state.label || user.personnel_state.status}
</span>
user.personnel_state || user.attendance_status ? (
<div className="flex max-w-full flex-wrap items-center justify-end gap-2">
{user.personnel_state ? (
<span
className={`app-status-pill ${getEmployeeActionTone(user.personnel_state.status).badgeClass}`}
>
{user.personnel_state.label || user.personnel_state.status}
</span>
) : null}
<ProfileAttendanceStatusPill status={user.attendance_status} />
</div>
) : null
}
avatar={

View File

@ -25,6 +25,7 @@ import EmployeeActionModal from "@/components/users/EmployeeActionModal";
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
import {
ProfileContactsPanel,
ProfileAttendanceStatusPill,
ProfileDepartmentBadge,
ProfileDepartmentsCard,
ProfileHeroCard,
@ -367,10 +368,10 @@ export default function UserDetailPage() {
<ProfileHeroCard
caption="Профиль сотрудника"
statusBadge={
(person.personnel_state || latestAction || currentUser) ? (
(person.personnel_state || person.attendance_status || latestAction || currentUser) ? (
<div
ref={profileMenuOpen ? profileMenuRef : null}
className="relative flex items-center gap-2"
className="relative flex max-w-full flex-wrap items-center justify-end gap-2"
>
{person.personnel_state ? (
<span
@ -379,6 +380,7 @@ export default function UserDetailPage() {
{person.personnel_state.label || person.personnel_state.status}
</span>
) : null}
<ProfileAttendanceStatusPill status={person.attendance_status} />
{currentUser ? (
<>
<button

View File

@ -17,6 +17,7 @@ import {
import { apiClient } from "@/lib/api";
import type { EmployeeWorkSchedule } from "@/lib/api/attendance";
import type {
EmployeeAttendanceStatus,
EmployeeDepartment,
EmployeePersonnelState,
Skill,
@ -188,6 +189,32 @@ export function ProfileHeroCard({
);
}
function getAttendanceStatusBadgeClass(status?: EmployeeAttendanceStatus | null) {
if (status?.event === "arrival") return "app-feedback-success";
if (status?.event === "departure") return "app-selected";
return "app-badge";
}
export function ProfileAttendanceStatusPill({
status,
}: {
status?: EmployeeAttendanceStatus | null;
}) {
if (!status) return null;
return (
<span
className={`app-status-pill inline-flex max-w-full items-center gap-1.5 ${getAttendanceStatusBadgeClass(status)}`}
title="Последняя посещаемость"
>
<Clock3 size={13} className="shrink-0" />
<span className="truncate">
{status.display || status.label || "Последняя посещаемость"}
</span>
</span>
);
}
export function ProfileContactsPanel({
rows,
emptyText = "Контакты не указаны",

View File

@ -28,6 +28,15 @@ export interface EmployeePersonnelState {
expects_attendance: boolean;
}
export interface EmployeeAttendanceStatus {
record_id: number;
date: string;
time: string | null;
event: "arrival" | "departure" | "none" | string;
label: string;
display: string;
}
export interface EmployeeDepartment {
id: number;
name: string;
@ -58,6 +67,7 @@ export interface User {
skills?: Skill[];
actions?: EmployeeAction[];
personnel_state?: EmployeePersonnelState;
attendance_status?: EmployeeAttendanceStatus | null;
email_verified?: boolean;
is_ldap_managed?: boolean;
created_at?: string;
@ -333,6 +343,7 @@ export interface TaskLinkedEmployee {
departments?: EmployeeDepartment[];
is_active?: boolean;
personnel_state?: EmployeePersonnelState | null;
attendance_status?: EmployeeAttendanceStatus | null;
} | null;
can_open?: boolean;
object_url?: string | null;