diff --git a/docs/projects-create-flow.md b/docs/projects-create-flow.md new file mode 100644 index 00000000..576ea3e7 --- /dev/null +++ b/docs/projects-create-flow.md @@ -0,0 +1,55 @@ +# Standalone Project Creation Flow + +## Назначение + +DEV-078 добавляет самостоятельное создание `Project` в React-контуре. Такой проект является постоянным рабочим результатом пользователя и не создает автоматически `Application` или `Submission`. + +Создание проекта из отправленного решения остается отдельным явным сценарием `POST /submissions//project/`. Он не изменен: `Submission` хранит зафиксированную версию решения, а новый самостоятельный Project начинается как пустой черновик. + +Старый Angular-клиент продолжает создавать проект пустым `POST /projects/` и переходить на `/office/projects//edit`. Новый endpoint отделён именно потому, что legacy model default `is_public=true` нельзя менять без риска для действующего контракта. + +## API + +### `POST /projects/workspace/` + +Endpoint требует авторизацию и принимает только пустой JSON-объект. Сервер: + +- назначает `request.user` руководителем; +- создает `draft=true`; +- создает `is_public=false`; +- не принимает `leader`, статусы публикации или другие поля от клиента; +- не создает Application, Submission, Team или связь с программой; +- возвращает полный workspace detail contract, включая ID и права текущего пользователя. + +Legacy `POST /projects/`, используемый Angular-приложением, не изменен. + +### `PATCH /projects//workspace/` + +Руководитель или staff может обновлять основные поля проекта. В DEV-078 контракт дополнен полями: + +- `industry` — ID существующей отрасли или `null`; +- `links` — список валидных URL. + +Ссылки заменяются атомарно вместе с обновлением проекта. Дубликаты удаляются с сохранением исходного порядка. Поля владельца, Application, Submission и программы по-прежнему недоступны для изменения. + +## Draft и публикация + +React-клиент использует два явных действия: + +- сохранение черновика отправляет `draft=true`, `is_public=false`; +- публикация отправляет `draft=false`, `is_public=true`. + +Workspace serializer рассчитывает итоговое состояние из текущего Project и присланных полей. Явное `draft=false`, явное `is_public=true` и изменение уже опубликованного проекта требуют название, регион, отрасль, описание, проблему, целевую аудиторию и обложку. Поэтому частичный PATCH только с одним флагом не позволяет обойти серверную проверку, а незаполненный приватный черновик по-прежнему можно сохранять с `draft=true` и `is_public=false`. + +Каталог `GET /projects/catalog/` возвращает только опубликованные публичные проекты. `GET /projects/my/` возвращает руководителю и черновики, и приватные проекты. + +## Маршруты интерфейса + +- `/office/projects/my` — список и создание проекта; +- `/office/projects/all` — публичный каталог; +- `/office/projects//edit` — заполнение и публикация; +- `/office/projects/` — карточка после сохранения. + +## Ограничения этапа + +DEV-078 не добавляет приглашения, подписки, уведомления, публикации проекта, вакансии или расширенное управление ресурсами. Удаление пустого черновика не добавлено: legacy DELETE не содержит отдельного безопасного правила «незаполненного» workspace-черновика. Создание из Submission также не меняется. diff --git a/projects/tests/test_project_workspace_api.py b/projects/tests/test_project_workspace_api.py index d5b2fadd..fa9e3c0b 100644 --- a/projects/tests/test_project_workspace_api.py +++ b/projects/tests/test_project_workspace_api.py @@ -3,7 +3,8 @@ from django.test.utils import CaptureQueriesContext from rest_framework.test import APIClient -from partner_programs.models import Application +from partner_programs.models import Application, Submission +from projects.models import Project, ProjectLink from projects.tests.helpers import ( create_collaborator, create_industry, @@ -27,16 +28,66 @@ def setUp(self): def authenticate(self, user): self.client.force_authenticate(user=user) + def create_complete_draft(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + industry=self.industry, + ) + project.region = "Москва" + project.problem = "Проблема" + project.target_audience = "Целевая аудитория" + project.cover_image_address = "https://example.com/cover.png" + project.save() + return project + def test_workspace_endpoints_require_authentication(self): project = create_project(leader=self.leader) self.assertEqual(self.client.get("/projects/catalog/").status_code, 401) self.assertEqual(self.client.get("/projects/my/").status_code, 401) + self.assertEqual(self.client.post("/projects/workspace/").status_code, 401) self.assertEqual( self.client.get(f"/projects/{project.pk}/workspace/").status_code, 401, ) + def test_workspace_create_makes_private_draft_for_current_user(self): + self.authenticate(self.leader) + + response = self.client.post("/projects/workspace/", {}, format="json") + + self.assertEqual(response.status_code, 201) + project = Project.objects.get(pk=response.data["id"]) + self.assertEqual(project.leader, self.leader) + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + self.assertTrue(response.data["can_edit"]) + self.assertEqual(response.data["current_user_role"], "leader") + + def test_workspace_create_rejects_client_owned_fields(self): + self.authenticate(self.leader) + + response = self.client.post( + "/projects/workspace/", + {"leader": self.outsider.pk, "draft": False, "is_public": True}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(Project.objects.filter(leader=self.leader).exists()) + self.assertFalse(Project.objects.filter(leader=self.outsider).exists()) + + def test_workspace_create_does_not_create_application_or_submission(self): + self.authenticate(self.leader) + + response = self.client.post("/projects/workspace/", {}, format="json") + + self.assertEqual(response.status_code, 201) + self.assertFalse(Application.objects.exists()) + self.assertFalse(Submission.objects.exists()) + def test_catalog_returns_only_public_published_projects(self): visible = create_project( leader=self.leader, @@ -160,16 +211,193 @@ def test_leader_can_patch_only_workspace_fields(self): response = self.client.patch( f"/projects/{project.pk}/workspace/", - {"name": "Updated", "draft": False, "is_public": True}, + {"name": "Updated", "draft": True, "is_public": False}, format="json", ) self.assertEqual(response.status_code, 200) project.refresh_from_db() self.assertEqual(project.name, "Updated") + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_draft_requires_main_fields_before_publication(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + project.name = None + project.description = None + project.region = None + project.industry = None + project.cover_image_address = None + project.save() + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"draft": False, "is_public": True}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + set(response.data), + { + "name", + "region", + "industry", + "description", + "problem", + "target_audience", + "cover_image_address", + }, + ) + project.refresh_from_db() + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_complete_draft_can_be_published(self): + project = self.create_complete_draft() + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + { + "name": "Published project", + "draft": False, + "is_public": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertFalse(project.draft) + self.assertTrue(project.is_public) + + def test_publication_patch_prefers_payload_over_stored_values(self): + project = self.create_complete_draft() + project.description = None + project.save() + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + { + "description": "Описание из запроса", + "draft": False, + "is_public": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.description, "Описание из запроса") self.assertFalse(project.draft) self.assertTrue(project.is_public) + def test_publication_rejects_whitespace_only_fields(self): + project = self.create_complete_draft() + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + { + "region": " ", + "draft": False, + "is_public": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("region", response.data) + project.refresh_from_db() + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_incomplete_draft_cannot_bypass_publication_with_single_flag(self): + payloads = ({"draft": False}, {"is_public": True}) + + for payload in payloads: + with self.subTest(payload=payload): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + payload, + format="json", + ) + + self.assertEqual(response.status_code, 400) + project.refresh_from_db() + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_workspace_update_does_not_allow_put(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + self.authenticate(self.leader) + + response = self.client.put( + f"/projects/{project.pk}/workspace/", + {"draft": False, "is_public": True}, + format="json", + ) + + self.assertEqual(response.status_code, 405) + project.refresh_from_db() + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + + def test_leader_can_patch_industry_and_project_links(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + old_link = ProjectLink.objects.create( + project=project, + link="https://old.example.com", + ) + replacement_industry = create_industry(name="Replacement industry") + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + { + "industry": replacement_industry.pk, + "links": [ + "https://example.com/one", + "https://example.com/one", + "https://example.com/two", + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.industry, replacement_industry) + self.assertFalse(ProjectLink.objects.filter(pk=old_link.pk).exists()) + self.assertEqual( + response.data["links"], + ["https://example.com/one", "https://example.com/two"], + ) + + def test_legacy_post_projects_contract_remains_available(self): + self.authenticate(self.leader) + + response = self.client.post( + "/projects/", + {"name": "Legacy project"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + project = Project.objects.get(pk=response.data["id"]) + self.assertEqual(project.leader, self.leader) + self.assertTrue(project.draft) + def test_collaborator_cannot_patch_and_leader_cannot_replace_leader(self): project = create_project(leader=self.leader, draft=True, is_public=False) create_collaborator(project, user=self.collaborator_user) diff --git a/projects/urls.py b/projects/urls.py index 8ddc3d02..65fb1a35 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -27,6 +27,7 @@ from projects.workspace_views import ( MyProjectsView, ProjectCatalogView, + ProjectWorkspaceCreateView, ProjectWorkspaceDetailView, ) @@ -65,6 +66,11 @@ path("", ProjectList.as_view()), path("catalog/", ProjectCatalogView.as_view(), name="workspace-catalog"), path("my/", MyProjectsView.as_view(), name="workspace-my"), + path( + "workspace/", + ProjectWorkspaceCreateView.as_view(), + name="workspace-create", + ), path( "/workspace/", ProjectWorkspaceDetailView.as_view(), diff --git a/projects/workspace_serializers.py b/projects/workspace_serializers.py index 9883f586..8051aef0 100644 --- a/projects/workspace_serializers.py +++ b/projects/workspace_serializers.py @@ -1,6 +1,7 @@ +from django.db import transaction from rest_framework import serializers -from projects.models import Project +from projects.models import Project, ProjectLink PROJECT_WORKSPACE_EDITABLE_FIELDS = frozenset( @@ -16,11 +17,23 @@ "presentation_address", "image_address", "cover_image_address", + "industry", + "links", "draft", "is_public", } ) +PROJECT_PUBLICATION_REQUIRED_FIELDS = { + "name": "Укажите название проекта.", + "region": "Укажите регион.", + "industry": "Выберите отрасль.", + "description": "Добавьте описание проекта.", + "problem": "Опишите проблему.", + "target_audience": "Опишите целевую аудиторию.", + "cover_image_address": "Загрузите обложку проекта.", +} + class ProjectWorkspaceUserSerializer(serializers.Serializer): id = serializers.IntegerField() @@ -126,6 +139,12 @@ def get_industry(self, project): class ProjectWorkspaceUpdateSerializer(serializers.ModelSerializer): editable_fields = PROJECT_WORKSPACE_EDITABLE_FIELDS + links = serializers.ListField( + child=serializers.URLField(), + required=False, + allow_empty=True, + write_only=True, + ) class Meta: model = Project @@ -135,6 +154,28 @@ class Meta: "description": {"allow_blank": True, "allow_null": True}, } + def _get_resulting_value(self, attrs, field): + """Возвращает значение поля после применения частичного обновления.""" + if field in attrs: + return attrs[field] + return getattr(self.instance, field) + + def _requires_publication_validation(self, attrs): + """Определяет, переводит ли запрос Project в публикуемое состояние. + + Legacy Project может уже иметь сочетание draft=true и is_public=true, + поэтому обычное редактирование такого объекта не считаем публикацией. + Явное включение публичности или снятие флага черновика всегда требует + заполненного проекта, как и изменение уже опубликованного Project. + """ + resulting_draft = self._get_resulting_value(attrs, "draft") + resulting_is_public = self._get_resulting_value(attrs, "is_public") + return ( + ("draft" in attrs and resulting_draft is False) + or ("is_public" in attrs and resulting_is_public is True) + or (resulting_draft is False and resulting_is_public is True) + ) + def validate(self, attrs): unsupported = set(self.initial_data).difference(self.editable_fields) if unsupported: @@ -144,4 +185,53 @@ def validate(self, attrs): for field in sorted(unsupported) } ) + + if self._requires_publication_validation(attrs): + errors = {} + for field, message in PROJECT_PUBLICATION_REQUIRED_FIELDS.items(): + value = self._get_resulting_value(attrs, field) + if value is None or (isinstance(value, str) and not value.strip()): + errors[field] = message + if errors: + raise serializers.ValidationError(errors) return attrs + + def validate_links(self, links): + """Удаляет дубликаты ссылок, сохраняя пользовательский порядок.""" + return list(dict.fromkeys(links)) + + @transaction.atomic + def update(self, instance, validated_data): + """Атомарно обновляет Project и его отдельные строки ProjectLink.""" + links = validated_data.pop("links", None) + project = super().update(instance, validated_data) + if links is not None: + ProjectLink.objects.filter(project=project).delete() + ProjectLink.objects.bulk_create( + [ProjectLink(project=project, link=link) for link in links] + ) + return project + + +class ProjectWorkspaceCreateSerializer(serializers.Serializer): + """Создает пустой приватный черновик без клиентских полей владения.""" + + def validate(self, attrs): + if self.initial_data: + raise serializers.ValidationError( + { + field: "Поле нельзя передавать при создании черновика." + for field in sorted(self.initial_data) + } + ) + return attrs + + @transaction.atomic + def create(self, validated_data): + # Отдельный React-контур явно создает private draft; legacy POST /projects/ + # сохраняет прежний контракт и model default is_public. + return Project.objects.create( + leader=self.context["request"].user, + draft=True, + is_public=False, + ) diff --git a/projects/workspace_views.py b/projects/workspace_views.py index 807230ac..b5230c4c 100644 --- a/projects/workspace_views.py +++ b/projects/workspace_views.py @@ -13,6 +13,7 @@ get_workspace_project_queryset, ) from projects.workspace_serializers import ( + ProjectWorkspaceCreateSerializer, ProjectWorkspaceDetailSerializer, ProjectWorkspaceListSerializer, ProjectWorkspaceUpdateSerializer, @@ -62,6 +63,28 @@ def get_queryset(self): ) +class ProjectWorkspaceCreateView(APIView): + """Создает самостоятельный приватный черновик для React workspace.""" + + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ProjectWorkspaceCreateSerializer( + data=request.data, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + project = serializer.save() + created_project = get_workspace_project_queryset(user=request.user).get( + pk=project.pk + ) + response_serializer = ProjectWorkspaceDetailSerializer( + created_project, + context={"request": request}, + ) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + class ProjectWorkspaceDetailView(APIView): """Безопасная карточка Project и ограниченное редактирование лидером."""