diff --git a/backend/api/v1/attendance/serializers.py b/backend/api/v1/attendance/serializers.py index 0b095add..d1ea571a 100644 --- a/backend/api/v1/attendance/serializers.py +++ b/backend/api/v1/attendance/serializers.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.models import ContentType from rest_framework import serializers from attendance.models import ( @@ -12,6 +13,53 @@ from api.v1.employees.serializers import EmployeeBriefSerializer from employees.constants import ACTION_REMOTE +def _linked_task_payloads(record: AttendanceRecord, user) -> list[dict]: + if not user or not getattr(user, "is_authenticated", False): + return [] + + try: + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + except Exception: + return [] + + content_type = ContentType.objects.get_for_model(AttendanceRecord) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, + content_type=content_type, + object_id=record.id, + task__board__in=accessible_boards, + ) + .select_related("task", "task__board", "task__column") + .order_by("task__title", "task_id") + ) + + return [ + { + "link_id": link.id, + "id": link.task_id, + "title": link.task.title, + "board_id": link.task.board_id, + "board_name": link.task.board.name, + "column_id": link.task.column_id, + "column_name": link.task.column.name, + "column_color": link.task.column.color, + "priority": link.task.priority, + "priority_display": link.task.get_priority_display(), + } + for link in links + ] + + class DateOverrideSerializer(serializers.Serializer): date = serializers.DateField() is_workday = serializers.BooleanField() @@ -310,6 +358,7 @@ class AttendanceRecordSerializer(serializers.ModelSerializer): analysis_run_id = serializers.IntegerField(read_only=True) comments_count = serializers.IntegerField(read_only=True, default=0) non_working_reason = serializers.SerializerMethodField() + linked_tasks = serializers.SerializerMethodField() class Meta: model = AttendanceRecord @@ -347,6 +396,7 @@ class AttendanceRecordSerializer(serializers.ModelSerializer): "manual_edited_at", "raw_data", "comments_count", + "linked_tasks", "updated_at", ) read_only_fields = fields @@ -360,6 +410,11 @@ class AttendanceRecordSerializer(serializers.ModelSerializer): return "Выходной по графику/календарю" return "Нерабочий день" + def get_linked_tasks(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return _linked_task_payloads(obj, user) + class AttendanceRecordUpdateSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/api/v1/attendance/views.py b/backend/api/v1/attendance/views.py index f11f79e9..80f7cb2e 100644 --- a/backend/api/v1/attendance/views.py +++ b/backend/api/v1/attendance/views.py @@ -587,7 +587,9 @@ class AttendanceRecordDetailAPIView(APIView): .annotate(**_comments_count_annotation()), pk=record_id, ) - return Response(AttendanceRecordSerializer(record).data) + return Response( + AttendanceRecordSerializer(record, context={"request": request}).data + ) def patch(self, request, record_id): if not request.user.is_staff: @@ -650,7 +652,9 @@ class AttendanceRecordDetailAPIView(APIView): .annotate(**_comments_count_annotation()) .get(pk=record.pk) ) - return Response(AttendanceRecordSerializer(record).data) + return Response( + AttendanceRecordSerializer(record, context={"request": request}).data + ) class AttendanceRecordDayEventsAPIView(APIView): diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index c8226a25..88507359 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -5,6 +5,7 @@ from django.utils import timezone from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from attendance.models import AttendanceRecord from api.v1.documents.serializers import DocumentReadSerializer from api.v1.employees.serializers import EmployeeBriefSerializer from api.v1.employees.serializers.department import DepartmentBriefSerializer @@ -27,6 +28,7 @@ from tasks.access import ( user_can_access_employee_request, user_can_access_guest, user_can_access_guest_visit, + user_can_access_attendance_record, user_can_access_procurement_request, user_can_access_task_board, ) @@ -154,6 +156,7 @@ class TaskSerializer(serializers.ModelSerializer): linked_employees_count = serializers.SerializerMethodField() linked_guests_count = serializers.SerializerMethodField() linked_guest_visits_count = serializers.SerializerMethodField() + linked_attendance_records_count = serializers.SerializerMethodField() linked_objects_count = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() @@ -186,6 +189,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_employees_count", "linked_guests_count", "linked_guest_visits_count", + "linked_attendance_records_count", "linked_objects_count", "comments_count", "created_at", @@ -207,6 +211,7 @@ class TaskSerializer(serializers.ModelSerializer): "linked_employees_count", "linked_guests_count", "linked_guest_visits_count", + "linked_attendance_records_count", "linked_objects_count", "comments_count", "created_at", @@ -277,6 +282,14 @@ class TaskSerializer(serializers.ModelSerializer): kind=TaskLinkedObjectKind.GUEST_VISIT, ).count() + @extend_schema_field(serializers.IntegerField()) + def get_linked_attendance_records_count(self, obj): + if hasattr(obj, "linked_attendance_records_count"): + return obj.linked_attendance_records_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, + ).count() + @extend_schema_field(serializers.IntegerField()) def get_linked_objects_count(self, obj): if hasattr(obj, "linked_objects_count"): @@ -992,3 +1005,88 @@ class TaskLinkedGuestVisitSerializer(serializers.ModelSerializer): def get_guest_visit_content_type(): return ContentType.objects.get_for_model(GuestVisit) + + +class TaskLinkedAttendanceRecordSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + attendance_record = serializers.SerializerMethodField() + attendance_record_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "attendance_record_id", + "attendance_record", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_attendance_record(self, obj): + if obj.kind != TaskLinkedObjectKind.ATTENDANCE_RECORD: + return None + + record = getattr(obj, "content_object", None) + if record is None: + return None + + return { + "id": record.id, + "employee_id": record.employee_id, + "employee": EmployeeBriefSerializer(record.employee).data, + "date": record.date, + "display_name": record.display_name, + "arrival_time": record.arrival_time, + "departure_time": record.departure_time, + "work_hours": record.work_hours, + "expected_hours": record.expected_hours, + "is_workday": record.is_workday, + "effective_is_workday": record.effective_is_workday, + "is_late": record.is_late, + "late_minutes": record.late_minutes, + "is_early_leave": record.is_early_leave, + "early_leave_minutes": record.early_leave_minutes, + "is_underwork": record.is_underwork, + "underwork_hours": record.underwork_hours, + "is_overtime": record.is_overtime, + "overtime_hours": record.overtime_hours, + "is_absent": record.is_absent, + "statuses": record.statuses, + "employee_issues": record.employee_issues, + "technical_issues": record.technical_issues, + "personnel_status": record.personnel_status, + "personnel_status_label": record.personnel_status_label, + "comments_count": get_comment_count(record), + "updated_at": record.updated_at, + } + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + record = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if ( + obj.kind != TaskLinkedObjectKind.ATTENDANCE_RECORD + or record is None + or not user + ): + return False + return user_can_access_attendance_record(user, record) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + record = getattr(obj, "content_object", None) + if record is None or not self.get_can_open(obj): + return None + return f"/attendance?record={record.id}" + + +def get_attendance_record_content_type(): + return ContentType.objects.get_for_model(AttendanceRecord) diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 978e7e30..5335e055 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -10,6 +10,7 @@ from django.db.models import ( Value, ) from django.db.models.functions import Coalesce +from attendance.models import AttendanceRecord from communications import comments_helpers from communications.models import Message from communications.utils import user_can_access_chat @@ -31,6 +32,7 @@ from tasks.access import ( user_can_access_employee_request, user_can_access_guest, user_can_access_guest_visit, + user_can_access_attendance_record, user_can_access_procurement_request, ) from tasks.models import ( @@ -55,6 +57,7 @@ from .serializers import ( TaskColumnSerializer, TaskLabelSerializer, TaskLinkedCalendarEventSerializer, + TaskLinkedAttendanceRecordSerializer, TaskLinkedDocumentSerializer, TaskLinkedEmployeeSerializer, TaskLinkedGuestSerializer, @@ -64,6 +67,7 @@ from .serializers import ( TaskLinkedRequestSerializer, TaskSerializer, create_default_columns, + get_attendance_record_content_type, get_calendar_event_content_type, get_document_content_type, get_employee_content_type, @@ -333,6 +337,15 @@ class TaskViewSet(viewsets.ModelViewSet): ), distinct=True, ), + linked_attendance_records_count=Count( + "linked_objects", + filter=Q( + linked_objects__kind=( + TaskLinkedObjectKind.ATTENDANCE_RECORD + ), + ), + distinct=True, + ), linked_objects_count=Count("linked_objects", distinct=True), comments_count=Coalesce( Subquery(comments_subquery, output_field=IntegerField()), @@ -862,6 +875,45 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=False, methods=["get"], url_path="linked-attendance-record-tasks") + def linked_attendance_record_tasks(self, request): + attendance_record_id = request.query_params.get("attendance_record_id") + try: + attendance_record_id = int(attendance_record_id) + except (TypeError, ValueError): + return Response( + {"attendance_record_id": ["Укажите ID записи посещаемости."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + record = AttendanceRecord.objects.select_related("employee").get( + id=attendance_record_id, + ) + except AttendanceRecord.DoesNotExist: + return Response( + {"attendance_record_id": ["Запись посещаемости не найдена."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_attendance_record(request.user, record): + return Response( + {"detail": "Нет доступа к этой записи посещаемости."}, + status=status.HTTP_403_FORBIDDEN, + ) + + queryset = ( + self.get_queryset() + .filter( + linked_objects__kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, + linked_objects__content_type=get_attendance_record_content_type(), + linked_objects__object_id=record.id, + ) + .distinct() + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + def _message_links_queryset(self, task): return ( TaskLinkedObject.objects.filter( @@ -942,6 +994,16 @@ class TaskViewSet(viewsets.ModelViewSet): .order_by("-created_at", "-id") ) + def _attendance_record_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, + ) + .select_related("created_by", "content_type") + .order_by("-created_at", "-id") + ) + @action(detail=True, methods=["get", "post"], url_path="linked-messages") def linked_messages(self, request, pk=None): task = self.get_object() @@ -1707,6 +1769,112 @@ class TaskViewSet(viewsets.ModelViewSet): ) return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get", "post"], url_path="linked-attendance-records") + def linked_attendance_records(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedAttendanceRecordSerializer( + self._attendance_record_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + attendance_record_id = request.data.get("attendance_record_id") + try: + attendance_record_id = int(attendance_record_id) + except (TypeError, ValueError): + return Response( + {"attendance_record_id": ["Укажите ID записи посещаемости."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + record = AttendanceRecord.objects.select_related("employee").get( + id=attendance_record_id, + ) + except AttendanceRecord.DoesNotExist: + return Response( + {"attendance_record_id": ["Запись посещаемости не найдена."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_attendance_record(request.user, record): + return Response( + {"detail": "Нет доступа к этой записи посещаемости."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, + content_type=get_attendance_record_content_type(), + object_id=record.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedAttendanceRecordSerializer( + link, + context={"request": request}, + ) + if created: + employee_name = str(record.employee) if record.employee_id else "" + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, + object_id=record.id, + metadata={ + "object_label": f"{employee_name} {record.date}".strip(), + "object_type": "Запись посещаемости", + }, + ) + send_task_board_update( + task.board, + "linked", + "attendance_record", + record.id, + extra={"task_id": task.id, "link_id": link.id}, + ) + return Response( + serializer.data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"linked-attendance-records/(?P\d+)", + ) + def unlink_attendance_record(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._attendance_record_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + attendance_record_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, + object_id=attendance_record_id, + metadata={"object_type": "Запись посещаемости"}, + ) + send_task_board_update( + task.board, + "unlinked", + "attendance_record", + attendance_record_id, + extra={"task_id": task.id, "link_id": int(link_id)}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get", "post"], url_path="linked-events") def linked_events(self, request, pk=None): task = self.get_object() diff --git a/backend/tasks/access.py b/backend/tasks/access.py index c6c85184..b2d5b6c2 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -1,4 +1,5 @@ from django.db.models import Q +from attendance.models import AttendanceRecord from documents.models import Document from employees.models import Department, EmployeeDepartment, RoleAssignment from requests_app.enums import RequestStatus @@ -161,3 +162,16 @@ def user_can_access_guest_visit(user, visit) -> bool: from guests.permissions import is_guest_admin return is_guest_admin(user) or visit.inviter_id == user.id + + +def user_can_access_attendance_record( + user, + record: AttendanceRecord | None, +) -> bool: + if not user or not user.is_authenticated or record is None: + return False + return bool( + user.is_staff + or user.is_superuser + or record.employee_id == user.id + ) diff --git a/backend/tasks/migrations/0010_alter_taskactivity_object_kind_and_more.py b/backend/tasks/migrations/0010_alter_taskactivity_object_kind_and_more.py new file mode 100644 index 00000000..34a5eca3 --- /dev/null +++ b/backend/tasks/migrations/0010_alter_taskactivity_object_kind_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2026-07-06 19:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0009_add_guest_link_kinds'), + ] + + operations = [ + migrations.AlterField( + model_name='taskactivity', + name='object_kind', + field=models.CharField(blank=True, choices=[('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип объекта'), + ), + migrations.AlterField( + model_name='tasklinkedobject', + name='kind', + field=models.CharField(choices=[('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип связи'), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 41af9c41..0356032a 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -182,6 +182,7 @@ class TaskLinkedObjectKind(models.TextChoices): EMPLOYEE = "employee", "Сотрудник" GUEST = "guest", "Гость" GUEST_VISIT = "guest_visit", "Заявка на гостевой визит" + ATTENDANCE_RECORD = "attendance_record", "Запись посещаемости" class TaskActivityAction(models.TextChoices): diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index 0a9af5ac..23352432 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -1,8 +1,9 @@ from unittest.mock import patch from io import BytesIO -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta import pytest +from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils import timezone from openpyxl import load_workbook @@ -22,6 +23,13 @@ from employees.constants import ( ACTION_RETURNED_FROM_LEAVE, ) from employees.models import EmployeeAction +from tasks.models import ( + Task, + TaskBoard, + TaskColumn, + TaskLinkedObject, + TaskLinkedObjectKind, +) pytestmark = pytest.mark.django_db @@ -185,6 +193,68 @@ def test_analyze_saves_run_and_records(auth_client_factory, user_factory): assert record.raw_data["employee_id"] == "42" +def test_attendance_records_include_visible_linked_tasks( + auth_client_factory, + user_factory, +): + employee = user_factory() + client = auth_client_factory(employee) + run = AttendanceAnalysisRun.objects.create( + employee=employee, + period_start=date(2026, 4, 20), + period_end=date(2026, 4, 20), + ) + record = AttendanceRecord.objects.create( + analysis_run=run, + employee=employee, + date=date(2026, 4, 20), + display_name="Сотрудник Посещаемость", + ) + board = TaskBoard.objects.create( + name="Доска посещаемости", + created_by=employee, + ) + column = TaskColumn.objects.create( + board=board, + name="Новые", + color="#38bdf8", + position=1000, + ) + task = Task.objects.create( + board=board, + column=column, + title="Разобрать посещаемость", + created_by=employee, + priority="high", + ) + link = TaskLinkedObject.objects.create( + task=task, + kind=TaskLinkedObjectKind.ATTENDANCE_RECORD, + content_type=ContentType.objects.get_for_model(AttendanceRecord), + object_id=record.id, + created_by=employee, + ) + + response = client.get(_records_url(), {"employee_id": employee.id}) + + assert response.status_code == 200 + payload = response.data["results"][0] + assert payload["linked_tasks"] == [ + { + "link_id": link.id, + "id": task.id, + "title": "Разобрать посещаемость", + "board_id": board.id, + "board_name": "Доска посещаемости", + "column_id": column.id, + "column_name": "Новые", + "column_color": "#38bdf8", + "priority": "high", + "priority_display": "Высокий", + } + ] + + def test_weekly_summary_counts_present_and_absent_people( auth_client_factory, user_factory, diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index ff3383e8..a9c571a1 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -1,5 +1,5 @@ from unittest.mock import patch -from datetime import timedelta +from datetime import date, timedelta import pytest from django.contrib.auth import get_user_model @@ -9,6 +9,7 @@ from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient +from attendance.models import AttendanceAnalysisRun, AttendanceRecord from communications.models import Chat, ChatMembership, Message from documents.models import Document from employees.models import Department, EmployeeDepartment, RoleAssignment @@ -892,6 +893,92 @@ def test_task_linked_guest_visit_respects_visit_access(api_client): assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN +def test_task_linked_attendance_record_respects_record_access(api_client): + owner = make_user("task-attendance-owner@example.com", "+79994440042") + employee = make_user( + "task-attendance-employee@example.com", + "+79994440043", + first_name="Петр", + last_name="Посещаемый", + ) + outsider = make_user("task-attendance-outsider@example.com", "+79994440044") + run = AttendanceAnalysisRun.objects.create( + employee=employee, + period_start=date(2026, 4, 20), + period_end=date(2026, 4, 20), + triggered_by=owner, + ) + record = AttendanceRecord.objects.create( + analysis_run=run, + employee=employee, + date=date(2026, 4, 20), + display_name="Посещаемый Петр", + arrival_time="09:10", + departure_time="18:05", + work_hours=8.9, + expected_hours=9, + is_late=True, + late_minutes=10, + ) + board = TaskBoard.objects.create( + name="Доска с посещаемостью", + created_by=owner, + ) + board.members.add(employee) + column = TaskColumn.objects.create( + board=board, + name="Новые", + position=1000, + color="#38bdf8", + ) + task = Task.objects.create( + board=board, + column=column, + title="Проверить посещаемость", + created_by=owner, + ) + + api_client.force_authenticate(user=employee) + link_response = api_client.post( + reverse( + "api:v1:tasks:task-linked-attendance-records", + kwargs={"pk": task.id}, + ), + {"attendance_record_id": record.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["attendance_record_id"] == record.id + assert link_response.data["attendance_record"]["employee"]["id"] == employee.id + assert str(link_response.data["attendance_record"]["date"]) == "2026-04-20" + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == f"/attendance?record={record.id}" + + linked_response = api_client.get( + reverse( + "api:v1:tasks:task-linked-attendance-records", + kwargs={"pk": task.id}, + ) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["attendance_record"]["arrival_time"] == "09:10" + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-attendance-record-tasks"), + {"attendance_record_id": record.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_200_OK + assert reverse_linked_response.data[0]["id"] == task.id + assert reverse_linked_response.data[0]["linked_attendance_records_count"] == 1 + + api_client.force_authenticate(user=outsider) + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-attendance-record-tasks"), + {"attendance_record_id": record.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_403_FORBIDDEN + + def test_task_activity_records_core_actions(api_client, user): api_client.force_authenticate(user=user) board_response = api_client.get( diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index b87d2db8..9414c472 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -856,9 +856,7 @@ export default function AttendancePage() { useEffect(() => { const params = new URLSearchParams(window.location.search); - const shouldOpenEvents = params.get("events") === "1"; const shouldOpenComments = params.get("comments") === "1"; - if (!shouldOpenEvents && !shouldOpenComments) return; const recordId = Number(params.get("record")); if (!Number.isInteger(recordId) || recordId <= 0) return; diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 5f582918..7d82aeb2 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -56,6 +56,7 @@ import type { TaskCard, TaskColumn, TaskComment, + TaskLinkedAttendanceRecord, TaskLinkedDocument, TaskLinkedEmployee, TaskLinkedCalendarEvent, @@ -1353,6 +1354,123 @@ function LinkedGuestVisitCard({ ); } +function formatAttendanceHours(value: unknown) { + const numeric = Number(value || 0); + if (!Number.isFinite(numeric)) return "0"; + return Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(1); +} + +function LinkedAttendanceRecordCard({ + link, + onUnlink, + disabled, +}: { + link: TaskLinkedAttendanceRecord; + onUnlink: (linkId: number) => void; + disabled?: boolean; +}) { + const record = link.attendance_record; + const canOpen = Boolean(link.can_open && link.object_url); + const employeeName = record?.display_name + || (record?.employee ? displayUserName(record.employee) : "") + || "Сотрудник не указан"; + const issueLabels = [ + record?.is_late ? "Опоздание" : "", + record?.is_early_leave ? "Ранний уход" : "", + record?.is_underwork ? "Недоработка" : "", + record?.is_overtime ? "Переработка" : "", + record?.is_absent ? "Отсутствие" : "", + ].filter(Boolean); + const mainContent = ( + <> +
+
+ + + Посещаемость + + {record?.personnel_status_label ? ( + + {record.personnel_status_label} + + ) : null} + {canOpen ? ( + + Открыть + + ) : null} +
+

+ {employeeName} +

+ {record?.date ? ( +

+ {formatDate(record.date)} +

+ ) : null} +
+ +
+ + Приход: {record?.arrival_time || "Нет"} + + + Уход: {record?.departure_time || "Нет"} + + + Часы: {formatAttendanceHours(record?.work_hours)} + {record?.expected_hours !== undefined ? ` / ${formatAttendanceHours(record.expected_hours)}` : ""} + +
+ + {issueLabels.length > 0 ? ( +
+ {issueLabels.map((label) => ( + + {label} + + ))} +
+ ) : null} + +

+ Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"} +

+ + ); + + return ( +
+
+ {canOpen && link.object_url ? ( + + {mainContent} + + ) : ( +
+ {mainContent} +
+ )} + + +
+
+ ); +} + const activityFieldLabels: Record = { title: "название", description: "описание", @@ -1459,6 +1577,7 @@ export default function TasksPage() { const [linkedEmployees, setLinkedEmployees] = useState([]); const [linkedGuests, setLinkedGuests] = useState([]); const [linkedGuestVisits, setLinkedGuestVisits] = useState([]); + const [linkedAttendanceRecords, setLinkedAttendanceRecords] = useState([]); const [linkedObjectsLoading, setLinkedObjectsLoading] = useState(false); const [commentsOpen, setCommentsOpen] = useState(false); const [taskComments, setTaskComments] = useState([]); @@ -1512,6 +1631,7 @@ export default function TasksPage() { employees, guests, guestVisits, + attendanceRecords, ] = await Promise.all([ apiClient.getTaskLinkedMessages(taskId), apiClient.getTaskLinkedEvents(taskId), @@ -1521,6 +1641,7 @@ export default function TasksPage() { apiClient.getTaskLinkedEmployees(taskId), apiClient.getTaskLinkedGuests(taskId), apiClient.getTaskLinkedGuestVisits(taskId), + apiClient.getTaskLinkedAttendanceRecords(taskId), ]); setLinkedMessages(messages); setLinkedEvents(events); @@ -1530,6 +1651,7 @@ export default function TasksPage() { setLinkedEmployees(employees); setLinkedGuests(guests); setLinkedGuestVisits(guestVisits); + setLinkedAttendanceRecords(attendanceRecords); } catch (linksError) { setError(getTaskError(linksError, "Не удалось загрузить связанные объекты")); } finally { @@ -1862,7 +1984,7 @@ export default function TasksPage() { () => activeColumns.find((column) => column.id === viewTask?.column) || null, [activeColumns, viewTask?.column], ); - const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length; + const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length; const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length; @@ -1885,6 +2007,7 @@ export default function TasksPage() { setLinkedEmployees([]); setLinkedGuests([]); setLinkedGuestVisits([]); + setLinkedAttendanceRecords([]); setTaskActivities([]); setTaskComments([]); setCommentDraft(""); @@ -1935,6 +2058,7 @@ export default function TasksPage() { setLinkedEmployees([]); setLinkedGuests([]); setLinkedGuestVisits([]); + setLinkedAttendanceRecords([]); setTaskComments([]); setTaskActivities([]); setCommentDraft(""); @@ -1958,6 +2082,7 @@ export default function TasksPage() { setLinkedEmployees([]); setLinkedGuests([]); setLinkedGuestVisits([]); + setLinkedAttendanceRecords([]); setTaskComments([]); setTaskActivities([]); setLinkedObjectsOpen(false); @@ -2424,6 +2549,23 @@ export default function TasksPage() { } }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + const unlinkAttendanceRecordFromViewedTask = useCallback(async (linkId: number) => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.unlinkTaskAttendanceRecord(viewTask.id, linkId); + await loadTaskLinkedObjects(viewTask.id); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (unlinkError) { + setError(getTaskError(unlinkError, "Не удалось убрать связь с записью посещаемости")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + const changeViewedTaskColumn = useCallback(async (columnId: number) => { if (!board || !viewTask || saving || viewTask.column === columnId) { setViewColumnMenuOpen(false); @@ -3274,6 +3416,14 @@ export default function TasksPage() { disabled={saving} /> ))} + {linkedAttendanceRecords.map((link) => ( + + ))} ) : (
diff --git a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx index 3952bf0d..7b408544 100644 --- a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx +++ b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx @@ -8,6 +8,7 @@ import { ChevronRight, Clock, Edit3, + Link2, Loader2, LogIn, LogOut, @@ -21,6 +22,8 @@ import { type AttendanceDayEventsPreview, } from "@/components/attendance/AttendanceDayEventsModal"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; +import TaskLinkPill from "@/components/tasks/TaskLinkPill"; +import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks"; import { Modal } from "@/components/ui"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; @@ -32,7 +35,7 @@ import type { AttendanceRecordUpdatePayload, PaginatedAttendanceRecords, } from "@/lib/api/attendance"; -import type { EmployeeAction } from "@/types/api"; +import type { EmployeeAction, TaskCard } from "@/types/api"; import { getPersonnelDayMeta } from "./personnel-day-meta.js"; type EmployeeAttendanceCardProps = { @@ -444,6 +447,7 @@ type AttendanceCardApiClient = typeof apiClient & { addAttendanceRecordComment?: unknown; analyzeAttendance?: unknown; deleteAttendanceRecordComment?: unknown; + getAttendanceRecord?: unknown; getAttendanceRecordComments?: unknown; getAttendanceRecords?: unknown; updateAttendanceRecord?: unknown; @@ -496,6 +500,14 @@ function getAttendanceRecords(params: { ); } +function getAttendanceRecord(recordId: number) { + const client = attendanceApiClient(); + if (typeof client.getAttendanceRecord === "function") { + return client.getAttendanceRecord(recordId) as Promise; + } + return client.request(`/api/v1/attendance/records/${recordId}/`); +} + function getAttendanceRecordComments(recordId: number) { const client = attendanceApiClient(); if (typeof client.getAttendanceRecordComments === "function") { @@ -577,6 +589,7 @@ export default function EmployeeAttendanceCard({ const [aliasError, setAliasError] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); const [editForm, setEditForm] = useState(null); + const [taskLinkRecord, setTaskLinkRecord] = useState(null); const canManageAttendance = Boolean( currentUser?.auth?.is_staff || currentUser?.auth?.is_superuser, @@ -744,6 +757,23 @@ export default function EmployeeAttendanceCard({ }); } + async function refreshRecord(recordId: number) { + const updated = await getAttendanceRecord(recordId); + setResult((current) => { + if (!current?.records) return current; + return { + ...current, + records: current.records.map((record) => + getRecordId(record) === recordId ? { ...record, ...updated } : record, + ), + }; + }); + setTaskLinkRecord((current) => ( + current && getRecordId(current) === recordId ? updated : current + )); + return updated; + } + async function ensureCommentsLoaded(recordId: number) { if (commentsMap[recordId] || commentsLoadingMap[recordId]) return; @@ -942,6 +972,23 @@ export default function EmployeeAttendanceCard({ } } + const taskLinkRecordId = taskLinkRecord ? getRecordId(taskLinkRecord) : 0; + const taskLinkEmployeeName = taskLinkRecord?.display_name + || displayUserName(currentUser) + || `Сотрудник ${employeeId}`; + const taskLinkDate = taskLinkRecord?.date || "Дата не указана"; + const taskLinkDescription = taskLinkRecord + ? [ + `Сотрудник: ${taskLinkEmployeeName}`, + `Дата: ${taskLinkDate}`, + `Приход: ${formatTime(taskLinkRecord.arrival_time)}`, + `Уход: ${formatTime(taskLinkRecord.departure_time)}`, + `Часы: ${formatHours(taskLinkRecord.work_hours)} / ${formatHours(taskLinkRecord.expected_hours)}`, + ...issueLabels(taskLinkRecord, getPersonnelDayMeta(employeeActions, taskLinkRecord.date, taskLinkRecord)) + .map((label) => `Замечание: ${formatAttendanceIssueLabel(label)}`), + ].join("\n") + : ""; + return ( <>
@@ -1116,6 +1163,7 @@ export default function EmployeeAttendanceCard({ const commentsCount = comments.length || Number(record.comments_count || 0); const commentsLoading = recordId ? Boolean(commentsLoadingMap[recordId]) : false; const commentDraft = recordId ? (commentDrafts[recordId] || "") : ""; + const linkedTasks = record.linked_tasks || []; return (
@@ -1147,6 +1195,16 @@ export default function EmployeeAttendanceCard({ ) : null} +
+ {linkedTasks.length > 0 ? ( +
+ {linkedTasks.slice(0, 3).map((task) => ( + + ))} + {linkedTasks.length > 3 ? ( + + +{linkedTasks.length - 3} + + ) : null} +
+ ) : null} {(expanded || commentsOpen) ? (
@@ -1334,6 +1408,24 @@ export default function EmployeeAttendanceCard({ record={dayEventsRecordPreview} /> + {taskLinkRecord && taskLinkRecordId ? ( + apiClient.getAttendanceRecordLinkedTasks(taskLinkRecordId) as Promise} + linkTask={(taskId) => apiClient.linkTaskAttendanceRecord(taskId, taskLinkRecordId)} + onClose={() => setTaskLinkRecord(null)} + onLinked={() => void refreshRecord(taskLinkRecordId)} + /> + ) : null} + setAliasModalOpen(false)} diff --git a/frontend/src/lib/api/attendance.ts b/frontend/src/lib/api/attendance.ts index 30969aeb..bca3a9f0 100644 --- a/frontend/src/lib/api/attendance.ts +++ b/frontend/src/lib/api/attendance.ts @@ -1,5 +1,5 @@ import { buildQuery, type GetTokenFn, type RequestFn } from './utils'; -import type { User } from '@/types/api'; +import type { MessageLinkedTask, User } from '@/types/api'; export type AttendanceDateOverridePayload = { date: string; @@ -85,6 +85,7 @@ export type AttendanceRecord = { employee_issues?: string[]; technical_issues?: string[]; statuses?: string[]; + linked_tasks?: MessageLinkedTask[]; comments_count?: number; personnel_status?: string; personnel_status_label?: string; diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index f5f16107..e9f626ba 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -6,6 +6,7 @@ import type { TaskActivity, TaskComment, TaskLabel, + TaskLinkedAttendanceRecord, TaskLinkedCalendarEvent, TaskLinkedDocument, TaskLinkedEmployee, @@ -92,6 +93,8 @@ export function createTasksApi(request: RequestFn) { request(`/api/v1/tasks/linked-guest-tasks/?guest_id=${guestId}`), getGuestVisitLinkedTasks: (guestVisitId: number): Promise => request(`/api/v1/tasks/linked-guest-visit-tasks/?guest_visit_id=${guestVisitId}`), + getAttendanceRecordLinkedTasks: (attendanceRecordId: number): Promise => + request(`/api/v1/tasks/linked-attendance-record-tasks/?attendance_record_id=${attendanceRecordId}`), getTaskLinkedMessages: (id: number): Promise => request(`/api/v1/tasks/${id}/linked-messages/`), linkTaskMessage: (id: number, messageId: number): Promise => @@ -169,6 +172,17 @@ export function createTasksApi(request: RequestFn) { request(`/api/v1/tasks/${id}/linked-guest-visits/${linkId}/`, { method: "DELETE", }), + getTaskLinkedAttendanceRecords: (id: number): Promise => + request(`/api/v1/tasks/${id}/linked-attendance-records/`), + linkTaskAttendanceRecord: (id: number, attendanceRecordId: number): Promise => + request(`/api/v1/tasks/${id}/linked-attendance-records/`, { + method: "POST", + body: JSON.stringify({ attendance_record_id: attendanceRecordId }), + }), + unlinkTaskAttendanceRecord: (id: number, linkId: number): Promise => + request(`/api/v1/tasks/${id}/linked-attendance-records/${linkId}/`, { + method: "DELETE", + }), getTaskLinkedEvents: (id: number): Promise => request(`/api/v1/tasks/${id}/linked-events/`), linkTaskCalendarEvent: (id: number, eventId: number): Promise => diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 979b6974..0a246fba 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -188,6 +188,7 @@ export interface TaskCard { linked_employees_count?: number; linked_guests_count?: number; linked_guest_visits_count?: number; + linked_attendance_records_count?: number; linked_objects_count?: number; comments_count?: number; created_at: string; @@ -367,12 +368,51 @@ export interface TaskLinkedGuestVisit { created_at: string; } +export interface TaskLinkedAttendanceRecord { + id: number; + kind: "attendance_record"; + attendance_record_id: number; + attendance_record: { + id: number; + employee_id?: number; + employee?: User | null; + date?: string; + display_name?: string; + arrival_time?: string | null; + departure_time?: string | null; + work_hours?: number | string | null; + expected_hours?: number | string | null; + is_workday?: boolean; + effective_is_workday?: boolean; + is_late?: boolean; + late_minutes?: number | null; + is_early_leave?: boolean; + early_leave_minutes?: number | null; + is_underwork?: boolean; + underwork_hours?: number | string | null; + is_overtime?: boolean; + overtime_hours?: number | string | null; + is_absent?: boolean; + statuses?: string[]; + employee_issues?: string[]; + technical_issues?: string[]; + personnel_status?: string; + personnel_status_label?: string; + comments_count?: number; + updated_at?: string; + } | null; + can_open?: boolean; + object_url?: string | null; + created_by?: User; + created_at: string; +} + export interface TaskActivity { id: number; action: "created" | "updated" | "moved" | "linked" | "unlinked"; action_display?: string; actor?: User | null; - object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | ""; + object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | ""; object_id?: number | null; metadata?: Record; created_at: string;