Skip to content
Draft
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
26 changes: 24 additions & 2 deletions docs/projects-lifecycle-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,28 @@ Angular использует legacy endpoints `GET/POST /projects/`, `GET/PUT/PA

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

DEV-087A добавляет read-only поле `vacancies` на основе существующего
`ProjectVacancyListSerializer`. Оно содержит все вакансии текущего проекта, включая
неактивные и созданные более 90 дней назад; ограничения публичного
`GET /vacancies/` к workspace detail не применяются. Пример элемента:

```json
{
"id": 15,
"role": "Backend-разработчик",
"specialization": null,
"required_skills": [
{"id": 3, "name": "Python", "category": {"id": 1, "name": "Backend"}}
],
"description": "Описание вакансии",
"project": 7,
"is_active": false,
"datetime_closed": "2026-08-01T10:00:00Z",
"response_count": 2,
"date_create_time": "2026-01-01T10:00:00+03:00"
}
```

### `PATCH /projects/<project_id>/workspace/`

Руководитель и staff могут изменять только:
Expand Down Expand Up @@ -143,15 +165,15 @@ Angular использует legacy endpoints `GET/POST /projects/`, `GET/PUT/PA

## Производительность и совместимость

List/detail selectors используют `select_related` и `Prefetch` для ролей, Application/Program, команды Project и ссылок. Тест списка задает query budget, который не растет с количеством Project.
List/detail selectors используют `select_related` и `Prefetch` для ролей, Application/Program, команды Project и ссылок. Workspace detail отдельно предзагружает вакансии и их навыки, а число необработанных откликов считает в SQL; количество запросов не растёт с количеством вакансий. Тест списка задает 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.
Следующим этапом остаются React-интерфейс управления вакансиями, чат, рабочая область, новости, подписки, legacy-приглашения, расширенное управление командой Project, передача лидерства и удаление. Также не входят legacy `ProjectScore`, Evaluation lifecycle и автоматическое обновление Project из новых Submission.

## Проверка

Expand Down
149 changes: 149 additions & 0 deletions projects/tests/test_project_workspace_vacancies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
from datetime import timedelta

from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
from rest_framework.test import APIClient

from projects.tests.helpers import create_project, create_user
from vacancy.models import Vacancy
from vacancy.tests.helpers import (
create_skill,
create_vacancy,
create_vacancy_response,
)


class ProjectWorkspaceVacanciesTests(TestCase):
def setUp(self):
self.client = APIClient()
self.leader = create_user(prefix="workspace-vacancies-leader")

def get_workspace(self, project):
self.client.force_authenticate(user=self.leader)
return self.client.get(f"/projects/{project.pk}/workspace/")

def test_workspace_detail_returns_only_current_project_vacancies(self):
project = create_project(
leader=self.leader,
draft=True,
is_public=False,
)
foreign_project = create_project()
active = create_vacancy(project=project, role="Активная", is_active=True)
inactive = create_vacancy(
project=project,
role="Неактивная",
is_active=False,
)
old = create_vacancy(project=project, role="Старая", is_active=True)
Vacancy.objects.filter(pk=old.pk).update(
datetime_created=timezone.now() - timedelta(days=120)
)
create_vacancy(project=foreign_project, role="Чужая", is_active=True)

response = self.get_workspace(project)

self.assertEqual(response.status_code, 200)
vacancies = {item["id"]: item for item in response.data["vacancies"]}
self.assertEqual(set(vacancies), {active.pk, inactive.pk, old.pk})
self.assertTrue(vacancies[active.pk]["is_active"])
self.assertFalse(vacancies[inactive.pk]["is_active"])
self.assertIn(old.pk, vacancies)

def test_workspace_detail_returns_empty_vacancy_list(self):
project = create_project(
leader=self.leader,
draft=True,
is_public=False,
)

response = self.get_workspace(project)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["vacancies"], [])

def test_workspace_vacancy_reuses_legacy_short_contract(self):
project = create_project(
leader=self.leader,
draft=True,
is_public=False,
)
vacancy = create_vacancy(
project=project,
role="Backend-разработчик",
is_active=False,
)
skill = create_skill(name="Python")
vacancy.required_skills.create(skill=skill)
create_vacancy_response(vacancy=vacancy, is_approved=None)
create_vacancy_response(vacancy=vacancy, is_approved=False)

response = self.get_workspace(project)

