From e527c65745da60e191adf2be9d3abf4e77cd592e Mon Sep 17 00:00:00 2001 From: Fedor Date: Sat, 29 Aug 2026 23:01:26 +0300 Subject: [PATCH] Expose vacancy catalog applicant actions --- vacancy/managers.py | 35 +++++- vacancy/serializers.py | 115 ++++++++++-------- .../test_vacancy_response_contract_api.py | 91 ++++++++++++++ vacancy/views.py | 15 ++- 4 files changed, 198 insertions(+), 58 deletions(-) diff --git a/vacancy/managers.py b/vacancy/managers.py index 82c4040b..da6b3805 100644 --- a/vacancy/managers.py +++ b/vacancy/managers.py @@ -1,8 +1,10 @@ from datetime import timedelta -from django.db.models import Manager +from django.db.models import Count, Manager, Prefetch, Q from django.utils import timezone +from core.models import SkillToObject + class VacancyManager(Manager): def get_vacancy_for_list_view(self): @@ -10,15 +12,44 @@ def get_vacancy_for_list_view(self): return ( self.get_queryset() .select_related("project") + .prefetch_related( + Prefetch( + "required_skills", + queryset=SkillToObject.objects.select_related( + "skill", + "skill__category", + ), + ) + ) + .annotate( + pending_response_count=Count( + "vacancy_requests", + filter=Q(vacancy_requests__is_approved__isnull=True), + ) + ) .filter(datetime_created__gte=expiration_check) .only( "role", - "required_skills", + "specialization", "description", "project__id", + "project__name", + "project__leader", + "project__description", + "project__image_address", + "project__industry", + "project__is_company", + "project__draft", + "project__is_public", "is_active", + "datetime_closed", "datetime_created", "datetime_updated", + "required_experience", + "work_schedule", + "work_format", + "salary", + "city", ) ) diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 183ee20a..26efbe99 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -122,6 +122,61 @@ def get_date_create_time(obj: Vacancy): return _format_datetime_with_seconds(getattr(obj, "datetime_created", None)) +class VacancyApplicantStateSerializerMixin(serializers.Serializer): + """Преобразует аннотации состояния отклика в безопасные UI-подсказки.""" + + has_responded = serializers.SerializerMethodField(read_only=True) + response_status = serializers.SerializerMethodField(read_only=True) + can_respond = serializers.SerializerMethodField(read_only=True) + can_manage_responses = serializers.SerializerMethodField(read_only=True) + + @staticmethod + def get_has_responded(vacancy: Vacancy) -> bool: + return bool(getattr(vacancy, "current_user_has_responded", False)) + + def get_response_status(self, vacancy: Vacancy) -> str | None: + if not self.get_has_responded(vacancy): + return None + + is_approved = getattr( + vacancy, + "current_user_response_is_approved", + None, + ) + if is_approved is True: + return "accepted" + if is_approved is False: + return "rejected" + return "pending" + + def get_can_respond(self, vacancy: Vacancy) -> bool: + request = self.context.get("request") + user = getattr(request, "user", None) + return bool( + user + and user.is_authenticated + and vacancy.is_active + and vacancy.project.is_public + and not vacancy.project.draft + and vacancy.project.leader_id != user.id + and not getattr(vacancy, "current_user_is_collaborator", False) + and not self.get_has_responded(vacancy) + ) + + def get_can_manage_responses(self, vacancy: Vacancy) -> bool: + request = self.context.get("request") + user = getattr(request, "user", None) + return bool( + user + and user.is_authenticated + and ( + vacancy.project.leader_id == user.id + or getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + ) + ) + + class ProjectVacancyListSerializer( VacancyCreationDateSerializerMixin, serializers.ModelSerializer, @@ -173,62 +228,13 @@ class Meta: class VacancyDetailSerializer( VacancyCityValidationMixin, VacancyCreationDateSerializerMixin, + VacancyApplicantStateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, AbstractVacancyEnumFields, RequiredSkillsWriteSerializerMixin[Vacancy], ): project = ProjectForVacancySerializer(many=False, read_only=True) - has_responded = serializers.SerializerMethodField(read_only=True) - response_status = serializers.SerializerMethodField(read_only=True) - can_respond = serializers.SerializerMethodField(read_only=True) - can_manage_responses = serializers.SerializerMethodField(read_only=True) - - @staticmethod - def get_has_responded(vacancy: Vacancy) -> bool: - return bool(getattr(vacancy, "current_user_has_responded", False)) - - def get_response_status(self, vacancy: Vacancy) -> str | None: - if not self.get_has_responded(vacancy): - return None - - is_approved = getattr( - vacancy, - "current_user_response_is_approved", - None, - ) - if is_approved is True: - return "accepted" - if is_approved is False: - return "rejected" - return "pending" - - def get_can_respond(self, vacancy: Vacancy) -> bool: - request = self.context.get("request") - user = getattr(request, "user", None) - return bool( - user - and user.is_authenticated - and vacancy.is_active - and vacancy.project.is_public - and not vacancy.project.draft - and vacancy.project.leader_id != user.id - and not getattr(vacancy, "current_user_is_collaborator", False) - and not self.get_has_responded(vacancy) - ) - - def get_can_manage_responses(self, vacancy: Vacancy) -> bool: - request = self.context.get("request") - user = getattr(request, "user", None) - return bool( - user - and user.is_authenticated - and ( - vacancy.project.leader_id == user.id - or getattr(user, "is_staff", False) - or getattr(user, "is_superuser", False) - ) - ) class Meta: model = Vacancy @@ -329,6 +335,7 @@ def validate(self, data): class ProjectVacancyCreateListSerializer( VacancyCityValidationMixin, VacancyCreationDateSerializerMixin, + VacancyApplicantStateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, AbstractVacancyEnumFields, @@ -390,11 +397,19 @@ class Meta: "work_format", "salary", "city", + "has_responded", + "response_status", + "can_respond", + "can_manage_responses", ] read_only_fields = [ "date_create_time", "datetime_created", "datetime_updated", + "has_responded", + "response_status", + "can_respond", + "can_manage_responses", ] diff --git a/vacancy/tests/test_vacancy_response_contract_api.py b/vacancy/tests/test_vacancy_response_contract_api.py index e471c3b3..3a506d2d 100644 --- a/vacancy/tests/test_vacancy_response_contract_api.py +++ b/vacancy/tests/test_vacancy_response_contract_api.py @@ -401,6 +401,97 @@ def test_response_status_does_not_add_separate_detail_query(self): self.assertEqual(response.data["response_status"], "pending") +class VacancyCatalogApplicantStateTests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="catalog-leader") + self.project = create_project(leader=self.leader) + self.vacancy = create_vacancy(project=self.project) + + def get_catalog_item(self, user=None, vacancy=None): + self.client.force_authenticate(user=user) + response = self.client.get("/vacancies/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + vacancy_id = (vacancy or self.vacancy).id + return next(item for item in response.data["results"] if item["id"] == vacancy_id) + + def test_anonymous_and_outsider_states(self): + anonymous = self.get_catalog_item() + self.assertFalse(anonymous["has_responded"]) + self.assertIsNone(anonymous["response_status"]) + self.assertFalse(anonymous["can_respond"]) + self.assertFalse(anonymous["can_manage_responses"]) + + outsider = create_user(prefix="catalog-outsider") + available = self.get_catalog_item(outsider) + self.assertFalse(available["has_responded"]) + self.assertIsNone(available["response_status"]) + self.assertTrue(available["can_respond"]) + self.assertFalse(available["can_manage_responses"]) + + def test_leader_and_collaborator_states(self): + managed = self.get_catalog_item(self.leader) + self.assertFalse(managed["has_responded"]) + self.assertFalse(managed["can_respond"]) + self.assertTrue(managed["can_manage_responses"]) + + collaborator = create_user(prefix="catalog-collaborator") + Collaborator.objects.create( + project=self.project, + user=collaborator, + role="Developer", + ) + member = self.get_catalog_item(collaborator) + self.assertFalse(member["has_responded"]) + self.assertFalse(member["can_respond"]) + self.assertFalse(member["can_manage_responses"]) + + def test_pending_response_state(self): + applicant = create_user(prefix="catalog-pending") + create_vacancy_response(user=applicant, vacancy=self.vacancy) + + item = self.get_catalog_item(applicant) + + self.assertTrue(item["has_responded"]) + self.assertEqual(item["response_status"], "pending") + self.assertFalse(item["can_respond"]) + self.assertFalse(item["can_manage_responses"]) + + def test_processed_response_status_mapping(self): + for is_approved, expected_status in ( + (True, "accepted"), + (False, "rejected"), + ): + with self.subTest(expected_status=expected_status): + applicant = create_user(prefix=f"catalog-{expected_status}") + create_vacancy_response( + user=applicant, + vacancy=self.vacancy, + is_approved=is_approved, + ) + + item = self.get_catalog_item(applicant) + + self.assertTrue(item["has_responded"]) + self.assertEqual(item["response_status"], expected_status) + self.assertFalse(item["can_respond"]) + + def test_catalog_query_count_does_not_grow_with_result_count(self): + outsider = create_user(prefix="catalog-query-count") + for index in range(4): + create_vacancy(project=self.project, role=f"Vacancy {index}") + self.client.force_authenticate(outsider) + self.client.get("/vacancies/", {"limit": 5}) + + def query_count(limit): + with CaptureQueriesContext(connection) as queries: + response = self.client.get("/vacancies/", {"limit": limit}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + return len(queries) + + self.assertEqual(query_count(1), query_count(5)) + + class VacancyResponseDecisionContractTests(TestCase): def setUp(self): self.client = APIClient() diff --git a/vacancy/views.py b/vacancy/views.py index 8eaf0495..7a46b8da 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -64,12 +64,15 @@ def get_queryset(self): if project_id and user.is_authenticated: if user.is_staff or user.is_superuser: - return queryset - return queryset.filter( - public_catalog | Q(project_id=project_id, project__leader_id=user.id) - ) - - return queryset.filter(public_catalog) + visible_queryset = queryset + else: + visible_queryset = queryset.filter( + public_catalog | Q(project_id=project_id, project__leader_id=user.id) + ) + else: + visible_queryset = queryset.filter(public_catalog) + + return with_applicant_state(visible_queryset, user) class VacancyDetail(generics.RetrieveUpdateDestroyAPIView):