feat(requests): configure allowed recipients
This commit is contained in:
parent
8fd5ae1054
commit
a226fd3b17
@ -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(
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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 (мы выставим его автоматически)
|
||||
|
||||
@ -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': 'Настройки заявлений',
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
"""Заявление сотрудника: тип, даты, статус, согласование и вложение."""
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' }),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user