self.assertEqual(response.status_code, 200)
item = response.data["vacancies"][0]
self.assertEqual(
set(item),
{
"id",
"role",
"specialization",
"required_skills",
"description",
"project",
"is_active",
"datetime_closed",
"response_count",
"date_create_time",
},
)
self.assertEqual(item["project"], project.pk)
self.assertEqual(item["required_skills"][0]["id"], skill.pk)
self.assertEqual(item["response_count"], 1)

def test_workspace_fields_and_access_flags_remain_available(self):
project = create_project(
leader=self.leader,
draft=True,
is_public=False,
)
create_vacancy(project=project)

response = self.get_workspace(project)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["current_user_role"], "leader")
self.assertTrue(response.data["can_edit"])
self.assertTrue(response.data["can_use_in_application"])
self.assertIn("activities", response.data)
self.assertIn("collaborators", response.data)
self.assertIn("vacancies", response.data)

def test_workspace_vacancies_do_not_create_n_plus_one_queries(self):
def capture_for(vacancy_count):
project = create_project(
leader=self.leader,
draft=True,
is_public=False,
)
for index in range(vacancy_count):
vacancy = create_vacancy(
project=project,
role=f"Vacancy {index}",
)
vacancy.required_skills.create(skill=create_skill(name=f"Skill {index}"))
create_vacancy_response(vacancy=vacancy, is_approved=None)

self.client.force_authenticate(user=self.leader)
with CaptureQueriesContext(connection) as queries:
response = self.client.get(f"/projects/{project.pk}/workspace/")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data["vacancies"]), vacancy_count)
return len(queries)

one_vacancy_queries = capture_for(1)
five_vacancies_queries = capture_for(5)

self.assertLessEqual(five_vacancies_queries, one_vacancy_queries)
16 changes: 15 additions & 1 deletion projects/workspace_selectors.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from django.db.models import Prefetch, Q, QuerySet
from django.db.models import Count, Prefetch, Q, QuerySet

from core.models import SkillToObject
from partner_programs.models import Application
from projects.models import Collaborator, Project
from vacancy.models import Vacancy


def _with_workspace_relations(queryset: QuerySet[Project], user) -> QuerySet[Project]:
Expand Down Expand Up @@ -49,6 +51,17 @@ def get_workspace_project_queryset(*, user):
collaborators = Collaborator.objects.select_related("user").order_by(
"datetime_created", "id"
)
required_skills = SkillToObject.objects.select_related("skill__category")
vacancies = (
Vacancy.objects.annotate(
workspace_response_count=Count(
"vacancy_requests",
filter=Q(vacancy_requests__is_approved__isnull=True),
)
)
.prefetch_related(Prefetch("required_skills", queryset=required_skills))
.order_by("-datetime_created", "-id")
)
queryset = _with_workspace_relations(Project.objects.all(), user)
return queryset.prefetch_related(
Prefetch(
Expand All @@ -57,6 +70,7 @@ def get_workspace_project_queryset(*, user):
to_attr="_workspace_collaborators",
),
"links",
Prefetch("vacancies", queryset=vacancies),
)


Expand Down
10 changes: 10 additions & 0 deletions projects/workspace_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from rest_framework import serializers

from projects.models import Project, ProjectLink
from vacancy.serializers import ProjectVacancyListSerializer


PROJECT_WORKSPACE_EDITABLE_FIELDS = frozenset(
Expand Down Expand Up @@ -35,6 +36,13 @@
}


class ProjectWorkspaceVacancySerializer(ProjectVacancyListSerializer):
"""Переиспользует legacy-контракт с заранее подсчитанными откликами."""

def get_response_count(self, vacancy):
return vacancy.workspace_response_count


class ProjectWorkspaceUserSerializer(serializers.Serializer):
id = serializers.IntegerField()
first_name = serializers.CharField(allow_blank=True)
Expand Down Expand Up @@ -106,6 +114,7 @@ class ProjectWorkspaceDetailSerializer(ProjectWorkspaceListSerializer):
collaborators = serializers.SerializerMethodField()
links = serializers.SerializerMethodField()
industry = serializers.SerializerMethodField()
vacancies = ProjectWorkspaceVacancySerializer(many=True, read_only=True)

class Meta(ProjectWorkspaceListSerializer.Meta):
fields = ProjectWorkspaceListSerializer.Meta.fields + (
Expand All @@ -122,6 +131,7 @@ class Meta(ProjectWorkspaceListSerializer.Meta):
"links",
"industry",
"datetime_created",
"vacancies",
)

def get_collaborators(self, project):
Expand Down
Loading