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
158 changes: 158 additions & 0 deletions docs/projects-lifecycle-api.md
Original file line number Diff line number Diff line change
@@ -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/<id>/`, `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/<project_id>/workspace/`

Дополняет list contract описанием, отраслью, регионом, TRL, сроком реализации, руководителем, коллабораторами и ссылками. Публичный опубликованный Project доступен любому авторизованному пользователю. Private/draft видят руководитель, Collaborator и staff. Для постороннего private/draft скрывается через 404.

Ответ не содержит email, телефон, Application.form_data, Submission или закрытые профильные данные.

### `PATCH /projects/<project_id>/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/<submission_id>/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 должны проверяться в целевой СУБД.
12 changes: 12 additions & 0 deletions partner_programs/serializers/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,6 +44,7 @@ class ApplicationSerializer(serializers.ModelSerializer):
"created_by",
"status",
"team",
"project_summary",
"submitted_at",
"approved_at",
"rejected_at",
Expand All @@ -56,6 +67,7 @@ class Meta:
"team_name",
"form_data",
"project",
"project_summary",
"project_id",
"submitted_at",
"approved_at",
Expand Down
111 changes: 111 additions & 0 deletions partner_programs/services/submission_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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().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):
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)
55 changes: 55 additions & 0 deletions partner_programs/submission_project_views.py
Original file line number Diff line number Diff line change
@@ -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),
)
6 changes: 6 additions & 0 deletions partner_programs/submission_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@
SubmissionDetailView,
SubmissionSubmitView,
)
from partner_programs.submission_project_views import SubmissionProjectCreateView

app_name = "submissions"

urlpatterns = [
path(
"<int:submission_id>/project/",
SubmissionProjectCreateView.as_view(),
name="project-create",
),
path(
"<int:submission_id>/evaluations/my/",
MyEvaluationView.as_view(),
Expand Down
Loading
Loading