Add user last activity and refine profile cards
This commit is contained in:
parent
c9549065fe
commit
9ec4c2349f
@ -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")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user