Skip to content
Closed
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
8 changes: 7 additions & 1 deletion vacancy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@


class ChoicesMixin:

@classmethod
def choices(cls):
"""Return a list of tuples (value, display_name) for choices."""
Expand Down Expand Up @@ -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)
20 changes: 20 additions & 0 deletions vacancy/migrations/0010_vacancy_city.py
Original file line number Diff line number Diff line change
@@ -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="Город"
),
),
]
7 changes: 7 additions & 0 deletions vacancy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions vacancy/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 контрактов вакансии."""

Expand Down Expand Up @@ -98,6 +122,7 @@ class ProjectVacancyListSerializer(
VacancyCreationDateSerializerMixin,
serializers.ModelSerializer,
AbstractVacancyReadOnlyFields,
AbstractVacancyEnumFields,
RequiredSkillsSerializerMixin[Vacancy],
):
class Meta:
Expand All @@ -113,6 +138,11 @@ class Meta:
"datetime_closed",
"response_count",
"date_create_time",
"required_experience",
"work_schedule",
"work_format",
"salary",
"city",
]


Expand All @@ -137,6 +167,7 @@ class Meta:


class VacancyDetailSerializer(
VacancyCityValidationMixin,
VacancyCreationDateSerializerMixin,
serializers.ModelSerializer,
AbstractVacancyReadOnlyFields,
Expand Down Expand Up @@ -165,6 +196,7 @@ class Meta:
"work_schedule",
"work_format",
"salary",
"city",
]
read_only_fields = ["project"]

Expand All @@ -187,6 +219,7 @@ class Meta:
"datetime_closed",
"response_count",
"date_create_time",
"city",
]


Expand Down Expand Up @@ -220,6 +253,7 @@ class Meta:
"work_schedule",
"work_format",
"salary",
"city",
]
read_only_fields = fields

Expand Down Expand Up @@ -261,6 +295,7 @@ def validate(self, data):


class ProjectVacancyCreateListSerializer(
VacancyCityValidationMixin,
VacancyCreationDateSerializerMixin,
serializers.ModelSerializer,
AbstractVacancyReadOnlyFields,
Expand Down Expand Up @@ -321,6 +356,7 @@ class Meta:
"work_schedule",
"work_format",
"salary",
"city",
]


Expand Down
121 changes: 121 additions & 0 deletions vacancy/tests/test_vacancy_city_metadata.py
Original file line number Diff line number Diff line change
@@ -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)