From 9ec4c2349fe554f8cdf4b392de81f76828576c89 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 15 Apr 2026 01:31:18 +0300 Subject: [PATCH] Add user last activity and refine profile cards --- .../api/v1/employees/serializers/employee.py | 7 ++ backend/api/v1/employees/views/employees.py | 15 ++++- backend/tests/api/v1/test_directory_login.py | 39 +++++++++++ frontend/src/app/profile/page.tsx | 5 ++ frontend/src/app/users/[id]/page.tsx | 44 ++----------- .../src/components/users/ProfileSections.tsx | 64 +++++++++++++++++-- frontend/src/hooks/useProfilePage.ts | 8 ++- frontend/src/types/api.ts | 1 + 8 files changed, 135 insertions(+), 48 deletions(-) diff --git a/backend/api/v1/employees/serializers/employee.py b/backend/api/v1/employees/serializers/employee.py index cf9f9392..5c26e3e9 100644 --- a/backend/api/v1/employees/serializers/employee.py +++ b/backend/api/v1/employees/serializers/employee.py @@ -43,6 +43,7 @@ class EmployeeSerializer(serializers.ModelSerializer): avatar = Base64ImageField(required=False, allow_null=True) actions = EmployeeActionSerializer(many=True, read_only=True) username = serializers.CharField(read_only=True, allow_blank=True) + last_activity_at = serializers.SerializerMethodField() skills = SkillSerializer(many=True, read_only=True) skills_ids = serializers.PrimaryKeyRelatedField( @@ -89,6 +90,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "created_at", "updated_at", "last_login", + "last_activity_at", "date_joined", "auth", ) @@ -99,6 +101,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "created_at", "updated_at", "last_login", + "last_activity_at", "date_joined", "auth", ) @@ -209,6 +212,10 @@ class EmployeeSerializer(serializers.ModelSerializer): return fields + @extend_schema_field(OpenApiTypes.DATETIME) + def get_last_activity_at(self, obj): + return getattr(obj, "last_activity_at", None) + @extend_schema_field(OpenApiTypes.OBJECT) def get_auth(self, obj): req = self.context.get("request") diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index d1ca627f..636fe42d 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -8,7 +8,7 @@ from __future__ import annotations import logging from common.emails import send_templated_mail -from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery +from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery from django.utils.crypto import get_random_string from employees.constants import ACTION_DISMISSED from employees.models import ( @@ -62,6 +62,15 @@ class EmployeeViewSet(viewsets.ModelViewSet): ordering_fields = ["last_name", "first_name", "created_at", "id"] ordering = ["last_name", "first_name"] + @staticmethod + def _with_last_activity(queryset): + return queryset.annotate( + last_activity_at=Max( + "auth_sessions__last_seen_at", + filter=Q(auth_sessions__revoked_at__isnull=True), + ) + ) + def get_permissions(self): if self.action == "create": return [IsAuthenticated(), AdminOrActionOrModelPerms()] @@ -96,7 +105,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): ), ] - qs = ( + qs = self._with_last_activity( Employee.objects.select_related("position") .prefetch_related(*prefetches) .annotate(last_action_code=last_action_code_sq) @@ -263,7 +272,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): if request.method == "GET": instance = ( - Employee.objects.select_related("position") + self._with_last_activity(Employee.objects.select_related("position")) .prefetch_related( "skills", Prefetch( diff --git a/backend/tests/api/v1/test_directory_login.py b/backend/tests/api/v1/test_directory_login.py index f72d973b..11b7a778 100644 --- a/backend/tests/api/v1/test_directory_login.py +++ b/backend/tests/api/v1/test_directory_login.py @@ -3,9 +3,13 @@ from unittest.mock import patch import pytest from django.contrib.auth import get_user_model +from django.utils.dateparse import parse_datetime +from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient +from api.auth.models import UserAuthSession + User = get_user_model() @@ -53,6 +57,41 @@ def test_employees_me_returns_cached_username(): assert response.json()["username"] == "cached.login" +@pytest.mark.django_db +def test_employees_me_returns_last_activity_from_active_sessions(): + client = APIClient() + user = make_user(username="cached.login", is_ldap_managed=True) + client.force_authenticate(user=user) + + older = timezone.now() - timezone.timedelta(hours=3) + newer = timezone.now() - timezone.timedelta(minutes=7) + + older_session = UserAuthSession.objects.create( + user=user, + device_name="Firefox on Linux", + ) + UserAuthSession.objects.filter(pk=older_session.pk).update(last_seen_at=older) + + newer_session = UserAuthSession.objects.create( + user=user, + device_name="Chrome on Linux", + ) + UserAuthSession.objects.filter(pk=newer_session.pk).update(last_seen_at=newer) + + revoked = UserAuthSession.objects.create( + user=user, + device_name="Safari on macOS", + ) + UserAuthSession.objects.filter(pk=revoked.pk).update(last_seen_at=timezone.now()) + revoked.revoke(commit=True) + + response = client.get("/api/v1/employees/me/") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["last_activity_at"] is not None + assert parse_datetime(response.json()["last_activity_at"]) == newer + + @pytest.mark.django_db def test_directory_login_returns_cached_username_without_ldap_lookup(settings): settings.LDAP_ENABLED = True diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index bffaa420..60ce08d7 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -8,6 +8,7 @@ import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline" import { ProfileContactsPanel, ProfileDepartmentBadge, + ProfileDepartmentsCard, ProfileHeroCard, ProfileInfoCard, ProfileSkillsCard, @@ -170,6 +171,10 @@ export default function ProfilePage() { + {user.departments?.length ? ( + + ) : null} + { if (!person || skillsSaving) return; @@ -504,40 +507,7 @@ export default function UserDetailPage() { {person.departments?.length ? ( -
-
-

Отделы

-
-
-
- {person.departments.map((department) => ( - -
-
-

- {department.name} -

- {department.role_name ? ( -

- {department.role_name} -

- ) : null} -
- {department.is_head ? ( - - Руководитель - - ) : null} -
- - ))} -
-
-
+ ) : null} = 2 ? "border-t border-[var(--border-subtle)]" : "", - index >= 2 && index % 2 === 1 ? "md:border-l" : "", + index > 0 ? "border-t border-[var(--border-subtle)]" : "", + index % 2 === 1 ? "md:border-l" : "", + index === 1 ? "md:border-t-0" : "", ].join(" ").trim()} >

