From 05814486197025ed94e4619eaab39f86a2e779aa Mon Sep 17 00:00:00 2001 From: Fedor Date: Sat, 8 Aug 2026 01:34:42 +0300 Subject: [PATCH] Add vacancies to project workspace detail --- docs/projects-lifecycle-api.md | 26 ++- .../tests/test_project_workspace_vacancies.py | 149 ++++++++++++++++++ projects/workspace_selectors.py | 16 +- projects/workspace_serializers.py | 10 ++ 4 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 projects/tests/test_project_workspace_vacancies.py diff --git a/docs/projects-lifecycle-api.md b/docs/projects-lifecycle-api.md index 318f0d76..edc98e66 100644 --- a/docs/projects-lifecycle-api.md +++ b/docs/projects-lifecycle-api.md @@ -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//workspace/` Руководитель и staff могут изменять только: @@ -143,7 +165,7 @@ 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-ответом. @@ -151,7 +173,7 @@ Legacy модели `Project`, `Collaborator`, `PartnerProgramProject`, `Project ## Что остается DEV-066 -Следующим этапом остаются полноценные вакансии, чат, рабочая область, новости, подписки, legacy-приглашения, расширенное управление командой Project, компаниями, ресурсами и целями, передача лидерства и удаление. Также не входят legacy `ProjectScore`, Evaluation lifecycle и автоматическое обновление Project из новых Submission. +Следующим этапом остаются React-интерфейс управления вакансиями, чат, рабочая область, новости, подписки, legacy-приглашения, расширенное управление командой Project, передача лидерства и удаление. Также не входят legacy `ProjectScore`, Evaluation lifecycle и автоматическое обновление Project из новых Submission. ## Проверка diff --git a/projects/tests/test_project_workspace_vacancies.py b/projects/tests/test_project_workspace_vacancies.py new file mode 100644 index 00000000..811958d5 --- /dev/null +++ b/projects/tests/test_project_workspace_vacancies.py @@ -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) diff --git a/projects/workspace_selectors.py b/projects/workspace_selectors.py index 181cebaa..ab855ea2 100644 --- a/projects/workspace_selectors.py +++ b/projects/workspace_selectors.py @@ -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]: @@ -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( @@ -57,6 +70,7 @@ def get_workspace_project_queryset(*, user): to_attr="_workspace_collaborators", ), "links", + Prefetch("vacancies", queryset=vacancies), ) diff --git a/projects/workspace_serializers.py b/projects/workspace_serializers.py index 8051aef0..467a85b2 100644 --- a/projects/workspace_serializers.py +++ b/projects/workspace_serializers.py @@ -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( @@ -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) @@ -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 + ( @@ -122,6 +131,7 @@ class Meta(ProjectWorkspaceListSerializer.Meta): "links", "industry", "datetime_created", + "vacancies", ) def get_collaborators(self, project):