diff --git a/backend/employees/admin.py b/backend/employees/admin.py
index b0bd6a17..23b9e061 100644
--- a/backend/employees/admin.py
+++ b/backend/employees/admin.py
@@ -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('Открыть инструменты', 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-модель сотрудника.
diff --git a/backend/employees/templates/admin/employees/secretskillsettings/tools.html b/backend/employees/templates/admin/employees/secretskillsettings/tools.html
new file mode 100644
index 00000000..68d019f2
--- /dev/null
+++ b/backend/employees/templates/admin/employees/secretskillsettings/tools.html
@@ -0,0 +1,60 @@
+{% extends "admin/base_site.html" %}
+{% load admin_urls %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+
+
+ Общая дата начала расчёта:
+ {{ settings_object.calculation_start_date|date:"d.m.Y" }}.
+ Изменить дату
+
+
+
+
Отчёт
+
+ Показывает лидеров по каждому секретному навыку, а для ещё не полученных
+ навыков — сотрудников, которые ближе всего к условиям получения.
+
+
+
+
+
+
Пересчёт
+
+ Сухой прогон только показывает будущие изменения. Реальный пересчёт
+ обновляет навыки и ранги в базе данных.
+
+
+
+
+ {% if output %}
+
+
Результат
+
{{ output }}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/backend/tests/employees/test_secret_skill_settings_admin.py b/backend/tests/employees/test_secret_skill_settings_admin.py
new file mode 100644
index 00000000..5d89dbd9
--- /dev/null
+++ b/backend/tests/employees/test_secret_skill_settings_admin.py
@@ -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
diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx
index 887fa7d4..4db62245 100644
--- a/frontend/src/app/users/[id]/page.tsx
+++ b/frontend/src/app/users/[id]/page.tsx
@@ -75,6 +75,7 @@ export default function UserDetailPage() {
handleCloseActionModal,
handleCloseEditModal,
handleClosePositionModal,
+ handleCreatePosition,
handleCopyToClipboard,
handleDeleteAction,
handleEditAction,
@@ -616,10 +617,12 @@ export default function UserDetailPage() {
/>
void;
+ onCreatePosition: (name: string, description: string) => Promise;
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 = (