diff --git a/vacancy/serializers.py b/vacancy/serializers.py index efbe7b67..ba2e1c73 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -118,6 +118,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, @@ -169,62 +224,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 @@ -285,6 +291,7 @@ class Meta: class VacancyCatalogSerializer( VacancyCreationDateSerializerMixin, + VacancyApplicantStateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, AbstractVacancyEnumFields, @@ -314,6 +321,10 @@ class Meta: "work_format", "salary", "city", + "has_responded", + "response_status", + "can_respond", + "can_manage_responses", ] read_only_fields = fields diff --git a/vacancy/tests/test_vacancy_contract_api.py b/vacancy/tests/test_vacancy_contract_api.py index 022f07a2..41ec3f6e 100644 --- a/vacancy/tests/test_vacancy_contract_api.py +++ b/vacancy/tests/test_vacancy_contract_api.py @@ -283,6 +283,97 @@ def test_response_status_does_not_add_separate_detail_query(self): self.assertEqual(response.data["response_status"], "pending") +class VacancyCatalogApplicantStateContractTests(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 VacancyResponseContractTests(TestCase): def setUp(self): self.client = APIClient() diff --git a/vacancy/views.py b/vacancy/views.py index 7a33e4ee..3b2d328c 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -60,7 +60,10 @@ class VacancyList(generics.ListCreateAPIView): pagination_class = VacancyPagination def get_queryset(self): - return get_public_vacancies_queryset() + return with_applicant_state( + get_public_vacancies_queryset(), + self.request.user, + ) def get_serializer_class(self): if self.request.method == "GET":