Allow authenticated project creation from procurement requests
All checks were successful
Deploy production / deploy (push) Successful in 16m12s

This commit is contained in:
lizerk 2026-11-13 10:55:04 +03:00
parent f29e7413c1
commit 0bd767d56b
15 changed files with 374 additions and 26 deletions

View File

@ -1,9 +1,11 @@
"""ViewSet composition for procurement requests."""
from django.db import transaction
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, viewsets
from rest_framework import filters, permissions, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.response import Response
from procurement.activity import create_procurement_activity
@ -12,12 +14,13 @@ from procurement.models import (
ProcurementActivityObjectKind,
ProcurementRequest,
)
from projects.models import Project
from projects.models import Project, ProjectEvent, ProjectMembership
from ..permissions import (
CanApproveProcurementRequest,
CanAssignProcurementProjects,
CanManageProcurementRequest,
user_can_view_procurement_request,
)
from ..serializers import (
ProcurementProjectsSerializer,
@ -113,6 +116,70 @@ class ProcurementRequestViewSet(
list(Project.objects.order_by("name", "pk").values("id", "name"))
)
@action(detail=True, methods=["post"], url_path="create-project")
def create_project(self, request, pk=None):
"""Create a local project and attach only that project to a visible request.
This does not grant permission to change other associations or request fields.
"""
procurement_request = self.get_object()
if not request.user.is_active or not user_can_view_procurement_request(
request.user, procurement_request
):
raise PermissionDenied("Нет доступа к этой заявке.")
class NewProjectSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, trim_whitespace=True)
payload = NewProjectSerializer(data=request.data)
payload.is_valid(raise_exception=True)
with transaction.atomic():
procurement_request = ProcurementRequest.objects.select_for_update().get(
pk=procurement_request.pk
)
previous = list(
procurement_request.projects.order_by("id").values("id", "name")
)
if len(previous) >= 50:
raise ValidationError(
{"projects": ["Не более 50 проектов в одной заявке."]}
)
project = Project.objects.create(
name=payload.validated_data["name"],
manager=request.user,
created_by=request.user,
)
ProjectMembership.objects.create(
project=project,
employee=request.user,
role="Руководитель",
starts_on=timezone.localdate(),
)
ProjectEvent.objects.create(
project=project,
actor=request.user,
action="Проект создан",
)
procurement_request.projects.add(project)
procurement_request.save(update_fields=["updated_at"])
create_procurement_activity(
procurement_request,
request.user,
ProcurementActivityAction.UPDATED,
object_kind=ProcurementActivityObjectKind.REQUEST,
object_id=procurement_request.pk,
metadata={
"fields": ["projects"],
"previous_projects": previous,
"new_projects": previous
+ [{"id": project.pk, "name": project.name}],
},
)
return Response(
{"id": project.pk, "name": project.name},
status=status.HTTP_201_CREATED,
)
@action(detail=True, methods=["patch"], url_path="projects")
def projects(self, request, pk=None):
"""Change only project associations; usable after approval or completion."""
@ -199,6 +266,8 @@ class ProcurementRequestViewSet(
permission_classes = [CanApproveProcurementRequest]
elif self.action in {"projects", "project_options"}:
permission_classes = [CanAssignProcurementProjects]
elif self.action == "create_project":
permission_classes = [permissions.IsAuthenticated]
elif self.action in {
"submit",
"set_viewed",

View File

@ -31,7 +31,12 @@ from projects.models import (
ProjectMembership,
TimeAllocation,
)
from projects.permissions import can_create, can_manage, visible_projects
from projects.permissions import (
can_create,
can_import_external,
can_manage,
visible_projects,
)
from projects.services.time_tracking import claim_reminder, day_data, save_day
from tasks.access import task_board_access_q
from tasks.application import link_task_object, unlink_task_object
@ -84,7 +89,7 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet):
def perform_create(self, serializer):
if not can_create(self.request.user):
raise PermissionDenied(
"Создание проектов доступно администраторам и пользователям с правом создания проектов."
"Создание проектов доступно только авторизованным активным пользователям."
)
with transaction.atomic():
p = serializer.save(created_by=self.request.user)
@ -132,6 +137,7 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet):
return Response(
{
"can_create": can_create(request.user),
"can_import_external": can_import_external(request.user),
"employees": [{"id": u.pk, "name": str(u)} for u in employees],
"manageable_projects": list(
manageable.order_by("name").values("id", "name")[:500]
@ -252,9 +258,9 @@ class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet):
@action(detail=False, methods=["post"], url_path="from-external")
def from_external(self, request):
if not can_create(request.user):
if not can_import_external(request.user):
raise PermissionDenied(
"Создание проектов доступно администраторам и пользователям с правом создания проектов."
"Импорт и привязка внешних проектов требуют специальных прав."
)
payload = ExternalProjectImportSerializer(data=request.data)
payload.is_valid(raise_exception=True)

View File

@ -17,7 +17,12 @@
## Доступ и история
Создание доступно администраторам и пользователям с `projects.add_project`.
Создание доступно всем активным авторизованным пользователям.
Создатель становится руководителем нового проекта.
Создать проект и привязать к видимой заявке на закупку можно также из карточки
заявки; право изменять существующие привязки этим не предоставляется.
Импорт и привязка записей из внешней системы требуют отдельного повышенного права
(сотрудник с правами администратора или разрешение add_project).
Руководитель управляет своей карточкой, командой, этапами, связями и видит отчёт
команды. Участник видит проект и собственные строки времени. Администратор видит
все проекты. `?mine=true` ограничивает список собственными назначениями и для

View File

@ -4,7 +4,19 @@ from .models import Project
def can_create(user):
return user.is_staff or user.has_perm("projects.add_project")
return bool(user and user.is_authenticated and user.is_active)
def can_import_external(user):
"""Binding a company external record requires an explicit elevated permission."""
return bool(
user
and user.is_authenticated
and user.is_active
and (
user.is_staff or user.is_superuser or user.has_perm("projects.add_project")
)
)
def can_manage(user, project):

View File

@ -127,6 +127,76 @@ def test_ordinary_requester_cannot_assign_even_to_own_request(setup):
)
def test_requester_can_create_project_from_existing_request_without_edit_rights(setup):
requester, _, outsider, _, project_a, _, _, req = setup
url = reverse("api:v1:procurement:procurementrequest-create-project", args=[req.pk])
assert client().post(url, {"name": "Нельзя"}, format="json").status_code in (
401,
403,
)
assert client(outsider).post(url, {"name": "Чужой"}, format="json").status_code in (
403,
404,
)
assert client(requester).post(url, {"name": " "}, format="json").status_code == 400
assert (
client(requester).post(url, {"name": "А" * 256}, format="json").status_code
== 400
)
response = client(requester).post(url, {"name": " Свежий проект "}, format="json")
assert response.status_code == 201, response.data
project = Project.objects.get(pk=response.data["id"])
assert project.name == "Свежий проект"
assert project.manager_id == requester.pk
assert project.created_by_id == requester.pk
assert project.memberships.get().employee_id == requester.pk
assert project.events.get().action == "Проект создан"
assert list(req.projects.values_list("pk", flat=True)) == [project.pk]
req.refresh_from_db()
assert req.status == "completed"
assert req.title == "Комплектующие"
audit = ProcurementRequestActivity.objects.get(
request=req, action=ProcurementActivityAction.UPDATED
)
assert audit.actor_id == requester.pk
assert audit.metadata["previous_projects"] == []
assert audit.metadata["new_projects"] == [
{"id": project.pk, "name": "Свежий проект"}
]
# Creating a project does not let the requester replace unrelated associations.
assert (
client(requester)
.patch(endpoint(req), {"projects": [project_a.pk]}, format="json")
.status_code
== 403
)
def test_project_creation_checks_request_visibility_and_50_project_limit(setup):
requester, buyer, outsider, _, _, _, _, req = setup
url = reverse("api:v1:procurement:procurementrequest-create-project", args=[req.pk])
assert client(outsider).post(
url, {"name": "Скрытый"}, format="json"
).status_code in (403, 404)
existing = [
Project(
name=f"Проект #{number}",
manager=requester,
created_by=requester,
)
for number in range(50)
]
Project.objects.bulk_create(existing)
req.projects.set(Project.objects.filter(name__startswith="Проект #"))
count_before = Project.objects.count()
result = client(buyer).post(url, {"name": "Лишний"}, format="json")
assert result.status_code == 400
assert Project.objects.count() == count_before
assert req.projects.count() == 50
def test_dedicated_permission_allows_project_edit_but_not_other_edits(setup):
requester, _, outsider, _, project_a, _, _, req = setup
requester.user_permissions.add(

View File

@ -65,7 +65,12 @@ def test_visibility_and_management(setup_project):
c.force_authenticate(outsider)
assert c.get(f"{ROOT}{p.pk}/").status_code == 404
assert c.get(ROOT).data["count"] == 0
assert c.post(ROOT, {"name": "Нельзя", "manager": outsider.pk}).status_code == 403
created = c.post(
ROOT, {"name": "Собственный проект", "manager": outsider.pk}, format="json"
)
assert created.status_code == 201
assert created.data["can_manage"] is True
assert c.get(f"{ROOT}{p.pk}/").status_code == 404
c.force_authenticate(worker)
worker_detail = c.get(f"{ROOT}{p.pk}/")
assert worker_detail.status_code == 200
@ -153,6 +158,35 @@ def test_creation_and_dates(setup_project):
assert not p.bindings.exists()
def test_ordinary_user_can_create_local_project_and_manage_own_project(
setup_project,
):
_, worker, outsider, _, client = setup_project
client.force_authenticate(worker)
options = client.get(f"{ROOT}options/")
assert options.status_code == 200
assert options.data["can_create"] is True
result = client.post(
ROOT, {"name": "Проект сотрудника", "manager": worker.pk}, format="json"
)
assert result.status_code == 201, result.data
project = Project.objects.get(pk=result.data["id"])
assert project.created_by_id == worker.pk
assert project.manager_id == worker.pk
assert project.memberships.get().employee_id == worker.pk
assert project.events.get().action == "Проект создан"
assert result.data["can_manage"] is True
client.force_authenticate(outsider)
assert client.get(f"{ROOT}{project.pk}/").status_code == 404
client.force_authenticate(user=None)
assert client.post(
ROOT, {"name": "Без входа", "manager": worker.pk}, format="json"
).status_code in (401, 403)
def test_snapshots_hours_and_conflicts(setup_project):
_, worker, _, p, c = setup_project
c.force_authenticate(worker)
@ -552,8 +586,11 @@ def test_project_options_only_offer_manageable_projects(setup_project):
client.force_authenticate(worker)
options = client.get(f"{ROOT}options/").data
assert options["manageable_projects"] == []
assert options["can_create"] is True
assert options["can_import_external"] is False
client.force_authenticate(manager)
options = client.get(f"{ROOT}options/").data
assert options["can_import_external"] is True
assert {row["id"] for row in options["manageable_projects"]} == {
project.pk,
other.pk,

View File

@ -328,3 +328,17 @@ presents this before/after information in the request history.
The create/edit form shows a soft reminder when a project has not yet been
selected, but still allows saving a request without it.
Создание проекта в разделе «Проекты» доступно всем активным
авторизованным сотрудникам (POST /api/v1/projects/), при создании фиксируются
автор, руководитель и событие.
Из карточки видимой пользователю заявки на закупку можно создать локальный
проект и сразу привязать его к закупке:
- POST /api/v1/procurement/requests/<id>/create-project/ с JSON {"name": "Название"}
- ответ 201 содержит ID и название созданного проекта;
- проект создаётся атомарно с привязкой и записью в историю;
- требуется активная авторизованная учётная запись и доступ к заявке;
- ограничение — 50 проектов на заявку, а изменение существующих привязок
остаётся защищено отдельным правом.

View File

@ -537,6 +537,7 @@ export default function ProcurementPage() {
departments,
employees,
projects,
rememberProject,
newProjectName,
setNewProjectName,
processingDepartments,
@ -2119,6 +2120,7 @@ export default function ProcurementPage() {
currentUserId={user?.id}
request={selectedRequest}
displayUserName={displayUserName}
onProjectCreated={rememberProject}
onProjectsSaved={async () => {
await refreshOne(selectedRequest.id);
await loadPage1();
@ -2962,6 +2964,7 @@ export default function ProcurementPage() {
currentUserId={user?.id}
request={resolvedDetail}
displayUserName={displayUserName}
onProjectCreated={rememberProject}
onProjectsSaved={async () => {
await refreshOne(resolvedDetail.id);
await loadPage1();
@ -3223,6 +3226,7 @@ export default function ProcurementPage() {
currentUserId={user?.id}
request={selectedRequest}
displayUserName={displayUserName}
onProjectCreated={rememberProject}
onProjectsSaved={async () => {
await refreshOne(selectedRequest.id);
await loadPage1();

View File

@ -47,6 +47,7 @@ export default function ProjectsPage() {
const router = useRouter();
const [projects, setProjects] = useState<LocalProject[]>([]),
[canCreate, setCanCreate] = useState(false),
[canImportExternal, setCanImportExternal] = useState(false),
[create, setCreate] = useState(false),
[date, setDate] = useState<string | null>(null),
[help, setHelp] = useState(false),
@ -95,6 +96,7 @@ export default function ProjectsPage() {
.then(([o, c]) => {
if (live) {
setCanCreate(o.can_create);
setCanImportExternal(o.can_import_external);
setCalendar(c);
setManageableProjects(o.manageable_projects);
}
@ -496,7 +498,7 @@ export default function ProjectsPage() {
className="app-text-muted shrink-0 group-hover:text-[var(--accent-primary)]"
/>
</button>
{canCreate && (
{canImportExternal && (
<div className="ml-auto flex shrink-0 gap-2">
<button
className={`${primaryClass} px-3 py-2 text-xs`}
@ -759,7 +761,7 @@ export default function ProjectsPage() {
</div>
</dl>
{canCreate ? (
{canImportExternal ? (
<div className="flex flex-wrap justify-end gap-2 border-t border-[var(--border-subtle)] pt-4">
<button
type="button"

View File

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { FolderKanban, Loader2, Pencil } from "lucide-react";
import { FolderKanban, Loader2, Pencil, Plus } from "lucide-react";
import { SearchableSelectMulti } from "@/components/shared/SearchableSelect";
import { apiClient } from "@/lib/api";
@ -12,11 +12,13 @@ type ProjectOption = { id: number; name: string };
type Props = {
request: ProcurementRequest;
onSaved: () => Promise<void>;
onCreated?: (project: ProjectOption) => void;
};
export default function ProcurementProjectsQuickEditor({
request,
onSaved,
onCreated,
}: Props) {
const [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false);
@ -24,9 +26,13 @@ export default function ProcurementProjectsQuickEditor({
const [options, setOptions] = useState<ProjectOption[]>([]);
const [selected, setSelected] = useState<number[]>([]);
const [error, setError] = useState("");
const [creating, setCreating] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [projectName, setProjectName] = useState("");
const [createdMessage, setCreatedMessage] = useState("");
const openEditor = async () => {
if (loading || busy) return;
if (loading || busy || creating) return;
setEditing(true);
setSelected(request.projects || []);
setError("");
@ -48,7 +54,7 @@ export default function ProcurementProjectsQuickEditor({
};
const save = async () => {
if (busy || loading) return;
if (busy || loading || creating) return;
setBusy(true);
setError("");
try {
@ -66,6 +72,39 @@ export default function ProcurementProjectsQuickEditor({
}
};
const createProject = async () => {
const name = projectName.trim();
if (!name || creating || busy || loading) return;
setCreating(true);
setError("");
setCreatedMessage("");
try {
const project = await apiClient.createProcurementRequestProject(
request.id,
name,
);
setOptions((current) =>
[...current, project].sort((a, b) =>
a.name.localeCompare(b.name, "ru", { sensitivity: "base" }),
),
);
setSelected((current) => [...new Set([...current, project.id])]);
onCreated?.(project);
setProjectName("");
setCreateOpen(false);
setCreatedMessage("Проект создан и добавлен к заявке.");
await onSaved();
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: "Не удалось создать и добавить проект.",
);
} finally {
setCreating(false);
}
};
const currentIds = [...(request.projects || [])].sort((a, b) => a - b);
const nextIds = [...selected].sort((a, b) => a - b);
const unchanged =
@ -91,7 +130,7 @@ export default function ProcurementProjectsQuickEditor({
void openEditor();
}
}}
disabled={busy}
disabled={busy || creating}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-50"
aria-expanded={editing}
>
@ -147,7 +186,9 @@ export default function ProcurementProjectsQuickEditor({
<button
type="button"
onClick={() => void save()}
disabled={busy || unchanged || selected.length > 50}
disabled={
busy || creating || unchanged || selected.length > 50
}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
>
{busy ? <Loader2 size={14} className="animate-spin" /> : null}
@ -169,6 +210,74 @@ export default function ProcurementProjectsQuickEditor({
)}
</div>
)}
<div className="space-y-2">
{!createOpen ? (
<button
type="button"
onClick={() => {
setCreateOpen(true);
setError("");
setCreatedMessage("");
}}
disabled={busy || loading || creating}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
>
<Plus size={14} />
Создать проект
</button>
) : (
<form
className="flex flex-col gap-2 sm:flex-row sm:items-end"
onSubmit={(event) => {
event.preventDefault();
void createProject();
}}
>
<label className="min-w-0 flex-1 space-y-1 text-xs">
<span className="app-text-muted">Название нового проекта</span>
<input
autoFocus
required
maxLength={255}
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
disabled={creating}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
placeholder="Например, Ремонт цеха"
/>
</label>
<div className="flex shrink-0 gap-2">
<button
type="submit"
disabled={creating || !projectName.trim()}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-50"
>
{creating ? (
<Loader2 size={14} className="animate-spin" />
) : null}
{creating ? "Создаём…" : "Создать и добавить"}
</button>
<button
type="button"
disabled={creating}
onClick={() => {
setCreateOpen(false);
setProjectName("");
setError("");
}}
className="app-action-secondary rounded-lg px-3 py-2 text-xs"
>
Отмена
</button>
</div>
</form>
)}
{createdMessage ? (
<p className="text-xs text-emerald-600" role="status">
{createdMessage}
</p>
) : null}
</div>
{error ? (
<p className="text-xs text-red-500" role="alert">
{error}

View File

@ -55,6 +55,7 @@ interface ProcurementRequestDetailContentProps {
) => string;
canProcessItems?: boolean;
onProjectsSaved?: () => Promise<void>;
onProjectCreated?: (project: { id: number; name: string }) => void;
busyKey?: string | null;
canDeleteAnyComment?: boolean;
onUpdateItem?: (
@ -1864,6 +1865,7 @@ export function ProcurementRequestDetailContent({
displayUserName,
canProcessItems = false,
onProjectsSaved,
onProjectCreated,
busyKey,
canDeleteAnyComment = false,
onUpdateItem,
@ -1999,6 +2001,7 @@ export function ProcurementRequestDetailContent({
key={request.id}
request={request}
onSaved={onProjectsSaved}
onCreated={onProjectCreated}
/>
</div>
) : null}

View File

@ -143,7 +143,7 @@ export function Modal({
full: "max-w-[98vw] sm:max-w-[95vw]",
};
const modalSizeClass = `w-full ${sizeClasses[size]} max-h-[95vh] sm:max-h-[90vh]`;
const modalSizeClass = `w-full ${sizeClasses[size]} max-h-[calc(100dvh-1rem)] sm:max-h-[90dvh]`;
const showHeader = !noHeader && (title || showCloseButton);
@ -201,11 +201,13 @@ export function Modal({
{/* Footer */}
{footer && (
<div className="shrink-0 px-4 pt-3 sm:px-6 sm:pt-4">{footer}</div>
<div className="shrink-0 px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:pt-4 sm:pb-6">
{footer}
</div>
)}
{/* Bottom padding */}
{!noPadding && <div className="pb-4 sm:pb-6" />}
{!noPadding && !footer && <div className="pb-4 sm:pb-6" />}
</div>
</div>,
document.body,

View File

@ -196,6 +196,15 @@ export function useProcurementPage(user: User | null) {
const [canCreateProjects, setCanCreateProjects] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [creatingProject, setCreatingProject] = useState(false);
const rememberProject = useCallback((project: ProjectOption) => {
setProjects((current) =>
current.some((option) => option.id === project.id)
? current
: [...current, project].sort((left, right) =>
left.name.localeCompare(right.name, "ru", { sensitivity: "base" }),
),
);
}, []);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -1552,12 +1561,7 @@ export function useProcurementPage(user: User | null) {
starts_on: null,
ends_on: null,
});
setProjects((current) =>
[...current, { id: created.id, name: created.name }].sort(
(left, right) =>
left.name.localeCompare(right.name, "ru", { sensitivity: "base" }),
),
);
rememberProject({ id: created.id, name: created.name });
setForm((current) => ({
...current,
projects: [...new Set([...current.projects, created.id])],
@ -1570,7 +1574,7 @@ export function useProcurementPage(user: User | null) {
} finally {
setCreatingProject(false);
}
}, [canCreateProjects, newProjectName, user?.id]);
}, [canCreateProjects, newProjectName, rememberProject, user?.id]);
const handleUpdateItem = useCallback(
async (
@ -1888,6 +1892,7 @@ export function useProcurementPage(user: User | null) {
processingDepartments,
subscriptionProcessingDepartments,
projects,
rememberProject,
canCreateProjects,
newProjectName,
setNewProjectName,

View File

@ -43,6 +43,15 @@ export function createProcurementApi(
id: number,
): Promise<{ id: number; name: string }[]> =>
request(`/api/v1/procurement/requests/${id}/project-options/`),
createProcurementRequestProject: (
id: number,
name: string,
): Promise<{ id: number; name: string }> =>
request("/api/v1/procurement/requests/" + id + "/create-project/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
setProcurementRequestProjects: (id: number, projects: number[]) =>
request(`/api/v1/procurement/requests/${id}/projects/`, {
method: "PATCH",

View File

@ -37,6 +37,7 @@ export function createProjectsApi(request: RequestFn) {
request(`${root}${id}/`),
getProjectOptions: (): Promise<{
can_create: boolean;
can_import_external: boolean;
employees: { id: number; name: string }[];
manageable_projects: ProjectOption[];
}> => request(root + "options/"),