From 85da69cb306e0fb58ea360f7925d46d4e34a7e6f Mon Sep 17 00:00:00 2001 From: Fedor Date: Sun, 2 Aug 2026 17:07:06 +0300 Subject: [PATCH 1/2] Add project lifecycle API --- docs/projects-lifecycle-api.md | 158 ++++++++++ partner_programs/serializers/applications.py | 12 + .../services/submission_project.py | 117 ++++++++ partner_programs/submission_project_views.py | 55 ++++ partner_programs/submission_urls.py | 6 + .../tests/test_submission_project_api.py | 265 ++++++++++++++++ projects/tests/test_project_workspace_api.py | 284 ++++++++++++++++++ projects/urls.py | 12 + projects/views.py | 2 +- projects/workspace_selectors.py | 60 ++++ projects/workspace_serializers.py | 147 +++++++++ projects/workspace_views.py | 112 +++++++ 12 files changed, 1229 insertions(+), 1 deletion(-) create mode 100644 docs/projects-lifecycle-api.md create mode 100644 partner_programs/services/submission_project.py create mode 100644 partner_programs/submission_project_views.py create mode 100644 partner_programs/tests/test_submission_project_api.py create mode 100644 projects/tests/test_project_workspace_api.py create mode 100644 projects/workspace_selectors.py create mode 100644 projects/workspace_serializers.py create mode 100644 projects/workspace_views.py diff --git a/docs/projects-lifecycle-api.md b/docs/projects-lifecycle-api.md new file mode 100644 index 00000000..318f0d76 --- /dev/null +++ b/docs/projects-lifecycle-api.md @@ -0,0 +1,158 @@ +# Projects Lifecycle API + +## Назначение + +DEV-077 добавляет отдельный API рабочего пространства проектов для React-контура. `Submission` и `Project` остаются разными сущностями: + +- `Submission` — неизменяемая после отправки версия решения в одной `Application` и одной активности; именно ее оценивает эксперт; +- `Project` — постоянный рабочий результат пользователя или команды, который можно развивать и повторно использовать; +- `Application.project` связывает выбранный Project с участием в конкретной `PartnerProgram`. + +Новый lifecycle не переносит Evaluation на `ProjectScore` и не использует `ProjectExpertAssignment`. Источником связанных активностей служит `Project ← Application → PartnerProgram`, а не legacy `PartnerProgramProject`. + +## Аудит старого Angular-раздела + +В `frontend-angular` изучены domain- и API-слои проекта, каталог и маршруты в `projects/social_platform/src/app`. Старый интерфейс поддерживает: + +- каталог и «Мои проекты»; +- карточку, создание, полное редактирование и удаление Project; +- команду, цели, партнеров, ресурсы и вакансии; +- подписки, приглашения, новости, рабочую область и чат; +- привязку проекта к программе и legacy-оценку проекта. + +Angular использует legacy endpoints `GET/POST /projects/`, `GET/PUT/PATCH/DELETE /projects//`, `GET /projects/count/`, `GET /auth/users/projects/`, а также вложенные endpoints коллабораторов, целей, ресурсов, компаний, вакансий, подписок и приглашений. Эти контракты не удаляются и не переименовываются. + +В DEV-077 перенесены каталог, список пользователя, базовая карточка, редактирование лидером, связанные активности, выбор существующего Project в Application и создание Project из Submission. Расширенные Angular-сценарии перечислены в разделе DEV-066 и не реализуются частично. + +## API + +Все новые endpoints требуют аутентификацию. + +### `GET /projects/catalog/` + +Возвращает только `draft=false` и `is_public=true`. Поддерживает limit/offset pagination, `search` по названию и `industry` по идентификатору. Порядок стабилен: сначала последние обновленные, затем больший id. + +### `GET /projects/my/` + +Возвращает Project, где текущий пользователь является `leader` либо имеет `Collaborator`. Включает приватные проекты и черновики. + +Минимальный list contract: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": "Название", + "short_description": "Краткое описание", + "image_address": null, + "cover_image_address": null, + "draft": true, + "is_public": false, + "current_user_role": "leader", + "can_edit": true, + "can_use_in_application": true, + "activities": [ + { + "id": 10, + "name": "Активность", + "application_id": 25, + "application_status": "submitted" + } + ], + "datetime_updated": "2026-08-01T10:00:00Z" + } + ] +} +``` + +`can_use_in_application=true` только у руководителя. Административный доступ staff не превращает чужой Project в собственный вариант для Application. + +### `GET /projects//workspace/` + +Дополняет list contract описанием, отраслью, регионом, TRL, сроком реализации, руководителем, коллабораторами и ссылками. Публичный опубликованный Project доступен любому авторизованному пользователю. Private/draft видят руководитель, Collaborator и staff. Для постороннего private/draft скрывается через 404. + +Ответ не содержит email, телефон, Application.form_data, Submission или закрытые профильные данные. + +### `PATCH /projects//workspace/` + +Руководитель и staff могут изменять только: + +- `name`, `description`, `region`; +- `actuality`, `problem`, `target_audience`; +- `implementation_deadline`, `trl`; +- `presentation_address`, `image_address`, `cover_image_address`; +- `draft`, `is_public`. + +Нельзя менять leader, collaborators, Application, Program, Submission, Evaluation и подписчиков. Неизвестное или запрещенное поле возвращает 400. + +### `POST /submissions//project/` + +Создает Project только из `submitted` или `final` Submission. Разрешен владельцу Application (капитану по текущему invariant), staff и superuser. Обычный accepted TeamMember и посторонний получают безопасный 404. + +При первом вызове в одной `transaction.atomic`: + +1. блокируются Submission и Application; +2. повторно проверяются права и статус; +3. создается private draft Project с leader=`Application.user`; +4. title/description и валидные уникальные HTTP(S)-ссылки переносятся из Submission; +5. accepted TeamMember, кроме капитана, добавляются в `Collaborator`; +6. Project сохраняется в `Application.project`. + +Ответ при создании — HTTP 201: + +```json +{ + "created": true, + "project": { "id": 1 } +} +``` + +Повторный запрос и запрос по другой версии Submission той же Application возвращают существующий Project с HTTP 200 и `created=false`. Если `Application.project` был выбран заранее, endpoint ничего в нем не переписывает: не меняет название, описание, ссылки или команду. + +## Application contract и переиспользование + +Существующее поле `project` сохраняет тип `number | null`. Ответ обратно совместимо дополнен: + +```json +{ + "project": 12, + "project_summary": { + "id": 12, + "name": "Проект", + "draft": true, + "is_public": false + } +} +``` + +Один Project можно выбрать в нескольких draft Application разных программ. Сервер проверяет, что пользователь является leader (staff имеет административное исключение). После submit serializer запрещает изменение Application, поэтому Project нельзя подменить. Новая Application не копирует Project, а новые Submission не изменяют его автоматически. + +## Матрица прав + +| Операция | Leader / владелец Application | Collaborator | Accepted TeamMember | Посторонний | Staff / superuser | +| --- | --- | --- | --- | --- | --- | +| Каталог public | Да | Да | Да | Да | Да | +| Свой private/draft Project | Да | Read-only | Только если также Collaborator | 404 | Да | +| Редактирование Project | Да | Нет | Нет | 404/нет | Да | +| Выбор Project в Application | Да | Нет | Нет | Нет | Административно | +| Создание Project из Submission | Да | Нет | Нет | 404 | Да | + +## Производительность и совместимость + +List/detail selectors используют `select_related` и `Prefetch` для ролей, Application/Program, команды Project и ссылок. Тест списка задает query budget, который не растет с количеством Project. + +Legacy модели `Project`, `Collaborator`, `PartnerProgramProject`, `ProjectScore`, serializers и `/projects/` сохранены. Подтвержденная ошибка legacy PATCH, который вызывал полный PUT, исправлена на partial update и покрыта regression-тестом. Остальной legacy contract не расширяется новым workspace-ответом. + +Изменений моделей и миграций в DEV-077 нет. + +## Что остается DEV-066 + +Следующим этапом остаются полноценные вакансии, чат, рабочая область, новости, подписки, legacy-приглашения, расширенное управление командой Project, компаниями, ресурсами и целями, передача лидерства и удаление. Также не входят legacy `ProjectScore`, Evaluation lifecycle и автоматическое обновление Project из новых Submission. + +## Проверка + +Backend PostgreSQL CI должен выполнить новые suites `projects.tests.test_project_workspace_api` и `partner_programs.tests.test_submission_project_api`, затем regression `projects` и `partner_programs`. Локально нельзя заменять PostgreSQL на SQLite: транзакционные блокировки и PostgreSQL constraints должны проверяться в целевой СУБД. diff --git a/partner_programs/serializers/applications.py b/partner_programs/serializers/applications.py index 34400456..552f1682 100644 --- a/partner_programs/serializers/applications.py +++ b/partner_programs/serializers/applications.py @@ -5,8 +5,18 @@ from projects.models import Project +class ApplicationProjectSummarySerializer(serializers.ModelSerializer): + class Meta: + model = Project + fields = ("id", "name", "draft", "is_public") + + class ApplicationSerializer(serializers.ModelSerializer): team = serializers.SerializerMethodField() + project_summary = ApplicationProjectSummarySerializer( + source="project", + read_only=True, + ) team_name = serializers.CharField( required=False, allow_blank=True, @@ -34,6 +44,7 @@ class ApplicationSerializer(serializers.ModelSerializer): "created_by", "status", "team", + "project_summary", "submitted_at", "approved_at", "rejected_at", @@ -56,6 +67,7 @@ class Meta: "team_name", "form_data", "project", + "project_summary", "project_id", "submitted_at", "approved_at", diff --git a/partner_programs/services/submission_project.py b/partner_programs/services/submission_project.py new file mode 100644 index 00000000..4fd2b4c5 --- /dev/null +++ b/partner_programs/services/submission_project.py @@ -0,0 +1,117 @@ +from dataclasses import dataclass + +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator +from django.db import transaction + +from partner_programs.models import Application, Submission, TeamMember +from projects.models import Collaborator, Project, ProjectLink + + +class SubmissionProjectError(Exception): + """Доменная ошибка создания постоянного Project из Submission.""" + + +class SubmissionProjectAccessError(SubmissionProjectError): + """Пользователь не может создавать Project из указанного Submission.""" + + +class SubmissionProjectStatusError(SubmissionProjectError): + """Текущий статус Submission не допускает создание Project.""" + + +@dataclass(frozen=True) +class SubmissionProjectResult: + project: Project + created: bool + + +def _valid_submission_links(links): + """Возвращает уникальные HTTP(S)-ссылки без падения на старых JSON-данных.""" + if not isinstance(links, list): + return [] + + validator = URLValidator(schemes=("http", "https")) + max_length = ProjectLink._meta.get_field("link").max_length + result = [] + for raw_link in links: + if not isinstance(raw_link, str): + continue + link = raw_link.strip() + if not link or len(link) > max_length or link in result: + continue + try: + validator(link) + except ValidationError: + continue + result.append(link) + return result + + +@transaction.atomic +def create_project_from_submission(*, submission_id, actor): + """Идемпотентно создает Project из зафиксированного Submission. + + Submission остается историческим снимком решения. Повторные версии одной + Application используют одну связь Application.project и не клонируют Project. + """ + submission = ( + Submission.objects.select_for_update() + .select_related("application") + .get(pk=submission_id) + ) + application = ( + Application.objects.select_for_update() + .select_related("user", "project") + .get(pk=submission.application_id) + ) + + if not (actor.is_staff or actor.is_superuser or application.user_id == actor.pk): + raise SubmissionProjectAccessError() + + if submission.status not in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + ): + raise SubmissionProjectStatusError( + "Создать проект можно только из отправленного или финального решения." + ) + + if application.project_id: + return SubmissionProjectResult(project=application.project, created=False) + + project = Project.objects.create( + leader=application.user, + name=submission.title, + description=submission.description, + draft=True, + is_public=False, + ) + ProjectLink.objects.bulk_create( + [ + ProjectLink(project=project, link=link) + for link in _valid_submission_links(submission.links) + ], + ignore_conflicts=True, + ) + + application.project = project + application.save(update_fields=["project", "updated_at"]) + + if application.participation_mode == Application.PARTICIPATION_MODE_TEAM: + accepted_members = ( + TeamMember.objects.select_for_update() + .filter( + team__application=application, + status=TeamMember.STATUS_ACCEPTED, + ) + .exclude(user_id=application.user_id) + ) + for member in accepted_members.select_related("user"): + Collaborator.objects.get_or_create( + project=project, + user=member.user, + defaults={"role": "Участник команды"}, + ) + + return SubmissionProjectResult(project=project, created=True) diff --git a/partner_programs/submission_project_views.py b/partner_programs/submission_project_views.py new file mode 100644 index 00000000..bd557c68 --- /dev/null +++ b/partner_programs/submission_project_views.py @@ -0,0 +1,55 @@ +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.models import Submission +from partner_programs.services.submission_project import ( + SubmissionProjectAccessError, + SubmissionProjectStatusError, + create_project_from_submission, +) +from projects.workspace_selectors import get_workspace_project_queryset +from projects.workspace_serializers import ProjectWorkspaceDetailSerializer + + +class SubmissionProjectCreateView(APIView): + """Создает постоянный Project из отправленной версии решения.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, submission_id): + visible_submissions = Submission.objects.select_related("application") + if not (request.user.is_staff or request.user.is_superuser): + # Accepted TeamMember может читать Submission, но постоянный Project + # создает только владелец Application (капитан по текущему invariant). + visible_submissions = visible_submissions.filter( + Q(application__user=request.user) + ) + submission = get_object_or_404(visible_submissions, pk=submission_id) + + try: + result = create_project_from_submission( + submission_id=submission.pk, + actor=request.user, + ) + except SubmissionProjectAccessError as exc: + raise NotFound("Решение не найдено.") from exc + except SubmissionProjectStatusError as exc: + raise ValidationError({"status": str(exc)}) from exc + + project = get_object_or_404( + get_workspace_project_queryset(user=request.user), + pk=result.project.pk, + ) + serializer = ProjectWorkspaceDetailSerializer( + project, + context={"request": request}, + ) + return Response( + {"created": result.created, "project": serializer.data}, + status=(status.HTTP_201_CREATED if result.created else status.HTTP_200_OK), + ) diff --git a/partner_programs/submission_urls.py b/partner_programs/submission_urls.py index d708ba8d..407b76e3 100644 --- a/partner_programs/submission_urls.py +++ b/partner_programs/submission_urls.py @@ -6,10 +6,16 @@ SubmissionDetailView, SubmissionSubmitView, ) +from partner_programs.submission_project_views import SubmissionProjectCreateView app_name = "submissions" urlpatterns = [ + path( + "/project/", + SubmissionProjectCreateView.as_view(), + name="project-create", + ), path( "/evaluations/my/", MyEvaluationView.as_view(), diff --git a/partner_programs/tests/test_submission_project_api.py b/partner_programs/tests/test_submission_project_api.py new file mode 100644 index 00000000..c2740403 --- /dev/null +++ b/partner_programs/tests/test_submission_project_api.py @@ -0,0 +1,265 @@ +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import Application, Submission, Team, TeamMember +from partner_programs.tests.helpers import ( + create_partner_program, + create_project, + create_user, +) +from projects.models import Collaborator, Project, ProjectLink + + +class SubmissionProjectAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.owner = create_user(prefix="submission-project-owner") + self.other = create_user(prefix="submission-project-other") + self.member = create_user(prefix="submission-project-member") + self.inactive_member = create_user(prefix="submission-project-inactive") + self.staff = create_user(prefix="submission-project-staff", is_staff=True) + self.superuser = create_user( + prefix="submission-project-superuser", + is_staff=True, + is_superuser=True, + ) + self.program = create_partner_program() + self.application = self.create_application() + + def create_application(self, **overrides): + values = { + "program": self.program, + "user": self.owner, + "created_by": self.owner, + "status": Application.STATUS_SUBMITTED, + "submitted_at": timezone.now(), + } + values.update(overrides) + return Application.objects.create(**values) + + def create_submission(self, **overrides): + values = { + "application": self.application, + "program": self.application.program, + "submitted_by": self.application.user, + "title": "Reusable solution", + "description": "Submission description", + "links": [ + "https://example.com/demo", + "invalid-link", + " https://example.com/demo ", + 123, + ], + "status": Submission.STATUS_SUBMITTED, + } + values.update(overrides) + return Submission.objects.create(**values) + + def post(self, submission, user=None): + self.client.force_authenticate(user=user or self.owner) + return self.client.post( + f"/submissions/{submission.pk}/project/", {}, format="json" + ) + + def test_endpoint_requires_authentication(self): + submission = self.create_submission() + self.client.force_authenticate(user=None) + + response = self.client.post(f"/submissions/{submission.pk}/project/") + + self.assertEqual(response.status_code, 401) + + def test_owner_creates_private_draft_project_from_submitted_submission(self): + submission = self.create_submission() + + response = self.post(submission) + + self.assertEqual(response.status_code, 201) + self.assertTrue(response.data["created"]) + project = Project.objects.get(pk=response.data["project"]["id"]) + self.application.refresh_from_db() + self.assertEqual(self.application.project, project) + self.assertEqual(project.leader, self.owner) + self.assertEqual(project.name, submission.title) + self.assertEqual(project.description, submission.description) + self.assertTrue(project.draft) + self.assertFalse(project.is_public) + self.assertEqual( + list( + ProjectLink.objects.filter(project=project).values_list("link", flat=True) + ), + ["https://example.com/demo"], + ) + + def test_final_submission_can_create_project(self): + submission = self.create_submission(status=Submission.STATUS_FINAL) + + response = self.post(submission) + + self.assertEqual(response.status_code, 201) + + def test_ineligible_submission_statuses_are_rejected(self): + for index, submission_status in enumerate( + ( + Submission.STATUS_DRAFT, + Submission.STATUS_RETURNED, + Submission.STATUS_CANCELLED, + ), + start=1, + ): + with self.subTest(status=submission_status): + submission = self.create_submission( + status=submission_status, + version=index, + ) + response = self.post(submission) + self.assertEqual(response.status_code, 400) + self.application.refresh_from_db() + self.assertIsNone(self.application.project) + + def test_outsider_and_accepted_member_receive_safe_not_found(self): + self.application.participation_mode = Application.PARTICIPATION_MODE_TEAM + self.application.save(update_fields=["participation_mode", "updated_at"]) + submission = self.create_submission() + team = Team.objects.create( + application=self.application, + captain=self.owner, + name="Team", + ) + TeamMember.objects.create( + team=team, + user=self.owner, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + TeamMember.objects.create( + team=team, + user=self.member, + status=TeamMember.STATUS_ACCEPTED, + ) + + outsider_response = self.post(submission, self.other) + member_response = self.post(submission, self.member) + + self.assertEqual(outsider_response.status_code, 404) + self.assertEqual(member_response.status_code, 404) + + def test_staff_can_create_project(self): + submission = self.create_submission() + + response = self.post(submission, self.staff) + superuser_response = self.post(submission, self.superuser) + + self.assertEqual(response.status_code, 201) + self.assertEqual(superuser_response.status_code, 200) + self.assertEqual(response.data["project"]["leader"]["id"], self.owner.pk) + + def test_team_project_adds_only_accepted_non_captain_members(self): + self.application.participation_mode = Application.PARTICIPATION_MODE_TEAM + self.application.save(update_fields=["participation_mode", "updated_at"]) + team = Team.objects.create( + application=self.application, + captain=self.owner, + name="Accepted team", + ) + TeamMember.objects.create( + team=team, + user=self.owner, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + TeamMember.objects.create( + team=team, + user=self.member, + status=TeamMember.STATUS_ACCEPTED, + ) + TeamMember.objects.create( + team=team, + user=self.inactive_member, + status=TeamMember.STATUS_INVITED, + ) + submission = self.create_submission() + + response = self.post(submission) + + self.assertEqual(response.status_code, 201) + project_id = response.data["project"]["id"] + collaborators = set( + Collaborator.objects.filter(project_id=project_id).values_list( + "user_id", flat=True + ) + ) + self.assertEqual(collaborators, {self.owner.pk, self.member.pk}) + + def test_individual_project_does_not_add_other_collaborators(self): + submission = self.create_submission() + + response = self.post(submission) + + project_id = response.data["project"]["id"] + self.assertEqual( + list( + Collaborator.objects.filter(project_id=project_id).values_list( + "user_id", flat=True + ) + ), + [self.owner.pk], + ) + + def test_repeated_request_and_other_version_are_idempotent(self): + first = self.create_submission(version=1) + second = self.create_submission(version=2, title="Newer title") + + created_response = self.post(first) + repeated_response = self.post(first) + other_version_response = self.post(second) + + self.assertEqual(created_response.status_code, 201) + self.assertEqual(repeated_response.status_code, 200) + self.assertEqual(other_version_response.status_code, 200) + self.assertFalse(repeated_response.data["created"]) + self.assertFalse(other_version_response.data["created"]) + project_ids = { + created_response.data["project"]["id"], + repeated_response.data["project"]["id"], + other_version_response.data["project"]["id"], + } + self.assertEqual(len(project_ids), 1) + project = Project.objects.get(pk=project_ids.pop()) + self.assertEqual(project.name, first.title) + + def test_existing_application_project_is_returned_without_changes(self): + existing_project = create_project( + leader=self.owner, + name="Existing project", + description="Existing description", + draft=False, + is_public=True, + ) + self.application.project = existing_project + self.application.save(update_fields=["project", "updated_at"]) + submission = self.create_submission(title="Must not overwrite") + + response = self.post(submission) + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["created"]) + existing_project.refresh_from_db() + self.assertIn("Existing project", existing_project.name) + self.assertEqual(existing_project.description, "Existing description") + + def test_one_project_can_be_reused_by_applications_in_different_programs(self): + project = create_project(leader=self.owner, draft=True, is_public=False) + self.application.project = project + self.application.save(update_fields=["project", "updated_at"]) + second_program = create_partner_program() + second_application = Application.objects.create( + program=second_program, + user=self.owner, + created_by=self.owner, + project=project, + ) + + self.assertEqual(self.application.project, second_application.project) + self.assertEqual(project.applications.count(), 2) diff --git a/projects/tests/test_project_workspace_api.py b/projects/tests/test_project_workspace_api.py new file mode 100644 index 00000000..d5b2fadd --- /dev/null +++ b/projects/tests/test_project_workspace_api.py @@ -0,0 +1,284 @@ +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from partner_programs.models import Application +from projects.tests.helpers import ( + create_collaborator, + create_industry, + create_partner_program, + create_project, + create_user, +) + + +class ProjectWorkspaceAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="workspace-leader") + self.collaborator_user = create_user(prefix="workspace-collaborator") + self.outsider = create_user(prefix="workspace-outsider") + self.staff = create_user(prefix="workspace-staff") + self.staff.is_staff = True + self.staff.save(update_fields=["is_staff"]) + self.industry = create_industry(name="Workspace industry") + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + 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.get(f"/projects/{project.pk}/workspace/").status_code, + 401, + ) + + def test_catalog_returns_only_public_published_projects(self): + visible = create_project( + leader=self.leader, + draft=False, + is_public=True, + industry=self.industry, + ) + create_project(leader=self.leader, draft=True, is_public=True) + create_project(leader=self.leader, draft=False, is_public=False) + self.authenticate(self.outsider) + + response = self.client.get("/projects/catalog/") + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data["results"]], [visible.pk]) + + def test_catalog_supports_search_and_industry_filter(self): + match = create_project( + leader=self.leader, + name="Solar laboratory", + draft=False, + is_public=True, + industry=self.industry, + ) + create_project( + leader=self.leader, + name="Other project", + draft=False, + is_public=True, + ) + self.authenticate(self.outsider) + + response = self.client.get( + "/projects/catalog/", + {"search": "Solar", "industry": self.industry.pk}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data["results"]], [match.pk]) + + def test_my_projects_include_private_draft_for_leader_and_collaborator(self): + led = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + joined = create_project( + leader=self.outsider, + draft=True, + is_public=False, + ) + create_collaborator(joined, user=self.leader) + self.authenticate(self.leader) + + response = self.client.get("/projects/my/") + + self.assertEqual(response.status_code, 200) + by_id = {item["id"]: item for item in response.data["results"]} + self.assertEqual(by_id[led.pk]["current_user_role"], "leader") + self.assertTrue(by_id[led.pk]["can_edit"]) + self.assertTrue(by_id[led.pk]["can_use_in_application"]) + self.assertEqual(by_id[joined.pk]["current_user_role"], "collaborator") + self.assertFalse(by_id[joined.pk]["can_edit"]) + self.assertFalse(by_id[joined.pk]["can_use_in_application"]) + + def test_list_contract_includes_related_activities_without_n_plus_one(self): + projects = [ + create_project(leader=self.leader, draft=True, is_public=False) + for _index in range(3) + ] + for index, project in enumerate(projects): + program = create_partner_program(name=f"Activity {index}") + Application.objects.create( + program=program, + user=self.leader, + created_by=self.leader, + project=project, + ) + self.authenticate(self.leader) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get("/projects/my/", {"limit": 20}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 3) + self.assertTrue(response.data["results"][0]["activities"]) + self.assertLessEqual(len(queries), 10) + + def test_workspace_detail_hides_private_project_from_outsider(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + self.authenticate(self.outsider) + + response = self.client.get(f"/projects/{project.pk}/workspace/") + + self.assertEqual(response.status_code, 404) + + def test_workspace_detail_is_public_but_private_data_is_not_exposed(self): + project = create_project( + leader=self.leader, + draft=False, + is_public=True, + industry=self.industry, + ) + self.authenticate(self.outsider) + + response = self.client.get(f"/projects/{project.pk}/workspace/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["leader"]["id"], self.leader.pk) + self.assertNotIn("email", response.data["leader"]) + self.assertNotIn("phone_number", response.data["leader"]) + self.assertNotIn("form_data", response.data) + + def test_leader_can_patch_only_workspace_fields(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"name": "Updated", "draft": False, "is_public": True}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.name, "Updated") + self.assertFalse(project.draft) + self.assertTrue(project.is_public) + + 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) + self.authenticate(self.collaborator_user) + forbidden = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"name": "Forbidden"}, + format="json", + ) + + self.authenticate(self.leader) + immutable = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"leader": self.outsider.pk}, + format="json", + ) + + self.assertEqual(forbidden.status_code, 403) + self.assertEqual(immutable.status_code, 400) + project.refresh_from_db() + self.assertEqual(project.leader, self.leader) + + def test_staff_can_patch_private_project(self): + project = create_project(leader=self.leader, draft=True, is_public=False) + self.authenticate(self.staff) + + response = self.client.patch( + f"/projects/{project.pk}/workspace/", + {"description": "Administrative correction"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.description, "Administrative correction") + + def test_legacy_patch_remains_partial(self): + project = create_project( + leader=self.leader, + description="Must remain", + draft=True, + ) + self.authenticate(self.leader) + + response = self.client.patch( + f"/projects/{project.pk}/", + {"name": "Legacy partial update"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.name, "Legacy partial update") + self.assertEqual(project.description, "Must remain") + + +class ApplicationProjectSummaryTests(TestCase): + def test_application_response_keeps_project_id_and_adds_summary(self): + client = APIClient() + user = create_user(prefix="application-project-summary") + program = create_partner_program() + project = create_project( + leader=user, + draft=True, + is_public=False, + ) + application = Application.objects.create( + program=program, + user=user, + created_by=user, + project=project, + ) + client.force_authenticate(user=user) + + response = client.get(f"/applications/{application.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["project"], project.pk) + self.assertEqual( + response.data["project_summary"], + { + "id": project.pk, + "name": project.name, + "draft": True, + "is_public": False, + }, + ) + + def test_submitted_application_cannot_replace_reused_project(self): + client = APIClient() + user = create_user(prefix="application-project-immutable") + program = create_partner_program() + original = create_project(leader=user, draft=True, is_public=False) + replacement = create_project(leader=user, draft=True, is_public=False) + application = Application.objects.create( + program=program, + user=user, + created_by=user, + project=original, + status=Application.STATUS_SUBMITTED, + ) + client.force_authenticate(user=user) + + response = client.patch( + f"/applications/{application.pk}/", + {"project_id": replacement.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual(application.project, original) diff --git a/projects/urls.py b/projects/urls.py index b9810e61..8ddc3d02 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -24,6 +24,11 @@ SetLikeOnProject, SwitchLeaderRole, ) +from projects.workspace_views import ( + MyProjectsView, + ProjectCatalogView, + ProjectWorkspaceDetailView, +) app_name = "projects" project_goal_list = GoalViewSet.as_view( @@ -58,6 +63,13 @@ ) urlpatterns = [ path("", ProjectList.as_view()), + path("catalog/", ProjectCatalogView.as_view(), name="workspace-catalog"), + path("my/", MyProjectsView.as_view(), name="workspace-my"), + path( + "/workspace/", + ProjectWorkspaceDetailView.as_view(), + name="workspace-detail", + ), path("/like/", SetLikeOnProject.as_view()), path("/news/", NewsList.as_view()), path("/subscribe/", ProjectSubscribe.as_view()), diff --git a/projects/views.py b/projects/views.py index aa252fc2..45da647d 100644 --- a/projects/views.py +++ b/projects/views.py @@ -198,7 +198,7 @@ def patch(self, request, pk, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) check_related_fields_update(request.data, pk) - return super(ProjectDetail, self).put(request, pk) + return super(ProjectDetail, self).patch(request, pk) class ProjectRecommendedUsers(generics.RetrieveAPIView): diff --git a/projects/workspace_selectors.py b/projects/workspace_selectors.py new file mode 100644 index 00000000..d010b6aa --- /dev/null +++ b/projects/workspace_selectors.py @@ -0,0 +1,60 @@ +from django.db.models import Prefetch, Q, QuerySet + +from partner_programs.models import Application +from projects.models import Collaborator, Project + + +def _with_workspace_relations(queryset: QuerySet[Project], user) -> QuerySet[Project]: + """Загружает роли и активности заранее, исключая N+1 в API проектов.""" + current_user_collaborations = Collaborator.objects.filter(user=user) + applications = Application.objects.select_related("program").order_by( + "-updated_at", "-id" + ) + return queryset.select_related("leader", "industry").prefetch_related( + Prefetch( + "collaborator_set", + queryset=current_user_collaborations, + to_attr="_current_user_collaborations", + ), + Prefetch( + "applications", + queryset=applications, + to_attr="_workspace_applications", + ), + ) + + +def get_project_catalog_queryset(*, user, search=None, industry_id=None): + """Возвращает опубликованные публичные проекты в стабильном порядке.""" + queryset = Project.objects.filter(draft=False, is_public=True) + if search: + queryset = queryset.filter(name__icontains=search.strip()) + if industry_id: + queryset = queryset.filter(industry_id=industry_id) + return _with_workspace_relations(queryset, user).order_by("-datetime_updated", "-id") + + +def get_user_projects_queryset(*, user, search=None): + """Возвращает проекты пользователя как руководителя или участника.""" + queryset = Project.objects.filter( + Q(leader=user) | Q(collaborator__user=user) + ).distinct() + if search: + queryset = queryset.filter(name__icontains=search.strip()) + return _with_workspace_relations(queryset, user).order_by("-datetime_updated", "-id") + + +def get_workspace_project_queryset(*, user): + """Готовит detail queryset с безопасными пользовательскими связями проекта.""" + collaborators = Collaborator.objects.select_related("user").order_by( + "datetime_created", "id" + ) + queryset = _with_workspace_relations(Project.objects.all(), user) + return queryset.prefetch_related( + Prefetch( + "collaborator_set", + queryset=collaborators, + to_attr="_workspace_collaborators", + ), + "links", + ) diff --git a/projects/workspace_serializers.py b/projects/workspace_serializers.py new file mode 100644 index 00000000..9883f586 --- /dev/null +++ b/projects/workspace_serializers.py @@ -0,0 +1,147 @@ +from rest_framework import serializers + +from projects.models import Project + + +PROJECT_WORKSPACE_EDITABLE_FIELDS = frozenset( + { + "name", + "description", + "region", + "actuality", + "problem", + "target_audience", + "implementation_deadline", + "trl", + "presentation_address", + "image_address", + "cover_image_address", + "draft", + "is_public", + } +) + + +class ProjectWorkspaceUserSerializer(serializers.Serializer): + id = serializers.IntegerField() + first_name = serializers.CharField(allow_blank=True) + last_name = serializers.CharField(allow_blank=True) + avatar = serializers.URLField(allow_blank=True, allow_null=True) + + +class ProjectWorkspaceCollaboratorSerializer(serializers.Serializer): + user = ProjectWorkspaceUserSerializer() + role = serializers.CharField(allow_blank=True, allow_null=True) + specialization = serializers.CharField(allow_blank=True, allow_null=True) + + +class ProjectActivitySerializer(serializers.Serializer): + id = serializers.IntegerField(source="program_id") + name = serializers.CharField(source="program.name") + application_id = serializers.IntegerField(source="id") + application_status = serializers.CharField(source="status") + + +class ProjectWorkspaceListSerializer(serializers.ModelSerializer): + short_description = serializers.SerializerMethodField() + current_user_role = serializers.SerializerMethodField() + can_edit = serializers.SerializerMethodField() + can_use_in_application = serializers.SerializerMethodField() + activities = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ( + "id", + "name", + "short_description", + "image_address", + "cover_image_address", + "draft", + "is_public", + "current_user_role", + "can_edit", + "can_use_in_application", + "activities", + "datetime_updated", + ) + + def get_short_description(self, project): + return project.get_short_description() + + def get_current_user_role(self, project): + user = self.context["request"].user + if project.leader_id == user.pk: + return "leader" + collaborations = getattr(project, "_current_user_collaborations", ()) + return "collaborator" if collaborations else None + + def get_can_edit(self, project): + user = self.context["request"].user + return bool(user.is_staff or user.is_superuser or project.leader_id == user.pk) + + def get_can_use_in_application(self, project): + return project.leader_id == self.context["request"].user.pk + + def get_activities(self, project): + applications = getattr(project, "_workspace_applications", ()) + return ProjectActivitySerializer(applications, many=True).data + + +class ProjectWorkspaceDetailSerializer(ProjectWorkspaceListSerializer): + leader = ProjectWorkspaceUserSerializer(read_only=True) + collaborators = serializers.SerializerMethodField() + links = serializers.SerializerMethodField() + industry = serializers.SerializerMethodField() + + class Meta(ProjectWorkspaceListSerializer.Meta): + fields = ProjectWorkspaceListSerializer.Meta.fields + ( + "description", + "region", + "actuality", + "problem", + "target_audience", + "implementation_deadline", + "trl", + "presentation_address", + "leader", + "collaborators", + "links", + "industry", + "datetime_created", + ) + + def get_collaborators(self, project): + collaborators = getattr(project, "_workspace_collaborators", ()) + return ProjectWorkspaceCollaboratorSerializer(collaborators, many=True).data + + def get_links(self, project): + return [item.link for item in project.links.all()] + + def get_industry(self, project): + if project.industry is None: + return None + return {"id": project.industry_id, "name": project.industry.name} + + +class ProjectWorkspaceUpdateSerializer(serializers.ModelSerializer): + editable_fields = PROJECT_WORKSPACE_EDITABLE_FIELDS + + class Meta: + model = Project + fields = tuple(sorted(PROJECT_WORKSPACE_EDITABLE_FIELDS)) + extra_kwargs = { + "name": {"allow_blank": False, "allow_null": False}, + "description": {"allow_blank": True, "allow_null": True}, + } + + def validate(self, attrs): + unsupported = set(self.initial_data).difference(self.editable_fields) + if unsupported: + raise serializers.ValidationError( + { + field: "Это поле нельзя изменить через API рабочего пространства." + for field in sorted(unsupported) + } + ) + return attrs diff --git a/projects/workspace_views.py b/projects/workspace_views.py new file mode 100644 index 00000000..807230ac --- /dev/null +++ b/projects/workspace_views.py @@ -0,0 +1,112 @@ +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from rest_framework import generics, status +from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from projects.pagination import ProjectsPagination +from projects.workspace_selectors import ( + get_project_catalog_queryset, + get_user_projects_queryset, + get_workspace_project_queryset, +) +from projects.workspace_serializers import ( + ProjectWorkspaceDetailSerializer, + ProjectWorkspaceListSerializer, + ProjectWorkspaceUpdateSerializer, +) + + +def _parse_industry_id(raw_value): + if raw_value in (None, ""): + return None + try: + value = int(raw_value) + except (TypeError, ValueError) as exc: + raise ValidationError( + {"industry": "Укажите корректный идентификатор отрасли."} + ) from exc + if value <= 0: + raise ValidationError({"industry": "Укажите корректный идентификатор отрасли."}) + return value + + +class ProjectCatalogView(generics.ListAPIView): + """Публичный каталог опубликованных проектов для авторизованного React-клиента.""" + + permission_classes = [IsAuthenticated] + serializer_class = ProjectWorkspaceListSerializer + pagination_class = ProjectsPagination + + def get_queryset(self): + return get_project_catalog_queryset( + user=self.request.user, + search=self.request.query_params.get("search"), + industry_id=_parse_industry_id(self.request.query_params.get("industry")), + ) + + +class MyProjectsView(generics.ListAPIView): + """Проекты, которыми пользователь руководит или в которых участвует.""" + + permission_classes = [IsAuthenticated] + serializer_class = ProjectWorkspaceListSerializer + pagination_class = ProjectsPagination + + def get_queryset(self): + return get_user_projects_queryset( + user=self.request.user, + search=self.request.query_params.get("search"), + ) + + +class ProjectWorkspaceDetailView(APIView): + """Безопасная карточка Project и ограниченное редактирование лидером.""" + + permission_classes = [IsAuthenticated] + + def get_object(self, request, project_id): + queryset = get_workspace_project_queryset(user=request.user) + if not (request.user.is_staff or request.user.is_superuser): + queryset = queryset.filter( + Q(draft=False, is_public=True) + | Q(leader=request.user) + | Q(collaborator__user=request.user) + ).distinct() + return get_object_or_404(queryset, pk=project_id) + + def get(self, request, project_id): + project = self.get_object(request, project_id) + serializer = ProjectWorkspaceDetailSerializer( + project, + context={"request": request}, + ) + return Response(serializer.data) + + def patch(self, request, project_id): + project = self.get_object(request, project_id) + if not ( + request.user.is_staff + or request.user.is_superuser + or project.leader_id == request.user.pk + ): + raise PermissionDenied("Редактировать проект может только руководитель.") + + serializer = ProjectWorkspaceUpdateSerializer( + project, + data=request.data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + # Повторная выборка возвращает тот же полный контракт, что и GET, + # включая вычисленные права и связанные активности. + updated_project = self.get_object(request, project_id) + response_serializer = ProjectWorkspaceDetailSerializer( + updated_project, + context={"request": request}, + ) + return Response(response_serializer.data, status=status.HTTP_200_OK) From 9c455da01042c6ad91853e7524bd7af35c635647 Mon Sep 17 00:00:00 2001 From: Fedor Date: Sun, 2 Aug 2026 17:27:50 +0300 Subject: [PATCH 2/2] Fix project creation database locking --- partner_programs/services/submission_project.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/partner_programs/services/submission_project.py b/partner_programs/services/submission_project.py index 4fd2b4c5..e759defd 100644 --- a/partner_programs/services/submission_project.py +++ b/partner_programs/services/submission_project.py @@ -55,15 +55,9 @@ def create_project_from_submission(*, submission_id, actor): Submission остается историческим снимком решения. Повторные версии одной Application используют одну связь Application.project и не клонируют Project. """ - submission = ( - Submission.objects.select_for_update() - .select_related("application") - .get(pk=submission_id) - ) - application = ( - Application.objects.select_for_update() - .select_related("user", "project") - .get(pk=submission.application_id) + submission = Submission.objects.select_for_update().get(pk=submission_id) + application = Application.objects.select_for_update().get( + pk=submission.application_id ) if not (actor.is_staff or actor.is_superuser or application.user_id == actor.pk):