diff --git a/projects/tests/test_project_workspace_vacancies.py b/projects/tests/test_project_workspace_vacancies.py index 811958d5..0cd1cd05 100644 --- a/projects/tests/test_project_workspace_vacancies.py +++ b/projects/tests/test_project_workspace_vacancies.py @@ -97,6 +97,11 @@ def test_workspace_vacancy_reuses_legacy_short_contract(self): "datetime_closed", "response_count", "date_create_time", + "required_experience", + "work_schedule", + "work_format", + "salary", + "city", }, ) self.assertEqual(item["project"], project.pk) diff --git a/vacancy/constants.py b/vacancy/constants.py index 41bb410d..5073fd6f 100644 --- a/vacancy/constants.py +++ b/vacancy/constants.py @@ -2,7 +2,6 @@ class ChoicesMixin: - @classmethod def choices(cls): """Return a list of tuples (value, display_name) for choices.""" @@ -51,3 +50,10 @@ class WorkFormat(ChoicesMixin, Enum): REMOTE: str = "удаленная работа" OFFICE: str = "работа в офисе" HYBRID: str = "смешанный формат" + + @classmethod + def from_display(cls, display_value): + """Нормализует legacy-значение Angular в канонический смешанный формат.""" + if display_value == "смешанная": + display_value = cls.HYBRID.value + return super().from_display(display_value) diff --git a/vacancy/migrations/0010_vacancy_city.py b/vacancy/migrations/0010_vacancy_city.py new file mode 100644 index 00000000..e30cd6d3 --- /dev/null +++ b/vacancy/migrations/0010_vacancy_city.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2026-08-25 20:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vacancy", "0009_vacancy_specialization"), + ] + + operations = [ + migrations.AddField( + model_name="vacancy", + name="city", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="Город" + ), + ), + ] diff --git a/vacancy/models.py b/vacancy/models.py index ba433c51..ba44b77f 100644 --- a/vacancy/models.py +++ b/vacancy/models.py @@ -21,6 +21,7 @@ class Vacancy(models.Model): required_experience: CharField (choice). work_schedule: CharField (choice). work_format: CharField (choice). + city: CharField city for office and hybrid vacancies. project: A ForeignKey referring to the Company model. is_active: A boolean indicating if Vacancy is active. datetime_created: A DateTimeField indicating date of creation. @@ -57,6 +58,12 @@ class Vacancy(models.Model): null=True, verbose_name="Формат работы", ) + city = models.CharField( + max_length=255, + null=True, + blank=True, + verbose_name="Город", + ) salary = models.IntegerField( blank=True, null=True, diff --git a/vacancy/serializers.py b/vacancy/serializers.py index ad758b71..8a4e9dd8 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -61,6 +61,30 @@ def to_representation(self, instance): return representation +class VacancyCityValidationMixin: + """Проверяет город по итоговому формату вакансии для create, PUT и PATCH.""" + + CITY_REQUIRED_MESSAGE = "Для офисного или смешанного формата укажите город." + + def validate(self, attrs): + attrs = super().validate(attrs) + instance = getattr(self, "instance", None) + work_format = attrs.get("work_format", getattr(instance, "work_format", None)) + city = attrs.get("city", getattr(instance, "city", None)) + + if work_format == WorkFormat.REMOTE.name.lower(): + attrs["city"] = None + elif work_format in { + WorkFormat.OFFICE.name.lower(), + WorkFormat.HYBRID.name.lower(), + }: + if not city or not city.strip(): + raise serializers.ValidationError({"city": self.CITY_REQUIRED_MESSAGE}) + attrs["city"] = city.strip() + + return attrs + + class AbstractVacancyReadOnlyFields(serializers.Serializer): """Общие вычисляемые поля read-only контрактов вакансии.""" @@ -98,6 +122,7 @@ class ProjectVacancyListSerializer( VacancyCreationDateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, + AbstractVacancyEnumFields, RequiredSkillsSerializerMixin[Vacancy], ): class Meta: @@ -113,6 +138,11 @@ class Meta: "datetime_closed", "response_count", "date_create_time", + "required_experience", + "work_schedule", + "work_format", + "salary", + "city", ] @@ -137,6 +167,7 @@ class Meta: class VacancyDetailSerializer( + VacancyCityValidationMixin, VacancyCreationDateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, @@ -165,6 +196,7 @@ class Meta: "work_schedule", "work_format", "salary", + "city", ] read_only_fields = ["project"] @@ -187,6 +219,7 @@ class Meta: "datetime_closed", "response_count", "date_create_time", + "city", ] @@ -220,6 +253,7 @@ class Meta: "work_schedule", "work_format", "salary", + "city", ] read_only_fields = fields @@ -261,6 +295,7 @@ def validate(self, data): class ProjectVacancyCreateListSerializer( + VacancyCityValidationMixin, VacancyCreationDateSerializerMixin, serializers.ModelSerializer, AbstractVacancyReadOnlyFields, @@ -321,6 +356,7 @@ class Meta: "work_schedule", "work_format", "salary", + "city", ] diff --git a/vacancy/tests/helpers.py b/vacancy/tests/helpers.py index c1118bcb..969bc3e8 100644 --- a/vacancy/tests/helpers.py +++ b/vacancy/tests/helpers.py @@ -96,6 +96,7 @@ def create_vacancy( work_schedule: str | None = WorkSchedule.FULL_TIME.name.lower(), work_format: str | None = WorkFormat.REMOTE.name.lower(), salary: int | None = 100000, + city: str | None = None, ) -> Vacancy: vacancy = Vacancy.objects.create( project=project or create_project(), @@ -107,6 +108,7 @@ def create_vacancy( work_schedule=work_schedule, work_format=work_format, salary=salary, + city=city, ) if datetime_created is not None: Vacancy.objects.filter(pk=vacancy.pk).update(datetime_created=datetime_created) diff --git a/vacancy/tests/test_vacancies_api.py b/vacancy/tests/test_vacancies_api.py index c536e93e..058c35d1 100644 --- a/vacancy/tests/test_vacancies_api.py +++ b/vacancy/tests/test_vacancies_api.py @@ -5,6 +5,7 @@ from rest_framework import status from rest_framework.test import APIClient +from vacancy.constants import WorkExperience, WorkFormat, WorkSchedule from vacancy.models import Vacancy from vacancy.tests.helpers import ( create_project, @@ -20,6 +21,17 @@ class VacancyAPITests(TestCase): def setUp(self): self.client = APIClient() + def create_vacancy_as_leader(self, **overrides): + leader = create_user(prefix="city-leader") + project = create_project(leader=leader) + self.client.force_authenticate(leader) + response = self.client.post( + "/vacancies/", + vacancy_payload(project, **overrides), + format="json", + ) + return response, project + def test_project_leader_can_create_vacancy(self): leader = create_user(prefix="leader") project = create_project(leader=leader) @@ -38,6 +50,71 @@ def test_project_leader_can_create_vacancy(self): self.assertEqual(response.data["required_skills"][0]["id"], skill.id) self.assertTrue(Vacancy.objects.get(pk=response.data["id"]).is_active) + def test_create_remote_vacancy_without_city(self): + response, _ = self.create_vacancy_as_leader() + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIsNone(response.data["city"]) + self.assertIsNone(Vacancy.objects.get(pk=response.data["id"]).city) + + def test_create_remote_vacancy_clears_city(self): + response, _ = self.create_vacancy_as_leader(city="Москва") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIsNone(response.data["city"]) + self.assertIsNone(Vacancy.objects.get(pk=response.data["id"]).city) + + def test_create_office_vacancy_requires_city(self): + response, _ = self.create_vacancy_as_leader( + work_format=WorkFormat.OFFICE.value, + city=None, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["city"], + ["Для офисного или смешанного формата укажите город."], + ) + + def test_create_office_vacancy_trims_city(self): + response, _ = self.create_vacancy_as_leader( + work_format=WorkFormat.OFFICE.value, + city=" Москва ", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["city"], "Москва") + self.assertEqual(Vacancy.objects.get(pk=response.data["id"]).city, "Москва") + + def test_create_hybrid_vacancy_requires_city(self): + response, _ = self.create_vacancy_as_leader( + work_format=WorkFormat.HYBRID.value, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("city", response.data) + + def test_create_hybrid_vacancy_returns_city(self): + response, _ = self.create_vacancy_as_leader( + work_format=WorkFormat.HYBRID.value, + city="Казань", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["work_format"], WorkFormat.HYBRID.value) + self.assertEqual(response.data["city"], "Казань") + + def test_create_accepts_legacy_hybrid_work_format(self): + response, _ = self.create_vacancy_as_leader( + work_format="смешанная", + city="Томск", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + vacancy = Vacancy.objects.get(pk=response.data["id"]) + self.assertEqual(vacancy.work_format, WorkFormat.HYBRID.name.lower()) + self.assertEqual(response.data["work_format"], WorkFormat.HYBRID.value) + def test_user_cannot_create_vacancy_for_foreign_project(self): leader = create_user(prefix="leader") outsider = create_user(prefix="outsider") @@ -132,7 +209,7 @@ def test_list_includes_active_vacancies_older_than_90_days(self): ) def test_detail_returns_vacancy_with_project_info(self): - vacancy = create_vacancy(role="Detail vacancy") + vacancy = create_vacancy(role="Detail vacancy", city=None) response = self.client.get(f"/vacancies/{vacancy.id}/") @@ -140,6 +217,152 @@ def test_detail_returns_vacancy_with_project_info(self): self.assertEqual(response.data["id"], vacancy.id) self.assertEqual(response.data["role"], "Detail vacancy") self.assertEqual(response.data["project"]["id"], vacancy.project.id) + self.assertIsNone(response.data["city"]) + + def test_patch_validates_city_against_final_work_format(self): + leader = create_user(prefix="patch-city-leader") + project = create_project(leader=leader) + vacancy = create_vacancy( + project=project, + work_format=WorkFormat.OFFICE.name.lower(), + city="Москва", + ) + self.client.force_authenticate(leader) + + response = self.client.patch( + f"/vacancies/{vacancy.id}/", + {"city": "\t "}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("city", response.data) + vacancy.refresh_from_db() + self.assertEqual(vacancy.city, "Москва") + + def test_patch_rejects_switch_to_hybrid_without_city(self): + leader = create_user(prefix="patch-hybrid-leader") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project, city=None) + self.client.force_authenticate(leader) + + response = self.client.patch( + f"/vacancies/{vacancy.id}/", + {"work_format": WorkFormat.HYBRID.value}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("city", response.data) + vacancy.refresh_from_db() + self.assertEqual(vacancy.work_format, WorkFormat.REMOTE.name.lower()) + + def test_patch_hybrid_uses_existing_city_and_can_update_it(self): + leader = create_user(prefix="patch-existing-city-leader") + project = create_project(leader=leader) + vacancy = create_vacancy( + project=project, + work_format=WorkFormat.OFFICE.name.lower(), + city="Москва", + ) + self.client.force_authenticate(leader) + + switched = self.client.patch( + f"/vacancies/{vacancy.id}/", + {"work_format": WorkFormat.HYBRID.value}, + format="json", + ) + updated = self.client.patch( + f"/vacancies/{vacancy.id}/", + {"city": " Казань "}, + format="json", + ) + + self.assertEqual(switched.status_code, status.HTTP_200_OK) + self.assertEqual(switched.data["city"], "Москва") + self.assertEqual(updated.status_code, status.HTTP_200_OK) + self.assertEqual(updated.data["city"], "Казань") + + def test_put_validates_final_office_city(self): + leader = create_user(prefix="put-city-leader") + project = create_project(leader=leader) + vacancy = create_vacancy( + project=project, + work_format=WorkFormat.OFFICE.name.lower(), + city="Москва", + ) + self.client.force_authenticate(leader) + + response = self.client.put( + f"/vacancies/{vacancy.id}/", + { + "role": vacancy.role, + "specialization": vacancy.specialization, + "description": vacancy.description, + "is_active": True, + "required_experience": WorkExperience.NO_EXPERIENCE.value, + "work_schedule": WorkSchedule.FULL_TIME.value, + "work_format": WorkFormat.OFFICE.value, + "salary": vacancy.salary, + "city": " ", + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("city", response.data) + vacancy.refresh_from_db() + self.assertEqual(vacancy.city, "Москва") + + def test_patch_switch_from_office_to_remote_clears_city(self): + leader = create_user(prefix="patch-remote-leader") + project = create_project(leader=leader) + vacancy = create_vacancy( + project=project, + work_format=WorkFormat.OFFICE.name.lower(), + city="Москва", + ) + self.client.force_authenticate(leader) + + response = self.client.patch( + f"/vacancies/{vacancy.id}/", + {"work_format": WorkFormat.REMOTE.value}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data["city"]) + vacancy.refresh_from_db() + self.assertEqual(vacancy.work_format, WorkFormat.REMOTE.name.lower()) + self.assertIsNone(vacancy.city) + + def test_project_detail_returns_vacancy_editable_metadata(self): + project = create_project() + vacancy = create_vacancy( + project=project, + work_format=WorkFormat.HYBRID.name.lower(), + required_experience=WorkExperience.FROM_ONE_TO_THREE_YEARS.name.lower(), + work_schedule=WorkSchedule.FLEXIBLE_SCHEDULE.name.lower(), + salary=150000, + city="Санкт-Петербург", + ) + + response = self.client.get(f"/projects/{project.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + serialized = next( + item for item in response.data["vacancies"] if item["id"] == vacancy.id + ) + self.assertEqual(serialized["city"], "Санкт-Петербург") + self.assertEqual(serialized["work_format"], WorkFormat.HYBRID.value) + self.assertEqual( + serialized["required_experience"], + WorkExperience.FROM_ONE_TO_THREE_YEARS.value, + ) + self.assertEqual( + serialized["work_schedule"], WorkSchedule.FLEXIBLE_SCHEDULE.value + ) + self.assertEqual(serialized["salary"], 150000) def test_project_leader_can_close_vacancy_and_decline_pending_responses(self): leader = create_user(prefix="leader") diff --git a/vacancy/tests/test_vacancy_contract_api.py b/vacancy/tests/test_vacancy_contract_api.py index 56ff4009..b058b5d3 100644 --- a/vacancy/tests/test_vacancy_contract_api.py +++ b/vacancy/tests/test_vacancy_contract_api.py @@ -87,6 +87,7 @@ def test_filters_and_limit_offset_pagination(self): work_format=WorkFormat.HYBRID.name.lower(), work_schedule=WorkSchedule.PART_TIME.name.lower(), salary=180000, + city="Казань", ) create_vacancy(salary=90000) @@ -106,6 +107,7 @@ def test_filters_and_limit_offset_pagination(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["count"], 1) self.assertEqual(response.data["results"][0]["id"], target.id) + self.assertEqual(response.data["results"][0]["city"], "Казань") def test_old_vacancy_is_visible_and_list_query_count_is_constant(self): old = create_vacancy(