diff --git a/backend/api/v1/employees/serializers/employee.py b/backend/api/v1/employees/serializers/employee.py index 6b46e5cf..bba84928 100644 --- a/backend/api/v1/employees/serializers/employee.py +++ b/backend/api/v1/employees/serializers/employee.py @@ -407,6 +407,8 @@ class EmployeeSerializer(serializers.ModelSerializer): u = req.user perms = sorted(u.get_all_permissions()) + from finance.payroll.access import can_access_payroll_management + by_app = defaultdict(list) for p in perms: app, code = p.split(".", 1) @@ -418,6 +420,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "email": u.email or "", "is_staff": u.is_staff, "is_superuser": u.is_superuser, + "can_manage_payroll": can_access_payroll_management(u), "groups": list(u.groups.values_list("name", flat=True)), "permissions": perms, "permissions_by_app": perms_by_app, diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 853022f7..540c2fd1 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -32,6 +32,7 @@ from finance.enums import ( ) from finance.models import ( EmployeePayRate, + FinanceSettings, PayrollAuditEvent, PayrollCarryover, PayrollCellCommentThread, @@ -53,7 +54,11 @@ from finance.payroll.carryovers import ( create_carryover, materialize_pending_carryovers, ) -from finance.payroll.access import has_payroll_permission, has_simple_admin_access +from finance.payroll.access import ( + can_access_payroll_management, + has_payroll_permission, + has_simple_admin_access, +) from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied from finance.payroll.services import ( approve_input_line, @@ -354,6 +359,8 @@ class PayrollAdminAPIView(NoStorePayrollResponseMixin, APIView): def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) + if not can_access_payroll_management(request.user): + _permission_error("finance.payroll_management_access") if not any( has_payroll_permission(request.user, code) for code in self.allowed_permissions @@ -400,6 +407,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): """One bounded bootstrap response for the native payroll screen.""" def get(self, request): + finance_settings = FinanceSettings.get_solo() permissions_payload = { key: has_payroll_permission(request.user, permission) for key, permission in PAYROLL_PERMISSIONS.items() @@ -671,6 +679,14 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): return Response( { "permissions": permissions_payload, + "access_settings": { + "restricted": finance_settings.management_access_is_restricted(), + "management_user_count": finance_settings.management_users.count(), + "can_configure": ( + request.user.is_superuser + or request.user.has_perm("finance.change_financesettings") + ), + }, "employees": PayrollEmployeeMiniSerializer(employees, many=True).data, "rate_employees": PayrollEmployeeMiniSerializer( rate_employees, @@ -705,6 +721,77 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView): ) +class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView): + """Read and update the allow-list shown in the native finance workspace.""" + + permission_classes = [permissions.IsAuthenticated] + + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if not ( + request.user.is_superuser + or request.user.has_perm("finance.change_financesettings") + ): + _permission_error("finance.change_financesettings") + + def _payload(self): + from ..employees.serializers import EmployeeBriefSerializer + + finance_settings = FinanceSettings.get_solo() + selected_ids = list( + finance_settings.management_users.values_list("id", flat=True) + ) + candidates = Employee.objects.filter( + Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True)) + | Q(id__in=selected_ids) + ).order_by("last_name", "first_name", "id") + return { + "restricted": bool(selected_ids), + "management_user_ids": selected_ids, + "management_user_count": len(selected_ids), + "candidates": EmployeeBriefSerializer(candidates, many=True).data, + } + + def get(self, request): + return Response(self._payload()) + + def patch(self, request): + raw_ids = request.data.get("management_user_ids") + if not isinstance(raw_ids, list): + raise ValidationError( + {"management_user_ids": "Передайте список администраторов."} + ) + try: + requested_ids = {int(user_id) for user_id in raw_ids} + except (TypeError, ValueError): + raise ValidationError( + {"management_user_ids": "Идентификаторы должны быть числами."} + ) + if any(user_id <= 0 for user_id in requested_ids): + raise ValidationError( + {"management_user_ids": "Некорректный идентификатор пользователя."} + ) + + eligible_users = Employee.objects.filter( + id__in=requested_ids, + is_active=True, + ).filter(Q(is_staff=True) | Q(is_superuser=True)) + eligible_ids = set(eligible_users.values_list("id", flat=True)) + invalid_ids = requested_ids - eligible_ids + if invalid_ids: + raise ValidationError( + { + "management_user_ids": ( + "Выбирать можно только активных администраторов." + ) + } + ) + + finance_settings = FinanceSettings.get_solo() + finance_settings.management_users.set(eligible_users) + return Response(self._payload()) + + class PayrollAdminEmployeeListView(PayrollAdminAPIView): def get(self, request): employees = ( diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index 5d564f6f..57f0c992 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -23,6 +23,7 @@ from .admin_views import ( PayrollAdminPeriodDetailView, PayrollAdminPeriodListCreateView, PayrollAdminWorkspaceView, + PayrollManagementAccessSettingsView, PayrollAttendanceWorkRecordsView, PayrollInputLineApproveView, PayrollInputLineDetailView, @@ -50,6 +51,11 @@ from .admin_views import ( app_name = "finance-payroll" urlpatterns = [ + path( + "payroll/management-access/", + PayrollManagementAccessSettingsView.as_view(), + name="management-access-settings", + ), path( "payroll/admin/workspace/", PayrollAdminWorkspaceView.as_view(), diff --git a/backend/finance/admin.py b/backend/finance/admin.py index 183dbb01..0adbd676 100644 --- a/backend/finance/admin.py +++ b/backend/finance/admin.py @@ -10,6 +10,7 @@ from django.template.response import TemplateResponse from .enums import ApprovalStatus from .models import ( EmployeePayRate, + FinanceSettings, PayrollAuditEvent, PayrollComponent, PayrollDailyWorkEntry, @@ -36,6 +37,29 @@ from .payroll.services import ( ) +@admin.register(FinanceSettings) +class FinanceSettingsAdmin(admin.ModelAdmin): + """Singleton allow-list for the native finance management workspace.""" + + list_display = ("name", "management_users_count", "updated_at") + fields = ("name", "management_users", "updated_at") + readonly_fields = ("updated_at",) + autocomplete_fields = ("management_users",) + + def has_add_permission(self, request): + if FinanceSettings.objects.exists(): + return False + return super().has_add_permission(request) + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + + @admin.display(description="Доступ к управлению") + def management_users_count(self, obj): + count = obj.management_users.count() + return count or "Все администраторы" + + def payroll_reason_action_response( model_admin, request, diff --git a/backend/finance/migrations/0018_financesettings.py b/backend/finance/migrations/0018_financesettings.py new file mode 100644 index 00000000..719549aa --- /dev/null +++ b/backend/finance/migrations/0018_financesettings.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.4 on 2026-08-10 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("finance", "0017_payrollcarryover"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FinanceSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + default="Основные настройки", + max_length=100, + verbose_name="Название", + ), + ), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")), + ( + "management_users", + models.ManyToManyField( + blank=True, + help_text=( + "Если список пуст, блок доступен всем администраторам и сотрудникам " + "с финансовыми правами. Заполните список, чтобы ограничить доступ." + ), + related_name="finance_management_settings", + to=settings.AUTH_USER_MODEL, + verbose_name="Доступ к блоку «Управление»", + ), + ), + ], + options={ + "verbose_name": "Настройки финансов", + "verbose_name_plural": "Настройки финансов", + }, + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 686d5c16..6a3840f6 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -41,6 +41,49 @@ payroll_cell_column_validator = RegexValidator( DEFAULT_DAILY_TARGET_POINTS = Decimal("5") +class FinanceSettings(models.Model): + """Global access settings for the native finance management workspace.""" + + name = models.CharField( + "Название", + max_length=100, + default="Основные настройки", + ) + management_users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="finance_management_settings", + verbose_name="Доступ к блоку «Управление»", + help_text=( + "Если список пуст, блок доступен всем администраторам и сотрудникам " + "с финансовыми правами. Заполните список, чтобы ограничить доступ." + ), + ) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Настройки финансов" + verbose_name_plural = "Настройки финансов" + + def __str__(self): + return self.name + + @classmethod + def get_solo(cls): + instance = cls.objects.first() + return instance if instance else cls.objects.create() + + def management_access_is_restricted(self) -> bool: + return self.management_users.exists() + + def allows_management_user(self, user) -> bool: + if user is None or not getattr(user, "is_authenticated", False): + return False + if not self.management_access_is_restricted(): + return True + return self.management_users.filter(pk=user.pk, is_active=True).exists() + + class PayrollPeriod(models.Model): """A calculation and publication period controlled by one active run.""" diff --git a/backend/finance/payroll/access.py b/backend/finance/payroll/access.py index 8052f60f..7e204710 100644 --- a/backend/finance/payroll/access.py +++ b/backend/finance/payroll/access.py @@ -10,6 +10,14 @@ from __future__ import annotations from .config import simple_admin_access_enabled SELF_APPROVAL_OVERRIDE_PERMISSION = "finance.override_payroll_approval" +PAYROLL_MANAGEMENT_PERMISSIONS = ( + "finance.manage_payroll_inputs", + "finance.approve_payroll_inputs", + "finance.calculate_payroll", + "finance.approve_payroll", + "finance.publish_payroll", + "finance.view_all_payroll", +) def has_simple_admin_access(user) -> bool: @@ -34,6 +42,24 @@ def has_payroll_permission(user, permission: str) -> bool: return user.has_perm(permission) +def can_access_payroll_management(user) -> bool: + """Check the global allow-list and the user's underlying payroll access.""" + + if user is None or not getattr(user, "is_authenticated", False): + return False + if not getattr(user, "is_active", False): + return False + + # Imported lazily so model imports never depend on the payroll adapter. + from finance.models import FinanceSettings + + if not FinanceSettings.get_solo().allows_management_user(user): + return False + return has_simple_admin_access(user) or any( + user.has_perm(permission) for permission in PAYROLL_MANAGEMENT_PERMISSIONS + ) + + def can_self_approve_payroll(user) -> bool: """Allow admins to approve directly while retaining the old override role.""" diff --git a/backend/tests/finance/test_payroll_admin_permissions.py b/backend/tests/finance/test_payroll_admin_permissions.py index 26921e2b..0b1fa4f9 100644 --- a/backend/tests/finance/test_payroll_admin_permissions.py +++ b/backend/tests/finance/test_payroll_admin_permissions.py @@ -14,6 +14,7 @@ from finance.admin import ( ) from finance.models import ( EmployeePayRate, + FinanceSettings, PayrollAuditEvent, PayrollDailyWorkEntry, PayrollPeriod, @@ -208,6 +209,109 @@ def test_simple_mode_staff_can_open_and_edit_any_payroll_draft( assert "amount" not in model_admin.get_readonly_fields(request, rate) +def test_finance_management_allow_list_restricts_native_admin_api( + settings, + user_factory, + api_client, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + allowed_admin = user_factory(email="allowed.finance@example.test", staff=True) + denied_admin = user_factory(email="denied.finance@example.test", staff=True) + FinanceSettings.get_solo().management_users.set([allowed_admin]) + + workspace_url = reverse("api:v1:finance-payroll:admin-workspace") + + api_client.force_authenticate(user=denied_admin) + denied_response = api_client.get(workspace_url) + assert denied_response.status_code == 403 + + api_client.force_authenticate(user=allowed_admin) + allowed_response = api_client.get(workspace_url) + assert allowed_response.status_code == 200 + assert allowed_response.data["access_settings"] == { + "restricted": True, + "management_user_count": 1, + "can_configure": False, + } + + +def test_empty_finance_management_allow_list_keeps_all_admins_enabled( + settings, + user_factory, +): + from finance.payroll.access import can_access_payroll_management + + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + administrator = user_factory(email="all.finance@example.test", staff=True) + + assert FinanceSettings.get_solo().management_users.count() == 0 + assert can_access_payroll_management(administrator) is True + + +def test_superuser_can_update_finance_management_allow_list_from_api( + settings, + user_factory, + api_client, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + superuser = user_factory( + email="settings.finance@example.test", + staff=True, + superuser=True, + ) + selected_admin = user_factory( + email="selected.finance@example.test", + staff=True, + ) + regular_employee = user_factory(email="regular.finance@example.test") + settings_url = reverse("api:v1:finance-payroll:management-access-settings") + api_client.force_authenticate(user=superuser) + + response = api_client.patch( + settings_url, + {"management_user_ids": [selected_admin.id]}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["restricted"] is True + assert response.data["management_user_ids"] == [selected_admin.id] + assert FinanceSettings.get_solo().management_users.filter( + id=selected_admin.id + ).exists() + + invalid_response = api_client.patch( + settings_url, + {"management_user_ids": [regular_employee.id]}, + format="json", + ) + assert invalid_response.status_code == 400 + + unrestricted_response = api_client.patch( + settings_url, + {"management_user_ids": []}, + format="json", + ) + assert unrestricted_response.status_code == 200 + assert unrestricted_response.data["restricted"] is False + + +def test_regular_payroll_admin_cannot_change_management_allow_list( + settings, + user_factory, + api_client, +): + settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True} + administrator = user_factory(email="readonly.finance@example.test", staff=True) + api_client.force_authenticate(user=administrator) + + response = api_client.get( + reverse("api:v1:finance-payroll:management-access-settings") + ) + + assert response.status_code == 403 + + def test_standard_model_permission_does_not_expose_all_statements(user_factory): staff = user_factory(email="generic.admin@example.test", staff=True) grant(staff, "view_payrollstatement") diff --git a/frontend/src/app/finances/page.tsx b/frontend/src/app/finances/page.tsx index 6b31a7a5..cb50ece6 100644 --- a/frontend/src/app/finances/page.tsx +++ b/frontend/src/app/finances/page.tsx @@ -166,11 +166,15 @@ export default function FinancesPage() { Управление ) : null} + {activeTab === "management" && showPayrollAdmin ? ( +
+ ) : null} {activeTab === "management" && showPayrollAdmin ? (+ Ограничение действует и для вкладки, и для прямых запросов к финансовому API. +
+ +Администраторы не найдены
+ )} +Выберите хотя бы одного администратора.
+ ) : null} +