feat(requests): configure allowed recipients

This commit is contained in:
lizerk 2026-08-04 20:33:29 +03:00
parent 8fd5ae1054
commit a226fd3b17
10 changed files with 256 additions and 6 deletions

View File

@ -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(

View File

@ -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]:

View File

@ -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 (мы выставим его автоматически)

View File

@ -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': 'Настройки заявлений',
},
),
]

View File

@ -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):
"""Заявление сотрудника: тип, даты, статус, согласование и вложение."""

View File

@ -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:

View File

@ -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}

View File

@ -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) => (

View File

@ -140,6 +140,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
/* data */
const [requests, setRequests] = useState<Request[]>([]);
const [employees, setEmployees] = useState<User[]>([]);
const [recipientEmployees, setRecipientEmployees] = useState<User[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [commentsMap, setCommentsMap] = useState<Record<number, RequestComment[]>>({});
const [commentsLoadingMap, setCommentsLoadingMap] = useState<Record<number, boolean>>({});
@ -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<User>((p) => apiClient.getEmployees(p)),
loadAllPages<Department>((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,

View File

@ -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<RequestEmployeeStatistics> =>
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<string, any>, saveAs?: 'draft' | 'submit') => formDataRequest('/api/v1/requests/', 'POST', data, saveAs),
updateRequest: (requestId: number, data: Record<string, any>, saveAs?: 'draft' | 'submit') => formDataRequest(`/api/v1/requests/${requestId}/`, 'PATCH', data, saveAs),
deleteRequest: (requestId: number): Promise<void> => request(`/api/v1/requests/${requestId}/`, { method: 'DELETE' }),