From e73f4115c7e83d2e57930dcdf3edbcfda9d31ed2 Mon Sep 17 00:00:00 2001 From: Peter <81633644+TThanos3000@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:08:42 +0300 Subject: [PATCH 1/5] Prod: add vacancy city metadata --- vacancy/constants.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) From cf9da2a0fb99b70d8ea41a048f35b61627680e8b Mon Sep 17 00:00:00 2001 From: Peter <81633644+TThanos3000@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:08:59 +0300 Subject: [PATCH 2/5] Prod: add vacancy city field --- vacancy/models.py | 7 +++++++ 1 file changed, 7 insertions(+) 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, From d4ebbbcaaed75b8414a87aeea8d36203e54171af Mon Sep 17 00:00:00 2001 From: Peter <81633644+TThanos3000@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:09:13 +0300 Subject: [PATCH 3/5] Prod: add vacancy city migration --- vacancy/migrations/0010_vacancy_city.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 vacancy/migrations/0010_vacancy_city.py 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="Город" + ), + ), + ] From 31824766189d9c670ce5429fc7b7d5cdc8a06ab7 Mon Sep 17 00:00:00 2001 From: Peter <81633644+TThanos3000@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:09:43 +0300 Subject: [PATCH 4/5] Prod: expose and validate vacancy city metadata --- vacancy/serializers.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) 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", ] From b1f9c850424efcb1ba59408097b6c35781e91ab6 Mon Sep 17 00:00:00 2001 From: Peter <81633644+TThanos3000@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:10:15 +0300 Subject: [PATCH 5/5] Prod: add vacancy city regression tests --- vacancy/tests/test_vacancy_city_metadata.py | 121 ++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 vacancy/tests/test_vacancy_city_metadata.py diff --git a/vacancy/tests/test_vacancy_city_metadata.py b/vacancy/tests/test_vacancy_city_metadata.py new file mode 100644 index 00000000..e4866414 --- /dev/null +++ b/vacancy/tests/test_vacancy_city_metadata.py @@ -0,0 +1,121 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from vacancy.constants import WorkFormat +from vacancy.models import Vacancy +from vacancy.serializers import ProjectVacancyListSerializer, VacancyCatalogSerializer +from vacancy.tests.helpers import create_project, create_user, vacancy_payload + + +class VacancyCityMetadataTests(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, leader + + def test_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_office_vacancy_requires_city(self): + response, _, _ = self.create_vacancy_as_leader( + work_format=WorkFormat.OFFICE.value, + city=" ", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["city"], + ["Для офисного или смешанного формата укажите город."], + ) + + def test_office_vacancy_trims_and_returns_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_hybrid_vacancy_requires_city(self): + response, _, _ = self.create_vacancy_as_leader( + work_format=WorkFormat.HYBRID.value, + city=None, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("city", response.data) + + def test_legacy_hybrid_is_normalized(self): + response, _, _ = self.create_vacancy_as_leader( + work_format="смешанная", + 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_patch_validates_final_office_state(self): + response, _, leader = self.create_vacancy_as_leader() + vacancy_id = response.data["id"] + self.client.force_authenticate(leader) + + patch_response = self.client.patch( + f"/vacancies/{vacancy_id}/", + {"work_format": WorkFormat.OFFICE.value}, + format="json", + ) + + self.assertEqual(patch_response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("city", patch_response.data) + + def test_switch_to_remote_clears_existing_city(self): + response, _, leader = self.create_vacancy_as_leader( + work_format=WorkFormat.OFFICE.value, + city="Томск", + ) + vacancy_id = response.data["id"] + self.client.force_authenticate(leader) + + patch_response = self.client.patch( + f"/vacancies/{vacancy_id}/", + {"work_format": WorkFormat.REMOTE.value}, + format="json", + ) + + self.assertEqual(patch_response.status_code, status.HTTP_200_OK) + self.assertIsNone(patch_response.data["city"]) + self.assertIsNone(Vacancy.objects.get(pk=vacancy_id).city) + + def test_project_and_catalog_serializers_expose_city_metadata(self): + response, _, _ = self.create_vacancy_as_leader( + work_format=WorkFormat.HYBRID.value, + city="Самара", + ) + vacancy = Vacancy.objects.get(pk=response.data["id"]) + + project_data = ProjectVacancyListSerializer(vacancy).data + catalog_data = VacancyCatalogSerializer(vacancy).data + + for data in (project_data, catalog_data): + self.assertEqual(data["city"], "Самара") + self.assertEqual(data["work_format"], WorkFormat.HYBRID.value) + self.assertIn("required_experience", data) + self.assertIn("work_schedule", data) + self.assertIn("salary", data)