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
111 changes: 61 additions & 50 deletions vacancy/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -285,6 +291,7 @@ class Meta:

class VacancyCatalogSerializer(
VacancyCreationDateSerializerMixin,
VacancyApplicantStateSerializerMixin,
serializers.ModelSerializer,
AbstractVacancyReadOnlyFields,
AbstractVacancyEnumFields,
Expand Down Expand Up @@ -314,6 +321,10 @@ class Meta:
"work_format",
"salary",
"city",
"has_responded",
"response_status",
"can_respond",
"can_manage_responses",
]
read_only_fields = fields

Expand Down
91 changes: 91 additions & 0 deletions vacancy/tests/test_vacancy_contract_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion vacancy/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Loading