Add user last activity and refine profile cards

This commit is contained in:
lizerk 2026-04-15 01:31:18 +03:00
parent c9549065fe
commit 9ec4c2349f
8 changed files with 135 additions and 48 deletions

View File

@ -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")

View File

@ -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(

View File

@ -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

View File

@ -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() {
<ProfileInfoCard items={infoItems} />
{user.departments?.length ? (
<ProfileDepartmentsCard departments={user.departments} />
) : null}
<EmployeeActionsTimeline
actionLoading={null}
canManageActions={false}

View File

@ -22,6 +22,7 @@ import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline"
import {
ProfileContactsPanel,
ProfileDepartmentBadge,
ProfileDepartmentsCard,
ProfileHeroCard,
ProfileInfoCard,
ProfileSkillsCard,
@ -258,10 +259,12 @@ export default function UserDetailPage() {
value: formatProfileDate(person?.created_at),
},
{
label: "Последний вход",
value: formatProfileDateTime(person?.last_login),
label: "Последняя активность",
value: formatProfileDateTime(
person?.last_activity_at || person?.last_login,
),
},
]), [person?.created_at, person?.date_joined, person?.last_login]);
]), [person?.created_at, person?.date_joined, person?.last_activity_at, person?.last_login]);
const handleAddSkill = async (forcedSkill?: { id: number; name: string }) => {
if (!person || skillsSaving) return;
@ -504,40 +507,7 @@ export default function UserDetailPage() {
<ProfileInfoCard items={infoItems} />
{person.departments?.length ? (
<section className="app-surface rounded-2xl p-5">
<div className="mb-4">
<h2 className="app-card-caption">Отделы</h2>
</div>
<div className="app-surface-muted rounded-2xl p-4">
<div className="space-y-3">
{person.departments.map((department) => (
<Link
key={department.id}
href={`/departments/${department.id}`}
className="app-surface block rounded-2xl px-4 py-3 transition hover:border-[var(--accent-primary)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">
{department.name}
</p>
{department.role_name ? (
<p className="app-text-muted mt-1 text-sm">
{department.role_name}
</p>
) : null}
</div>
{department.is_head ? (
<span className="app-pill-active inline-flex rounded-full px-3 py-1 text-xs font-medium">
Руководитель
</span>
) : null}
</div>
</Link>
))}
</div>
</div>
</section>
<ProfileDepartmentsCard departments={person.departments} />
) : null}
<EmployeeActionsTimeline

View File

@ -2,8 +2,8 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { Plus, X } from "lucide-react";
import type { Skill } from "@/types/api";
import { Crown, Link2, Plus, X } from "lucide-react";
import type { EmployeeDepartment, Skill } from "@/types/api";
export type ProfileContactRow = {
key: string;
@ -175,9 +175,9 @@ export function ProfileInfoCard({
key={item.label}
className={[
"px-4 py-3.5",
index % 2 === 1 ? "border-t border-[var(--border-subtle)] md:border-l md:border-t-0" : "",
index >= 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()}
>
<p className="text-sm font-semibold text-[var(--foreground)]">
@ -319,3 +319,57 @@ export function ProfileDepartmentBadge({
</span>
);
}
export function ProfileDepartmentsCard({
title = "Отделы",
departments,
}: {
title?: string;
departments: EmployeeDepartment[];
}) {
if (!departments.length) {
return null;
}
return (
<section className="app-surface rounded-2xl p-5">
<SectionTitle title={title} />
<div className="app-surface-muted rounded-2xl p-4">
<div className="flex flex-wrap gap-2">
{departments.map((department) => (
<Link
key={department.id}
href={`/departments/${department.id}`}
className="app-badge inline-flex max-w-full items-center gap-2 rounded-full px-2 py-1.5 text-[var(--foreground)] transition hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]"
>
<span className="truncate px-1 text-sm font-medium">
{department.name}
</span>
{department.role_name ? (
<span className="app-surface inline-flex max-w-full items-center rounded-full px-2.5 py-1 text-xs font-medium text-[var(--muted-foreground)]">
<span className="truncate">{department.role_name}</span>
</span>
) : null}
{department.is_head ? (
<span
className="inline-flex items-center justify-center rounded-full border border-[color:color-mix(in_srgb,#f59e0b_42%,var(--border-subtle))] bg-[color:color-mix(in_srgb,#f59e0b_14%,transparent)] px-2.5 py-1 text-[#f59e0b]"
title="Руководитель отдела"
>
<Crown size={12} />
</span>
) : null}
{department.via_assignment ? (
<span
className="app-surface inline-flex items-center justify-center rounded-full px-2.5 py-1"
title="Роль в отделе без прямого членства"
>
<Link2 size={12} />
</span>
) : null}
</Link>
))}
</div>
</div>
</section>
);
}

View File

@ -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) => {

View File

@ -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;