From a229624373644ef8d4ef8f7c4f280b1c4047a77c Mon Sep 17 00:00:00 2001 From: Fedor Date: Fri, 28 Aug 2026 13:05:04 +0300 Subject: [PATCH] Fix dev vacancy catalog visibility and timestamps --- vacancy/filters.py | 20 ++--- vacancy/managers.py | 1 + vacancy/serializers.py | 7 ++ vacancy/tests/test_vacancies_api.py | 126 +++++++++++++++++++++++++++- vacancy/views.py | 23 ++++- 5 files changed, 160 insertions(+), 17 deletions(-) diff --git a/vacancy/filters.py b/vacancy/filters.py index 17e4159c..630c7e2b 100644 --- a/vacancy/filters.py +++ b/vacancy/filters.py @@ -11,11 +11,7 @@ def project_id_filter(queryset, name, value) -> QuerySet: - return queryset.filter( - **{ - "project_id": value[0], - } - ) + return queryset.filter(project_id=value) class VacancyFilter(filters.FilterSet): @@ -44,7 +40,7 @@ def __init__(self, *args, **kwargs): """if is_active filter is not passed, default to True""" super().__init__(*args, **kwargs) if self.data.get("is_active") is None: - self.data = dict(self.data) + self.data = self.data.copy() self.data["is_active"] = True def filter_by_experience( @@ -69,10 +65,10 @@ def filter_by_format( ) def filter_by_salary_min( - self, queryset: QuerySet[Vacancy], name, value: list[str] + self, queryset: QuerySet[Vacancy], name, value: str ) -> QuerySet[Vacancy]: try: - min_salary = int(value[0]) + min_salary = int(value) return queryset.filter(Q(salary__gte=min_salary) | Q(salary=None)).order_by( F("salary").asc(nulls_last=True) ) @@ -80,10 +76,10 @@ def filter_by_salary_min( return queryset def filter_by_salary_max( - self, queryset: QuerySet[Vacancy], name, value: list[str] + self, queryset: QuerySet[Vacancy], name, value: str ) -> QuerySet[Vacancy]: try: - max_salary = int(value[0]) + max_salary = int(value) return queryset.filter(Q(salary__lte=max_salary) | Q(salary=None)).order_by( F("salary").asc(nulls_last=True) ) @@ -91,11 +87,11 @@ def filter_by_salary_max( return queryset def filter_by_role( - self, queryset: QuerySet[Vacancy], name, value: list[str] + self, queryset: QuerySet[Vacancy], name, value: str ) -> QuerySet[Vacancy]: if not value: return queryset - return queryset.filter(role__icontains=value[0]) + return queryset.filter(role__icontains=value) project_id = filters.Filter(method=project_id_filter) is_active = filters.BooleanFilter(field_name="is_active") diff --git a/vacancy/managers.py b/vacancy/managers.py index 27869373..82c4040b 100644 --- a/vacancy/managers.py +++ b/vacancy/managers.py @@ -18,6 +18,7 @@ def get_vacancy_for_list_view(self): "project__id", "is_active", "datetime_created", + "datetime_updated", ) ) diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 0a5533a0..1866cda4 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -365,12 +365,19 @@ class Meta: "datetime_closed", "response_count", "date_create_time", + "datetime_created", + "datetime_updated", "required_experience", "work_schedule", "work_format", "salary", "city", ] + read_only_fields = [ + "date_create_time", + "datetime_created", + "datetime_updated", + ] class VacancyResponseListSerializer(serializers.ModelSerializer): diff --git a/vacancy/tests/test_vacancies_api.py b/vacancy/tests/test_vacancies_api.py index e6882ce1..d83786f9 100644 --- a/vacancy/tests/test_vacancies_api.py +++ b/vacancy/tests/test_vacancies_api.py @@ -2,6 +2,7 @@ from django.test import TestCase from django.utils import timezone +from django.utils.dateparse import parse_datetime from rest_framework import status from rest_framework.test import APIClient @@ -156,11 +157,22 @@ def test_public_list_returns_only_active_vacancies_by_default(self): [active_vacancy.id], ) - def test_list_can_include_inactive_vacancies_by_filter(self): - create_vacancy(role="Active vacancy", is_active=True) - inactive_vacancy = create_vacancy(role="Inactive vacancy", is_active=False) + def test_project_leader_can_list_inactive_vacancies_by_project(self): + leader = create_user(prefix="inactive-vacancy-leader") + project = create_project(leader=leader, draft=True) + while project.id < 10: + project = create_project(leader=leader, draft=True) + inactive_vacancy = create_vacancy( + project=project, + role="Inactive vacancy", + is_active=False, + ) + self.client.force_authenticate(leader) - response = self.client.get("/vacancies/", {"is_active": "false"}) + response = self.client.get( + "/vacancies/", + {"project_id": str(project.id), "is_active": "false"}, + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( @@ -168,6 +180,74 @@ def test_list_can_include_inactive_vacancies_by_filter(self): [inactive_vacancy.id], ) + def test_outsider_does_not_see_draft_or_private_project_vacancies(self): + outsider = create_user(prefix="catalog-outsider") + draft_project = create_project(draft=True) + private_project = create_project(is_public=False) + public_vacancy = create_vacancy(role="Public project vacancy") + create_vacancy( + project=draft_project, + role="Draft project vacancy", + is_active=False, + ) + create_vacancy(project=private_project, role="Private project vacancy") + self.client.force_authenticate(outsider) + + response = self.client.get("/vacancies/") + inactive_response = self.client.get("/vacancies/", {"is_active": "false"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [public_vacancy.id], + ) + self.assertEqual(inactive_response.status_code, status.HTTP_200_OK) + self.assertEqual(inactive_response.data["results"], []) + + def test_anonymous_does_not_see_closed_project_vacancies(self): + draft_project = create_project(draft=True) + private_project = create_project(is_public=False) + public_vacancy = create_vacancy(role="Public project vacancy") + create_vacancy(project=draft_project, role="Draft project vacancy") + create_vacancy(project=private_project, role="Private project vacancy") + create_vacancy(role="Inactive vacancy", is_active=False) + + response = self.client.get("/vacancies/") + inactive_response = self.client.get("/vacancies/", {"is_active": "false"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [public_vacancy.id], + ) + self.assertEqual(inactive_response.status_code, status.HTTP_200_OK) + self.assertEqual(inactive_response.data["results"], []) + + def test_outsider_cannot_use_project_filter_to_disclose_closed_project(self): + outsider = create_user(prefix="project-filter-outsider") + draft_project = create_project(draft=True) + private_project = create_project(is_public=False) + create_vacancy( + project=draft_project, + role="Draft project vacancy", + is_active=False, + ) + create_vacancy(project=private_project, role="Private project vacancy") + self.client.force_authenticate(outsider) + + draft_response = self.client.get( + "/vacancies/", + {"project_id": str(draft_project.id), "is_active": "false"}, + ) + private_response = self.client.get( + "/vacancies/", {"project_id": str(private_project.id)} + ) + + self.assertEqual(draft_response.status_code, status.HTTP_200_OK) + self.assertEqual(draft_response.data["results"], []) + self.assertEqual(private_response.status_code, status.HTTP_200_OK) + self.assertEqual(private_response.data["results"], []) + def test_list_filters_by_project_role_salary_and_work_conditions(self): project = create_project(name="Target project") target = create_vacancy( @@ -205,6 +285,36 @@ def test_list_excludes_vacancies_older_than_90_days(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual([item["id"] for item in response.data["results"]], [fresh.id]) + def test_list_returns_real_vacancy_timestamps(self): + now = timezone.now() + older = create_vacancy( + role="Older vacancy", + datetime_created=now - timedelta(days=2), + ) + newer = create_vacancy( + role="Newer vacancy", + datetime_created=now - timedelta(days=1), + ) + + response = self.client.get("/vacancies/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + vacancies = {item["id"]: item for item in response.data["results"]} + self.assertEqual( + parse_datetime(vacancies[older.id]["datetime_created"]), + older.datetime_created, + ) + self.assertEqual( + parse_datetime(vacancies[newer.id]["datetime_created"]), + newer.datetime_created, + ) + self.assertNotEqual( + vacancies[older.id]["datetime_created"], + vacancies[newer.id]["datetime_created"], + ) + self.assertIn("datetime_updated", vacancies[older.id]) + self.assertIn("date_create_time", vacancies[older.id]) + def test_detail_returns_vacancy_with_project_info(self): vacancy = create_vacancy(role="Detail vacancy", city=None) @@ -215,6 +325,14 @@ def test_detail_returns_vacancy_with_project_info(self): self.assertEqual(response.data["role"], "Detail vacancy") self.assertEqual(response.data["project"]["id"], vacancy.project.id) self.assertIsNone(response.data["city"]) + self.assertEqual( + parse_datetime(response.data["datetime_created"]), + vacancy.datetime_created, + ) + self.assertEqual( + parse_datetime(response.data["datetime_updated"]), + vacancy.datetime_updated, + ) def test_patch_validates_city_against_final_work_format(self): leader = create_user(prefix="patch-city-leader") diff --git a/vacancy/views.py b/vacancy/views.py index a424eb73..8eaf0495 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -1,5 +1,5 @@ from django.db import transaction -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from django.http import Http404 from django_filters import rest_framework as filters from django.shortcuts import get_object_or_404 @@ -50,6 +50,27 @@ class VacancyList(generics.ListCreateAPIView): filterset_class = VacancyFilter pagination_class = VacancyPagination + def get_queryset(self): + """Закрытые вакансии доступны только менеджеру запрошенного проекта.""" + + queryset = super().get_queryset() + public_catalog = Q( + is_active=True, + project__draft=False, + project__is_public=True, + ) + project_id = self.request.query_params.get("project_id") + user = self.request.user + + 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) + class VacancyDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Vacancy.objects.get_vacancy_for_detail_view()