Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-27 09:12:54 +03:00
commit 948cc704c5
7 changed files with 483 additions and 4 deletions

View File

@ -1,5 +1,7 @@
# backend/employees/admin.py
from io import StringIO
from django import forms
from django.contrib import admin, messages
from django.contrib.auth.admin import GroupAdmin as DefaultGroupAdmin
@ -9,6 +11,13 @@ from django.contrib.auth.forms import (
)
from django.contrib.auth.models import Group, Permission
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.core.exceptions import PermissionDenied
from django.core.management import call_command
from django.core.management.base import CommandError
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils import timezone
from django.utils.html import format_html
from simple_history.admin import SimpleHistoryAdmin
@ -19,6 +28,7 @@ from .models import (
DepartmentRole,
EmployeeDepartment,
EmployeeSkillProgress,
SecretSkillSettings,
Position,
Skill,
SkillExperienceEvent,
@ -26,6 +36,151 @@ from .models import (
)
class SecretSkillSettingsAdminForm(forms.ModelForm):
class Meta:
model = SecretSkillSettings
fields = ("calculation_start_date",)
def clean_calculation_start_date(self):
value = self.cleaned_data["calculation_start_date"]
if value > timezone.localdate():
raise forms.ValidationError(
"Дата начала расчёта не может быть из будущего."
)
return value
class SecretSkillRecalculateAdminForm(forms.Form):
employee = forms.ModelChoiceField(
label="Сотрудник",
queryset=Employee.objects.none(),
required=False,
help_text="Не выбирайте сотрудника, чтобы проверить или пересчитать всех.",
)
dry_run = forms.BooleanField(
label="Сухой прогон без сохранения",
required=False,
initial=True,
)
confirm = forms.BooleanField(
label="Подтверждаю реальный пересчёт и сохранение изменений",
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["employee"].queryset = Employee.objects.order_by(
"last_name",
"first_name",
"id",
)
def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get("dry_run") and not cleaned_data.get("confirm"):
raise forms.ValidationError(
"Для реального пересчёта подтвердите сохранение изменений."
)
return cleaned_data
class SecretSkillReportAdminForm(forms.Form):
top = forms.IntegerField(
label="Количество сотрудников в рейтинге",
min_value=1,
max_value=100,
initial=3,
)
@admin.register(SecretSkillSettings)
class SecretSkillSettingsAdmin(admin.ModelAdmin):
"""Единая дата начала расчёта всех секретных навыков."""
form = SecretSkillSettingsAdminForm
list_display = ("calculation_start_date", "updated_at", "tools_link")
fields = ("calculation_start_date", "updated_at", "tools_link")
readonly_fields = ("updated_at", "tools_link")
def get_urls(self):
custom_urls = [
path(
"tools/",
self.admin_site.admin_view(self.tools_view),
name="employees_secretskillsettings_tools",
)
]
return custom_urls + super().get_urls()
@admin.display(description="Операции")
def tools_link(self, obj=None):
url = reverse("admin:employees_secretskillsettings_tools")
return format_html('<a class="button" href="{}">Открыть инструменты</a>', url)
def tools_view(self, request):
if not self.has_change_permission(request):
raise PermissionDenied
operation = request.POST.get("operation")
recalculate_form = SecretSkillRecalculateAdminForm(
request.POST if operation == "recalculate" else None,
prefix="recalculate",
)
report_form = SecretSkillReportAdminForm(
request.POST if operation == "report" else None,
prefix="report",
)
output = ""
try:
if operation == "recalculate" and recalculate_form.is_valid():
stream = StringIO()
employee = recalculate_form.cleaned_data["employee"]
command_options = {
"dry_run": recalculate_form.cleaned_data["dry_run"],
"stdout": stream,
}
if employee is not None:
command_options["employee_id"] = employee.pk
call_command("recalculate_secret_skills", **command_options)
output = stream.getvalue()
messages.success(request, "Пересчёт завершён.")
elif operation == "report" and report_form.is_valid():
stream = StringIO()
call_command(
"secret_skills_report",
top=report_form.cleaned_data["top"],
stdout=stream,
)
output = stream.getvalue()
messages.success(request, "Отчёт сформирован.")
except CommandError as error:
messages.error(request, str(error))
context = {
**self.admin_site.each_context(request),
"opts": self.model._meta,
"title": "Инструменты секретных навыков",
"settings_object": SecretSkillSettings.get_solo(),
"recalculate_form": recalculate_form,
"report_form": report_form,
"output": output,
}
return TemplateResponse(
request,
"admin/employees/secretskillsettings/tools.html",
context,
)
def has_add_permission(self, request):
if SecretSkillSettings.objects.exists():
return False
return super().has_add_permission(request)
def has_delete_permission(self, request, obj=None):
return False
def _resolve_ldap_user_for_employee(employee):
"""Возвращает наиболее вероятную LDAP-модель сотрудника.

View File

@ -0,0 +1,60 @@
{% extends "admin/base_site.html" %}
{% load admin_urls %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Главная</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; Инструменты
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<p>
Общая дата начала расчёта:
<strong>{{ settings_object.calculation_start_date|date:"d.m.Y" }}</strong>.
<a href="{% url opts|admin_urlname:'change' settings_object.pk|admin_urlquote %}">Изменить дату</a>
</p>
<div class="module" style="margin-bottom: 24px; padding: 16px;">
<h2 style="margin: -16px -16px 16px; padding: 8px 16px;">Отчёт</h2>
<p>
Показывает лидеров по каждому секретному навыку, а для ещё не полученных
навыков — сотрудников, которые ближе всего к условиям получения.
</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="operation" value="report">
{{ report_form.as_div }}
<div class="submit-row" style="padding: 12px 0 0;">
<input type="submit" class="default" value="Сформировать отчёт">
</div>
</form>
</div>
<div class="module" style="margin-bottom: 24px; padding: 16px;">
<h2 style="margin: -16px -16px 16px; padding: 8px 16px;">Пересчёт</h2>
<p>
Сухой прогон только показывает будущие изменения. Реальный пересчёт
обновляет навыки и ранги в базе данных.
</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="operation" value="recalculate">
{{ recalculate_form.as_div }}
<div class="submit-row" style="padding: 12px 0 0;">
<input type="submit" class="default" value="Запустить пересчёт">
</div>
</form>
</div>
{% if output %}
<div class="module" style="padding: 16px;">
<h2 style="margin: -16px -16px 16px; padding: 8px 16px;">Результат</h2>
<pre style="margin: 0; overflow: auto; max-height: 60vh; white-space: pre-wrap;">{{ output }}</pre>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,140 @@
from datetime import timedelta
from io import StringIO
from unittest.mock import patch
import pytest
from django.urls import reverse
from django.utils import timezone
from employees.models import SecretSkillSettings
pytestmark = pytest.mark.django_db
def test_admin_can_change_secret_skills_start_date(client, user_factory):
administrator = user_factory(staff=True, superuser=True)
settings = SecretSkillSettings.get_solo()
target = timezone.localdate() - timedelta(days=90)
client.force_login(administrator)
response = client.post(
reverse(
"admin:employees_secretskillsettings_change",
args=[settings.pk],
),
{"calculation_start_date": target.isoformat()},
)
assert response.status_code == 302
settings.refresh_from_db()
assert settings.calculation_start_date == target
def test_admin_does_not_allow_duplicate_or_delete(rf):
from django.contrib import admin
from employees.admin import SecretSkillSettingsAdmin
settings = SecretSkillSettings.get_solo()
model_admin = SecretSkillSettingsAdmin(SecretSkillSettings, admin.site)
request = rf.get("/admin/")
assert model_admin.has_add_permission(request) is False
assert model_admin.has_delete_permission(request, settings) is False
def test_admin_secret_skill_tools_page(client, user_factory):
administrator = user_factory(staff=True, superuser=True)
settings = SecretSkillSettings.get_solo()
client.force_login(administrator)
response = client.get(reverse("admin:employees_secretskillsettings_tools"))
assert response.status_code == 200
content = response.content.decode()
assert "Отчёт" in content
assert "Пересчёт" in content
assert settings.calculation_start_date.strftime("%d.%m.%Y") in content
def test_admin_runs_secret_skill_report(client, user_factory):
administrator = user_factory(staff=True, superuser=True)
client.force_login(administrator)
def fake_call(command_name, **options):
assert command_name == "secret_skills_report"
assert options["top"] == 5
assert isinstance(options["stdout"], StringIO)
options["stdout"].write("Тестовый отчёт")
with patch("employees.admin.call_command", side_effect=fake_call) as command:
response = client.post(
reverse("admin:employees_secretskillsettings_tools"),
{"operation": "report", "report-top": "5"},
)
assert response.status_code == 200
assert command.call_count == 1
assert "Тестовый отчёт" in response.content.decode()
def test_admin_runs_dry_recalculation_for_one_employee(client, user_factory):
administrator = user_factory(staff=True, superuser=True)
employee = user_factory()
client.force_login(administrator)
def fake_call(command_name, **options):
assert command_name == "recalculate_secret_skills"
assert options["employee_id"] == employee.pk
assert options["dry_run"] is True
options["stdout"].write("Сухой прогон")
with patch("employees.admin.call_command", side_effect=fake_call) as command:
response = client.post(
reverse("admin:employees_secretskillsettings_tools"),
{
"operation": "recalculate",
"recalculate-employee": str(employee.pk),
"recalculate-dry_run": "on",
},
)
assert response.status_code == 200
assert command.call_count == 1
assert "Сухой прогон" in response.content.decode()
def test_admin_requires_confirmation_for_real_recalculation(client, user_factory):
administrator = user_factory(staff=True, superuser=True)
client.force_login(administrator)
with patch("employees.admin.call_command") as command:
response = client.post(
reverse("admin:employees_secretskillsettings_tools"),
{"operation": "recalculate"},
)
assert response.status_code == 200
assert command.call_count == 0
assert "подтвердите сохранение изменений" in response.content.decode()
def test_admin_runs_confirmed_recalculation_for_all(client, user_factory):
administrator = user_factory(staff=True, superuser=True)
client.force_login(administrator)
with patch("employees.admin.call_command") as command:
response = client.post(
reverse("admin:employees_secretskillsettings_tools"),
{
"operation": "recalculate",
"recalculate-confirm": "on",
},
)
assert response.status_code == 200
assert command.call_count == 1
args, options = command.call_args
assert args == ("recalculate_secret_skills",)
assert options["dry_run"] is False
assert "employee_id" not in options

View File

@ -75,6 +75,7 @@ export default function UserDetailPage() {
handleCloseActionModal,
handleCloseEditModal,
handleClosePositionModal,
handleCreatePosition,
handleCopyToClipboard,
handleDeleteAction,
handleEditAction,
@ -616,10 +617,12 @@ export default function UserDetailPage() {
/>
<ChangeUserPositionModal
key={isPositionModalOpen ? "position-modal-open" : "position-modal-closed"}
actionLoading={actionLoading}
error={positionsError}
isOpen={isPositionModalOpen}
onClose={handleClosePositionModal}
onCreatePosition={handleCreatePosition}
onPositionChange={setPositionValue}
onSave={handleSavePosition}
person={person}

View File

@ -1,11 +1,14 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { Modal } from "@/components/ui";
import type { Position, User } from "@/types/api";
type ChangeUserPositionModalProps = {
isOpen: boolean;
onClose: () => void;
onCreatePosition: (name: string, description: string) => Promise<boolean>;
onSave: () => void;
onPositionChange: (value: string) => void;
positions: Position[];
@ -19,6 +22,7 @@ type ChangeUserPositionModalProps = {
export default function ChangeUserPositionModal({
isOpen,
onClose,
onCreatePosition,
onSave,
onPositionChange,
positions,
@ -29,13 +33,30 @@ export default function ChangeUserPositionModal({
error,
}: ChangeUserPositionModalProps) {
const isSaving = actionLoading === "position";
const isCreating = actionLoading === "position-create";
const isBusy = isSaving || isCreating;
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [newPositionName, setNewPositionName] = useState("");
const [newPositionDescription, setNewPositionDescription] = useState("");
const handleCreatePosition = async () => {
const created = await onCreatePosition(
newPositionName,
newPositionDescription,
);
if (!created) return;
setNewPositionName("");
setNewPositionDescription("");
setIsCreateOpen(false);
};
const footer = (
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
disabled={isSaving}
disabled={isBusy}
className="app-action-secondary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
Отмена
@ -43,7 +64,7 @@ export default function ChangeUserPositionModal({
<button
type="button"
onClick={onSave}
disabled={isSaving || positionsLoading}
disabled={isBusy || positionsLoading}
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
{isSaving ? "Сохранение..." : "Сохранить"}
@ -57,7 +78,7 @@ export default function ChangeUserPositionModal({
onClose={onClose}
title="Изменить должность"
size="sm"
closeOnEsc={!isSaving}
closeOnEsc={!isBusy}
footer={footer}
>
<div className="space-y-4">
@ -80,7 +101,7 @@ export default function ChangeUserPositionModal({
value={positionValue}
onChange={(event) => onPositionChange(event.target.value)}
className="app-select w-full rounded-lg px-4 py-2.5 text-sm"
disabled={positionsLoading || isSaving}
disabled={positionsLoading || isBusy}
>
<option value="">Без должности</option>
{positions.map((position) => (
@ -99,6 +120,66 @@ export default function ChangeUserPositionModal({
<p className="mt-3 text-sm text-[var(--danger-foreground)]">{error}</p>
) : null}
</section>
<section className="app-surface-muted rounded-xl p-4">
<button
type="button"
onClick={() => setIsCreateOpen((current) => !current)}
disabled={isBusy}
className="flex w-full items-center justify-between gap-3 text-left text-sm font-medium text-[var(--foreground)] disabled:opacity-50"
aria-expanded={isCreateOpen}
>
<span>Добавить новую должность</span>
<Plus
size={18}
className={`app-text-muted shrink-0 transition-transform ${isCreateOpen ? "rotate-45" : ""}`}
/>
</button>
{isCreateOpen ? (
<div className="mt-4 space-y-3 border-t border-[var(--border)] pt-4">
<label className="block">
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Название
</span>
<input
type="text"
value={newPositionName}
onChange={(event) => setNewPositionName(event.target.value)}
placeholder="Например, Системный аналитик"
maxLength={255}
autoFocus
disabled={isBusy}
className="app-input w-full rounded-lg px-4 py-2.5 text-sm"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Описание
<span className="app-text-muted ml-1 font-normal">(необязательно)</span>
</span>
<textarea
value={newPositionDescription}
onChange={(event) => setNewPositionDescription(event.target.value)}
placeholder="Кратко опишите обязанности"
rows={3}
disabled={isBusy}
className="app-input w-full resize-y rounded-lg px-4 py-2.5 text-sm"
/>
</label>
<button
type="button"
onClick={() => void handleCreatePosition()}
disabled={isBusy || !newPositionName.trim()}
className="app-action-secondary w-full rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
{isCreating ? "Создание..." : "Создать и выбрать"}
</button>
</div>
) : null}
</section>
</div>
</Modal>
);

View File

@ -337,6 +337,40 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
}
}, [handleClosePositionModal, person, positionValue, refreshPerson, userId]);
const handleCreatePosition = useCallback(async (
name: string,
description: string,
): Promise<boolean> => {
const trimmedName = name.trim();
if (!trimmedName) {
setPositionsError("Укажите название должности");
return false;
}
setActionLoading("position-create");
setPositionsError(null);
try {
const createdPosition = await apiClient.createPosition({
name: trimmedName,
description: description.trim() || undefined,
}) as Position;
setPositions((current) => (
[...current.filter((position) => position.id !== createdPosition.id), createdPosition]
.sort((left, right) => left.name.localeCompare(right.name, "ru"))
));
setPositionValue(String(createdPosition.id));
return true;
} catch (createError) {
setPositionsError(
getErrorMessage(createError, "Не удалось создать должность"),
);
return false;
} finally {
setActionLoading(null);
}
}, []);
const canEdit = Boolean(currentUser && (
currentUser.auth?.is_staff ||
currentUser.auth?.is_superuser ||
@ -372,6 +406,7 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
handleCloseActionModal,
handleCloseEditModal,
handleClosePositionModal,
handleCreatePosition,
handleCopyToClipboard: copyToClipboard,
handleDeleteAction,
handleEditAction,

View File

@ -28,6 +28,11 @@ export function createEmployeesApi(request: RequestFn) {
},
getEmployee: (id: number | string) => request(`/api/v1/employees/${id}/`),
getPositions: () => request('/api/v1/positions/'),
createPosition: (data: { name: string; description?: string }) =>
request('/api/v1/positions/', {
method: 'POST',
body: JSON.stringify(data),
}),
addEmployeeSkill: (
id: number | string,
data: { skill_id?: number; name?: string },