diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index 4064b59e..f9facce6 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -8,7 +8,7 @@ from django.db import models from employees.models import Department from requests_app.enums import RequestStatus from requests_app.enums import RequestType -from requests_app.models import Request +from requests_app.models import Request, RequestSettings from requests_app.notifications.signals import ( dispatch_pending_new_request_notifications, ) @@ -453,6 +453,25 @@ class RequestWriteSerializer(serializers.ModelSerializer): {"recipient_ids": "Автор не может быть получателем"} ) + settings = RequestSettings.get_solo() + if settings.recipients_are_restricted(): + allowed_ids = set( + settings.get_allowed_recipients_queryset().values_list( + "id", + flat=True, + ) + ) + unavailable_ids = set(recipient_ids) - allowed_ids + if unavailable_ids: + raise serializers.ValidationError( + { + "recipient_ids": ( + "Одного или нескольких сотрудников нельзя выбрать " + "получателями заявления." + ) + } + ) + if type_ == RequestType.SHORT_DAY_OFF: if not (date_from and time_from and time_to): raise serializers.ValidationError( diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index 24774638..5fa86252 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -12,6 +12,7 @@ from django.shortcuts import get_object_or_404 # раскомментируйт from django.utils import timezone from requests_app.enums import DAY_OFF_REQUEST_TYPES, RequestStatus, RequestType from requests_app.models import Request as EmployeeRequest +from requests_app.models import RequestSettings from communications import comments_helpers from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -281,7 +282,7 @@ class RequestViewSet(viewsets.ModelViewSet): return [IsRecipientOfRequest()] if self.action == "cancel": return [IsRequestAuthor()] - if self.action == "statistics": + if self.action in {"statistics", "recipient_options"}: from rest_framework.permissions import IsAuthenticated return [IsAuthenticated()] @@ -297,6 +298,24 @@ class RequestViewSet(viewsets.ModelViewSet): return RequestReadSerializer return RequestWriteSerializer + @action(detail=False, methods=["get"], url_path="recipient-options") + def recipient_options(self, request): + """Вернуть активных сотрудников, доступных для поля «Кому».""" + from ..employees.serializers import EmployeeBriefSerializer + + settings = RequestSettings.get_solo() + recipients = settings.get_allowed_recipients_queryset().order_by( + "last_name", + "first_name", + "id", + ) + return Response( + { + "restricted": settings.recipients_are_restricted(), + "results": EmployeeBriefSerializer(recipients, many=True).data, + } + ) + # ---------- Queryset с учётом прав ---------- def get_queryset(self) -> QuerySet[EmployeeRequest]: diff --git a/backend/requests_app/admin.py b/backend/requests_app/admin.py index 9bdb5cf9..df9c7b73 100644 --- a/backend/requests_app/admin.py +++ b/backend/requests_app/admin.py @@ -5,7 +5,30 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from .enums import RequestStatus -from .models import Request +from .models import Request, RequestSettings + + +@admin.register(RequestSettings) +class RequestSettingsAdmin(admin.ModelAdmin): + """Единственная запись настроек получателей заявлений.""" + + list_display = ("name", "allowed_recipients_count", "updated_at") + fields = ("name", "allowed_recipients", "updated_at") + readonly_fields = ("updated_at",) + autocomplete_fields = ("allowed_recipients",) + + def has_add_permission(self, request): + if RequestSettings.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 allowed_recipients_count(self, obj): + count = obj.allowed_recipients.count() + return count or "Все сотрудники" # Админ-форма: без принуждения вводить approver (мы выставим его автоматически) diff --git a/backend/requests_app/migrations/0015_add_request_recipient_settings.py b/backend/requests_app/migrations/0015_add_request_recipient_settings.py new file mode 100644 index 00000000..ab695ba3 --- /dev/null +++ b/backend/requests_app/migrations/0015_add_request_recipient_settings.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.4 on 2026-08-04 17:26 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('requests_app', '0014_add_short_day_off'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RequestSettings', + 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='Обновлено')), + ('allowed_recipients', models.ManyToManyField(blank=True, help_text='Если список пуст, заявление можно отправить любому активному сотруднику. Заполните список, чтобы ограничить поле «Кому».', related_name='request_recipient_settings', to=settings.AUTH_USER_MODEL, verbose_name='Доступные получатели')), + ], + options={ + 'verbose_name': 'Настройки заявлений', + 'verbose_name_plural': 'Настройки заявлений', + }, + ), + ] diff --git a/backend/requests_app/models.py b/backend/requests_app/models.py index 30c8ed17..74ef3638 100644 --- a/backend/requests_app/models.py +++ b/backend/requests_app/models.py @@ -56,6 +56,45 @@ def attachment_mime_validator(value): ) +class RequestSettings(models.Model): + """Настройки адресации заявлений сотрудников.""" + + name = models.CharField("Название", max_length=100, default="Основные настройки") + allowed_recipients = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="request_recipient_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 get_allowed_recipients_queryset(self): + User = self.allowed_recipients.model + configured = self.allowed_recipients.filter(is_active=True) + if self.allowed_recipients.exists(): + return configured + return User.objects.filter(is_active=True) + + def recipients_are_restricted(self) -> bool: + return self.allowed_recipients.exists() + + class Request(models.Model): """Заявление сотрудника: тип, даты, статус, согласование и вложение.""" diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 4f48948b..0763a79e 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -12,6 +12,7 @@ from rest_framework.test import APIClient from requests_app.enums import RequestStatus, RequestType from requests_app.models import Request as EmployeeRequest +from requests_app.models import RequestSettings from tasks.models import ( Task, TaskBoard, @@ -523,6 +524,111 @@ def test_create_regular_user_and_admin_forced_to_self( ) +def test_empty_recipient_settings_allow_all_active_employees( + auth_client, regular_user: models.Model, make_user +) -> None: + """Пустая настройка не ограничивает поле «Кому».""" + recipient = make_user(email="request-open-recipient@example.com") + RequestSettings.get_solo() + + options_resp = auth_client(regular_user).get( + f"{API_BASE}recipient-options/" + ) + create_resp = auth_client(regular_user).post( + API_BASE, + data={ + "type": RequestType.OTHER, + "recipient_ids": [recipient.id], + }, + format="json", + ) + + assert options_resp.status_code == 200 + assert options_resp.json()["restricted"] is False + assert recipient.id in _result_ids(options_resp.json()) + assert create_resp.status_code == 201 + + +def test_configured_recipient_settings_restrict_primary_recipients( + auth_client, regular_user: models.Model, make_user +) -> None: + """Выбранные в админке сотрудники ограничивают только поле «Кому».""" + allowed = make_user(email="request-allowed-recipient@example.com") + blocked = make_user(email="request-blocked-recipient@example.com") + settings = RequestSettings.get_solo() + settings.allowed_recipients.add(allowed) + + options_resp = auth_client(regular_user).get( + f"{API_BASE}recipient-options/" + ) + allowed_resp = auth_client(regular_user).post( + API_BASE, + data={ + "type": RequestType.OTHER, + "recipient_ids": [allowed.id], + }, + format="json", + ) + blocked_resp = auth_client(regular_user).post( + API_BASE, + data={ + "type": RequestType.OTHER, + "recipient_ids": [blocked.id], + }, + format="json", + ) + + assert options_resp.status_code == 200 + assert options_resp.json()["restricted"] is True + assert _result_ids(options_resp.json()) == {allowed.id} + assert allowed_resp.status_code == 201 + assert blocked_resp.status_code == 400 + assert "recipient_ids" in blocked_resp.json() + + +def test_recipient_settings_do_not_restrict_cc_users( + auth_client, regular_user: models.Model, make_user +) -> None: + """В копию можно поставить любого активного сотрудника.""" + allowed = make_user(email="request-primary-recipient@example.com") + cc_user = make_user(email="request-unrestricted-cc@example.com") + settings = RequestSettings.get_solo() + settings.allowed_recipients.add(allowed) + + response = auth_client(regular_user).post( + API_BASE, + data={ + "type": RequestType.OTHER, + "recipient_ids": [allowed.id], + "cc_user_ids": [cc_user.id], + }, + format="json", + ) + + assert response.status_code == 201 + request_obj = EmployeeRequest.objects.get(id=response.json()["id"]) + assert request_obj.cc_users.filter(id=cc_user.id).exists() + + +def test_inactive_configured_recipient_does_not_disable_restriction( + auth_client, regular_user: models.Model, make_user +) -> None: + """Настройка только с неактивным сотрудником не означает «разрешить всех».""" + inactive = make_user(email="request-inactive-recipient@example.com") + inactive.is_active = False + inactive.save(update_fields=["is_active"]) + settings = RequestSettings.get_solo() + settings.allowed_recipients.add(inactive) + + response = auth_client(regular_user).get( + f"{API_BASE}recipient-options/" + ) + + assert response.status_code == 200 + assert response.json()["restricted"] is True + assert _result_ids(response.json()) == set() + + def test_maternity_request_requires_and_accepts_period( auth_client, regular_user: models.Model, make_user ) -> None: diff --git a/frontend/src/app/requests/page.tsx b/frontend/src/app/requests/page.tsx index 5f065a0c..6652f8c7 100644 --- a/frontend/src/app/requests/page.tsx +++ b/frontend/src/app/requests/page.tsx @@ -183,6 +183,7 @@ function RequestsPageContent() { currentUserId={user?.id} editingRequest={h.editingRequest} employees={h.employees} + recipientEmployees={h.recipientEmployees} form={h.form} mode={h.modalMode} onClose={h.closeModal} diff --git a/frontend/src/components/requests/RequestComposeModal.tsx b/frontend/src/components/requests/RequestComposeModal.tsx index a140699c..e2f626be 100644 --- a/frontend/src/components/requests/RequestComposeModal.tsx +++ b/frontend/src/components/requests/RequestComposeModal.tsx @@ -27,6 +27,7 @@ type RequestComposeModalProps = { currentUserId?: number | null; editingRequest: Request | null; employees: User[]; + recipientEmployees: User[]; form: RequestFormState; mode: "create" | "edit"; onClose: () => void; @@ -58,6 +59,7 @@ export function RequestComposeModal({ currentUserId, editingRequest, employees, + recipientEmployees, form, mode, onClose, @@ -74,6 +76,12 @@ export function RequestComposeModal({ .map((employee) => ({ id: employee.id, name: displayUserName(employee), user: employee })), [currentUserId, employees], ); + const selectableRecipients = useMemo( + () => recipientEmployees + .filter((employee) => !currentUserId || employee.id !== currentUserId) + .map((employee) => ({ id: employee.id, name: displayUserName(employee), user: employee })), + [currentUserId, recipientEmployees], + ); const dateMode = getRequestDateMode(form.type); const showOptionalDates = dateMode === "optional" && Boolean(form.date_from || form.date_to); @@ -347,7 +355,7 @@ export function RequestComposeModal({ label="Кому *" layout="inline" placeholder="Добавьте получателей" - items={selectableEmployees} + items={selectableRecipients} selectedIds={form.recipient_ids} onToggle={(id) => toggleSelection("recipient_ids", id)} renderSelectedItem={(item) => ( diff --git a/frontend/src/hooks/useRequestsPage.ts b/frontend/src/hooks/useRequestsPage.ts index f5d9c8e7..7e77ae4c 100644 --- a/frontend/src/hooks/useRequestsPage.ts +++ b/frontend/src/hooks/useRequestsPage.ts @@ -140,6 +140,7 @@ export function useRequestsPage(_userId: number | null | undefined) { /* data */ const [requests, setRequests] = useState([]); const [employees, setEmployees] = useState([]); + const [recipientEmployees, setRecipientEmployees] = useState([]); const [departments, setDepartments] = useState([]); const [commentsMap, setCommentsMap] = useState>({}); const [commentsLoadingMap, setCommentsLoadingMap] = useState>({}); @@ -239,14 +240,17 @@ export function useRequestsPage(_userId: number | null | undefined) { useEffect(() => { (async () => { try { - const [allEmployees, allDepartments] = await Promise.all([ + const [allEmployees, allDepartments, recipientOptions] = await Promise.all([ loadAllPages((p) => apiClient.getEmployees(p)), loadAllPages((p) => apiClient.getDepartments(p)), + apiClient.getRequestRecipientOptions(), ]); setEmployees(allEmployees); + setRecipientEmployees(recipientOptions.results || []); setDepartments(allDepartments); } catch { setEmployees([]); + setRecipientEmployees([]); setDepartments([]); } })(); @@ -553,6 +557,7 @@ export function useRequestsPage(_userId: number | null | undefined) { requests: filteredRequests, pendingDecisionRequests, employees, + recipientEmployees, departments, departmentNameMap, commentsMap, diff --git a/frontend/src/lib/api/requests.ts b/frontend/src/lib/api/requests.ts index dae2f4f7..11b66cf9 100644 --- a/frontend/src/lib/api/requests.ts +++ b/frontend/src/lib/api/requests.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { RequestEmployeeStatistics } from '@/types/api'; +import type { RequestEmployeeStatistics, User } from '@/types/api'; import type { RequestFn, GetTokenFn } from './utils'; export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) { @@ -35,6 +35,8 @@ export function createRequestsApi(request: RequestFn, getToken: GetTokenFn) { request(`/api/v1/requests/${requestId}/employee-statistics/?period=${period}`), getEmployeeRequestStatistics: (employeeId: number, period: 'all' | 'year' | 'month' = 'all'): Promise => request(`/api/v1/requests/statistics/?employee_id=${employeeId}&period=${period}`), + getRequestRecipientOptions: (): Promise<{ restricted: boolean; results: User[] }> => + request('/api/v1/requests/recipient-options/'), createRequest: (data: Record, saveAs?: 'draft' | 'submit') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs), updateRequest: (requestId: number, data: Record, saveAs?: 'draft' | 'submit') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs), deleteRequest: (requestId: number): Promise => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }),