Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/projects-create-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Standalone Project Creation Flow

## Назначение

DEV-078 добавляет самостоятельное создание `Project` в React-контуре. Такой проект является постоянным рабочим результатом пользователя и не создает автоматически `Application` или `Submission`.

Создание проекта из отправленного решения остается отдельным явным сценарием `POST /submissions/<submission_id>/project/`. Он не изменен: `Submission` хранит зафиксированную версию решения, а новый самостоятельный Project начинается как пустой черновик.

Старый Angular-клиент продолжает создавать проект пустым `POST /projects/` и переходить на `/office/projects/<id>/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/<project_id>/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/<project_id>/edit` — заполнение и публикация;
- `/office/projects/<project_id>` — карточка после сохранения.

## Ограничения этапа

DEV-078 не добавляет приглашения, подписки, уведомления, публикации проекта, вакансии или расширенное управление ресурсами. Удаление пустого черновика не добавлено: legacy DELETE не содержит отдельного безопасного правила «незаполненного» workspace-черновика. Создание из Submission также не меняется.
232 changes: 230 additions & 2 deletions projects/tests/test_project_workspace_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from projects.workspace_views import (
MyProjectsView,
ProjectCatalogView,
ProjectWorkspaceCreateView,
ProjectWorkspaceDetailView,
)

Expand Down Expand Up @@ -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(
"<int:project_id>/workspace/",
ProjectWorkspaceDetailView.as_view(),
Expand Down
Loading
Loading