@@ -319,3 +319,57 @@ export function ProfileDepartmentBadge({ ); } + +export function ProfileDepartmentsCard({ + title = "Отделы", + departments, +}: { + title?: string; + departments: EmployeeDepartment[]; +}) { + if (!departments.length) { + return null; + } + + return ( +

+ +
+
+ {departments.map((department) => ( + + + {department.name} + + {department.role_name ? ( + + {department.role_name} + + ) : null} + {department.is_head ? ( + + + + ) : null} + {department.via_assignment ? ( + + + + ) : null} + + ))} +
+
+
+ ); +} diff --git a/frontend/src/hooks/useProfilePage.ts b/frontend/src/hooks/useProfilePage.ts index 221ae23c..707a87e5 100644 --- a/frontend/src/hooks/useProfilePage.ts +++ b/frontend/src/hooks/useProfilePage.ts @@ -409,11 +409,13 @@ export function useProfilePage(): ProfilePageController { value: formatProfileDate(user?.created_at), }, { - label: "Последний вход", - value: formatProfileDateTime(user?.last_login), + label: "Последняя активность", + value: formatProfileDateTime( + user?.last_activity_at || user?.last_login, + ), }, ], - [user?.created_at, user?.date_joined, user?.last_login], + [user?.created_at, user?.date_joined, user?.last_activity_at, user?.last_login], ); const onInputSkillName = useCallback((value: string) => { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index f74d3dc9..d191a4f4 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -51,6 +51,7 @@ export interface User { created_at?: string; updated_at?: string; last_login?: string; + last_activity_at?: string; date_joined?: string; auth?: { id?: number;