diff --git a/backend/api/v1/procurement/viewsets/requests.py b/backend/api/v1/procurement/viewsets/requests.py index 8b81208d..b95fe7c1 100644 --- a/backend/api/v1/procurement/viewsets/requests.py +++ b/backend/api/v1/procurement/viewsets/requests.py @@ -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", diff --git a/backend/api/v1/projects/views.py b/backend/api/v1/projects/views.py index b5301cf7..006d2ae8 100644 --- a/backend/api/v1/projects/views.py +++ b/backend/api/v1/projects/views.py @@ -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) diff --git a/backend/projects/README.md b/backend/projects/README.md index 79576b96..2c4845e1 100644 --- a/backend/projects/README.md +++ b/backend/projects/README.md @@ -17,7 +17,12 @@ ## Доступ и история -Создание доступно администраторам и пользователям с `projects.add_project`. +Создание доступно всем активным авторизованным пользователям. +Создатель становится руководителем нового проекта. +Создать проект и привязать к видимой заявке на закупку можно также из карточки +заявки; право изменять существующие привязки этим не предоставляется. +Импорт и привязка записей из внешней системы требуют отдельного повышенного права +(сотрудник с правами администратора или разрешение add_project). Руководитель управляет своей карточкой, командой, этапами, связями и видит отчёт команды. Участник видит проект и собственные строки времени. Администратор видит все проекты. `?mine=true` ограничивает список собственными назначениями и для diff --git a/backend/projects/permissions.py b/backend/projects/permissions.py index 8c543d60..c110f095 100644 --- a/backend/projects/permissions.py +++ b/backend/projects/permissions.py @@ -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): diff --git a/backend/tests/api/v1/procurement/test_quick_request_projects.py b/backend/tests/api/v1/procurement/test_quick_request_projects.py index a9d421f3..b3a3c851 100644 --- a/backend/tests/api/v1/procurement/test_quick_request_projects.py +++ b/backend/tests/api/v1/procurement/test_quick_request_projects.py @@ -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( diff --git a/backend/tests/projects/test_projects.py b/backend/tests/projects/test_projects.py index dcd56e25..8f54bc0b 100644 --- a/backend/tests/projects/test_projects.py +++ b/backend/tests/projects/test_projects.py @@ -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, diff --git a/docs/guides/API.md b/docs/guides/API.md index 7c2684f3..08b75eaa 100644 --- a/docs/guides/API.md +++ b/docs/guides/API.md @@ -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//create-project/ с JSON {"name": "Название"} +- ответ 201 содержит ID и название созданного проекта; +- проект создаётся атомарно с привязкой и записью в историю; +- требуется активная авторизованная учётная запись и доступ к заявке; +- ограничение — 50 проектов на заявку, а изменение существующих привязок + остаётся защищено отдельным правом. diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index f30d07b1..1f4b4358 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -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(); diff --git a/frontend/src/app/projects/page.tsx b/frontend/src/app/projects/page.tsx index 599a986f..e9a3eb88 100644 --- a/frontend/src/app/projects/page.tsx +++ b/frontend/src/app/projects/page.tsx @@ -47,6 +47,7 @@ export default function ProjectsPage() { const router = useRouter(); const [projects, setProjects] = useState([]), [canCreate, setCanCreate] = useState(false), + [canImportExternal, setCanImportExternal] = useState(false), [create, setCreate] = useState(false), [date, setDate] = useState(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)]" /> - {canCreate && ( + {canImportExternal && (
)} +
+ {!createOpen ? ( + + ) : ( +
{ + event.preventDefault(); + void createProject(); + }} + > + +
+ + +
+
+ )} + {createdMessage ? ( +

+ {createdMessage} +

+ ) : null} +
{error ? (

{error} diff --git a/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx index 18810c02..c120a0d1 100644 --- a/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx +++ b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx @@ -55,6 +55,7 @@ interface ProcurementRequestDetailContentProps { ) => string; canProcessItems?: boolean; onProjectsSaved?: () => Promise; + 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} /> ) : null} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index b1d6785e..089a9985 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -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 && ( -

{footer}
+
+ {footer} +
)} {/* Bottom padding */} - {!noPadding &&
} + {!noPadding && !footer &&
}
, document.body, diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts index 723c889d..dc851e7c 100644 --- a/frontend/src/hooks/useProcurementPage.ts +++ b/frontend/src/hooks/useProcurementPage.ts @@ -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(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, diff --git a/frontend/src/lib/api/procurement.ts b/frontend/src/lib/api/procurement.ts index f1c9bb8b..f13398f9 100644 --- a/frontend/src/lib/api/procurement.ts +++ b/frontend/src/lib/api/procurement.ts @@ -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", diff --git a/frontend/src/lib/api/projects.ts b/frontend/src/lib/api/projects.ts index 2ff5e2f9..f2dda16c 100644 --- a/frontend/src/lib/api/projects.ts +++ b/frontend/src/lib/api/projects.ts @@ -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/"),