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" }}. + Изменить дату +

+ +
+

Отчёт

+

+ Показывает лидеров по каждому секретному навыку, а для ещё не полученных + навыков — сотрудников, которые ближе всего к условиям получения. +

+
+ {% csrf_token %} + + {{ report_form.as_div }} +
+ +
+
+
+ +
+

Пересчёт

+

+ Сухой прогон только показывает будущие изменения. Реальный пересчёт + обновляет навыки и ранги в базе данных. +

+
+ {% csrf_token %} + + {{ recalculate_form.as_div }} +
+ +
+
+
+ + {% 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 = (
+ + {isCreateOpen ? ( +
+ + +