From 98096a7af4230281aba4778fdf4c48f65415847b Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 6 Jul 2026 23:39:15 +0300 Subject: [PATCH] Fix request date filters and employee attendance status --- .../api/v1/employees/serializers/employee.py | 91 ++++++++++++++ backend/api/v1/employees/views/employees.py | 33 ++++- backend/api/v1/requests_app/views.py | 17 ++- .../tests/api/v1/employees/test_employees.py | 70 +++++++++++ .../api/v1/requests_app/test_requests_api.py | 117 +++++++++++++++++- frontend/src/app/profile/page.tsx | 18 ++- frontend/src/app/users/[id]/page.tsx | 6 +- .../src/components/users/ProfileSections.tsx | 27 ++++ frontend/src/types/api.ts | 11 ++ 9 files changed, 370 insertions(+), 20 deletions(-) diff --git a/backend/api/v1/employees/serializers/employee.py b/backend/api/v1/employees/serializers/employee.py index 1d200a59..6b46e5cf 100644 --- a/backend/api/v1/employees/serializers/employee.py +++ b/backend/api/v1/employees/serializers/employee.py @@ -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) diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index abfaf9d3..acdc963b 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -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 diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index 4a165da3..c06dcf1a 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -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 diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index 20b82cf2..295815de 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -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") 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 0329b27f..4f130d23 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -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: diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 98886d06..6872693f 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -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() { - {user.personnel_state.label || user.personnel_state.status} - + user.personnel_state || user.attendance_status ? ( +
+ {user.personnel_state ? ( + + {user.personnel_state.label || user.personnel_state.status} + + ) : null} + +
) : null } avatar={ diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index 28ec459b..fe4af496 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -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() { {person.personnel_state ? ( ) : null} + {currentUser ? ( <>