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
35 changes: 33 additions & 2 deletions vacancy/managers.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,55 @@
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):
expiration_check = timezone.now() - timedelta(days=90)
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",
)
)

Expand Down
115 changes: 65 additions & 50 deletions vacancy/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -329,6 +335,7 @@ def validate(self, data):
class ProjectVacancyCreateListSerializer(
VacancyCityValidationMixin,
VacancyCreationDateSerializerMixin,
VacancyApplicantStateSerializerMixin,
serializers.ModelSerializer,
AbstractVacancyReadOnlyFields,
AbstractVacancyEnumFields,
Expand Down Expand Up @@ -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",
]


Expand Down
91 changes: 91 additions & 0 deletions vacancy/tests/test_vacancy_response_contract_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 9 additions & 6 deletions vacancy/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading