diff --git a/docs/vacancy-archive-api.md b/docs/vacancy-archive-api.md new file mode 100644 index 00000000..259f641c --- /dev/null +++ b/docs/vacancy-archive-api.md @@ -0,0 +1,39 @@ +# Архив вакансий проекта — API + +База: dev 1c424ff. Дизайн и ручные действия согласованы 21.10.2026. + +- PATCH /vacancies/{id}/: is_active=false закрывает набор, отклоняет ожидающие отклики и ставит email-уведомления после commit. Повторное закрытие не меняет дату и не дублирует письма. +- PATCH с is_active=true: возобновление требует опубликованного публичного проекта, не меняет прежние решения. +- DELETE /vacancies/{id}/: наличие любого отклика запрещает удаление, включая архив. Ответ 400 содержит code=vacancy_has_responses и detail. +- Поле total_response_count readonly и включает всю историю. response_count продолжает считать ожидающие решения отклики. +- Руководитель и администратор проверяются после блокировок Project → Vacancy. Порядок совместим с операциями над откликами. +- В публичном представлении проекта закрытые вакансии скрыты; менеджер видит обе группы. +- PATCH/PUT валидируются до изменений. Invalid skills не удаляют прежние навыки; PUT без is_active не отклоняет отклики. +- Архив остается закрытым при повторной публикации проекта до ручного возврата. Дата закрытия и история не меняются. Принятие кандидата также отменяет автоматическое возобновление. +- Только новые вакансии черновика и активные вакансии, временно скрытые при переводе проекта в черновик, возобновляются вместе с проектом. Создание блокирует Project, чтобы не пропустить одновременную публикацию. + +Новых endpoint нет. API с миграцией `vacancy.0011_vacancy_resume_on_publish` нужно внедрять до связанного Angular PR. +Внутренний `resume_on_publish` не входит в API. Значение по умолчанию false сохраняет все старые закрытые записи +в архиве, включая закрытые вакансии старых черновиков: причина их закрытия ранее не сохранялась, +поэтому автоматический возврат для них не предполагается. Статусы и даты миграция не переписывает. +После публикации руководитель может вернуть нужные старые вакансии вручную. + +## Проверено + +PostgreSQL 18.1, Python 3.11.15 / Django 4.2.11, отдельная локальная test database. +`manage.py test vacancy.tests projects.tests.test_project_signals projects.tests.test_program_project_detail_access --noinput --keepdb` — 119 tests passed. +`manage.py check --tag models`, `manage.py makemigrations --check --dry-run`, Black измененных файлов — exit 0. +Тесты покрывают историю, pending-only notifications, повтор, возврат, permissions, прямой DELETE, +атомарность, публичное представление, совместимость счетчиков, повторную публикацию, создание в черновике, +принятие кандидата во временно скрытую вакансию и upgrade миграции на существующих записях. +Регрессионные проверки количества SQL-запросов проходят. + +Ревью проверило блокировки и повторную проверку владельца, сохранение истории и отказ прямому API, +совместимость payload и readonly fields, callback после commit и сохранение старых статусов/дат в миграции. +Реальная доставка email и deployed dev не проверены. Полный suite запускает CI PR. + +## Решение и внедрение + +Пользователь подтвердил 21.10.2026: «Оставлять архивные вакансии в архиве до ручного возврата». +Правило реализовано на сервере и проверено API и signal regression tests. +Порядок внедрения: API + миграция → Angular PR #475 → ручная acceptance на dev. Merge и deploy выполняет пользователь. diff --git a/projects/serializers.py b/projects/serializers.py index e63a4b69..ac4e80e5 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -2,6 +2,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction +from django.db.models import Count, Q from rest_framework import serializers from core.serializers import SkillToObjectSerializer @@ -265,7 +266,7 @@ class ProjectDetailSerializer(TeamPolicySerializerMixin, serializers.ModelSerial collaborators = CollaboratorSerializer( source="collaborator_set", many=True, read_only=True ) - vacancies = ProjectVacancyListSerializer(many=True, read_only=True) + vacancies = serializers.SerializerMethodField() goals = ProjectGoalSerializer(many=True, read_only=True) partners = ProjectCompanySerializer( source="project_companies", many=True, read_only=True @@ -285,6 +286,22 @@ class ProjectDetailSerializer(TeamPolicySerializerMixin, serializers.ModelSerial implementation_deadline = serializers.DateField(required=False, allow_null=True) trl = serializers.IntegerField(required=False, allow_null=True) + def get_vacancies(self, project): + from vacancy.selectors import can_manage_project + + request = self.context.get("request") + vacancies = project.vacancies.annotate( + all_response_count=Count("vacancy_requests"), + pending_response_count=Count( + "vacancy_requests", filter=Q(vacancy_requests__is_approved__isnull=True) + ), + ).prefetch_related("required_skills__skill__category") + if not can_manage_project(getattr(request, "user", None), project): + vacancies = [vacancy for vacancy in vacancies if vacancy.is_active] + return ProjectVacancyListSerializer( + vacancies, many=True, context=self.context + ).data + def get_is_default_cover(self, project): """Сверяет URL с системными обложками, а не с владельцем или доменом. diff --git a/projects/signals.py b/projects/signals.py index 0f737b83..50040c0f 100644 --- a/projects/signals.py +++ b/projects/signals.py @@ -1,4 +1,3 @@ -from django.db.models import Q from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.utils import timezone @@ -47,29 +46,23 @@ def update_vacancy(sender, instance, created, raw=False, **kwargs): if raw or created or previous_draft is None or previous_draft == instance.draft: return - vacancies = list(Vacancy.objects.filter(project=instance)) target_is_active = not instance.draft - changed_vacancies = [ - vacancy for vacancy in vacancies if vacancy.is_active != target_is_active - ] - now = timezone.now() - + # Only vacancies suspended by the project lifecycle can resume automatically. + # Inactive legacy rows and explicitly archived vacancies keep their history. + queryset = Vacancy.objects.filter(project=instance) if target_is_active: - Vacancy.objects.filter(project=instance).filter( - Q(is_active=False) | Q(datetime_closed__isnull=False) - ).update( - is_active=True, - datetime_closed=None, - datetime_updated=now, - ) + queryset = queryset.filter(is_active=False, resume_on_publish=True) else: - Vacancy.objects.filter(project=instance).filter( - Q(is_active=True) | Q(datetime_closed__isnull=True) - ).update( - is_active=False, - datetime_closed=now, - datetime_updated=now, - ) + queryset = queryset.filter(is_active=True) + changed_vacancies = list(queryset) + now = timezone.now() + + queryset.update( + is_active=target_is_active, + resume_on_publish=not target_is_active, + datetime_closed=None if target_is_active else now, + datetime_updated=now, + ) for vacancy in changed_vacancies: vacancy.is_active = target_is_active diff --git a/projects/tests/helpers.py b/projects/tests/helpers.py index 8bf3eaac..891b3b5b 100644 --- a/projects/tests/helpers.py +++ b/projects/tests/helpers.py @@ -238,10 +238,12 @@ def create_vacancy( *, role: str = "Backend developer", is_active: bool = True, + resume_on_publish: bool = False, ) -> Vacancy: return Vacancy.objects.create( project=project, role=f"{role} {unique_suffix()}", description="Vacancy description", is_active=is_active, + resume_on_publish=resume_on_publish, ) diff --git a/projects/tests/test_project_signals.py b/projects/tests/test_project_signals.py index 6fd6f7d6..9b0c15d2 100644 --- a/projects/tests/test_project_signals.py +++ b/projects/tests/test_project_signals.py @@ -11,7 +11,7 @@ class ProjectDraftSignalRegressionTests(TestCase): def test_publish_project_activates_vacancies_creates_feed_news_and_chat(self): project = create_project(draft=True) - vacancy = create_vacancy(project, is_active=False) + vacancy = create_vacancy(project, is_active=False, resume_on_publish=True) self.assertIsNotNone(vacancy.datetime_closed) project.draft = False @@ -19,6 +19,7 @@ def test_publish_project_activates_vacancies_creates_feed_news_and_chat(self): vacancy.refresh_from_db() self.assertTrue(vacancy.is_active) + self.assertFalse(vacancy.resume_on_publish) self.assertIsNone(vacancy.datetime_closed) self.assertTrue(News.objects.get_news(vacancy).filter(text="").exists()) self.assertTrue(ProjectChat.objects.filter(project=project).exists()) @@ -34,6 +35,7 @@ def test_return_project_to_draft_deactivates_vacancies_and_removes_feed_news(sel vacancy.refresh_from_db() self.assertFalse(vacancy.is_active) + self.assertTrue(vacancy.resume_on_publish) self.assertIsNotNone(vacancy.datetime_closed) self.assertFalse(News.objects.get_news(vacancy).filter(text="").exists()) @@ -72,7 +74,7 @@ def test_save_published_project_does_not_reopen_closed_vacancy(self): def test_repeated_publish_does_not_duplicate_feed_news_or_chat(self): project = create_project(draft=True) - vacancy = create_vacancy(project, is_active=False) + vacancy = create_vacancy(project, is_active=False, resume_on_publish=True) project.draft = False project.save() @@ -80,3 +82,36 @@ def test_repeated_publish_does_not_duplicate_feed_news_or_chat(self): self.assertEqual(News.objects.get_news(vacancy).filter(text="").count(), 1) self.assertEqual(ProjectChat.objects.filter(project=project).count(), 1) + + def test_republish_resumes_suspended_vacancy_but_preserves_archive(self): + project = create_project(draft=False) + active = create_vacancy(project) + archived = create_vacancy(project, is_active=False) + closed_at = archived.datetime_closed + updated_at = archived.datetime_updated + + project.draft = True + project.save() + project.draft = False + project.save() + + active.refresh_from_db() + archived.refresh_from_db() + self.assertTrue(active.is_active) + self.assertIsNone(active.datetime_closed) + self.assertFalse(archived.is_active) + self.assertEqual(archived.datetime_closed, closed_at) + self.assertEqual(archived.datetime_updated, updated_at) + self.assertFalse(News.objects.get_news(archived).exists()) + + def test_publish_does_not_reopen_legacy_inactive_vacancy(self): + project = create_project(draft=True) + vacancy = create_vacancy(project, is_active=False) + closed_at = vacancy.datetime_closed + + project.draft = False + project.save() + + vacancy.refresh_from_db() + self.assertFalse(vacancy.is_active) + self.assertEqual(vacancy.datetime_closed, closed_at) diff --git a/vacancy/lifecycle.py b/vacancy/lifecycle.py new file mode 100644 index 00000000..c2a65fb3 --- /dev/null +++ b/vacancy/lifecycle.py @@ -0,0 +1,66 @@ +"""Архив вакансий: сохранение истории и единый порядок блокировок.""" + +from django.utils import timezone +from rest_framework import serializers +from rest_framework.exceptions import NotFound, PermissionDenied + +from projects.models import Project +from vacancy.mapping import MessageTypeEnum +from vacancy.models import Vacancy, VacancyResponse +from vacancy.response_services import _email_payload +from vacancy.selectors import can_manage_vacancy +from vacancy.tasks import send_email +from django.db import transaction + + +def lock_managed_vacancy(vacancy_id, actor): + """Вызывать внутри atomic; порядок Project → Vacancy как у откликов.""" + try: + project_id = Vacancy.objects.only("project_id").get(pk=vacancy_id).project_id + project = Project.objects.select_for_update().get(pk=project_id) + vacancy = Vacancy.objects.select_for_update().get(pk=vacancy_id) + except (Vacancy.DoesNotExist, Project.DoesNotExist) as error: + raise NotFound() from error + vacancy.project = project + if not can_manage_vacancy(actor, vacancy): + raise PermissionDenied() + return vacancy + + +def validate_reopening(vacancy, is_active): + if ( + is_active + and not vacancy.is_active + and (vacancy.project.draft or not vacancy.project.is_public) + ): + raise serializers.ValidationError( + { + "is_active": "Возобновить набор можно только в опубликованном публичном проекте." + } + ) + + +def resolve_pending_responses(vacancy): + """Вызывать после закрытия под блокировкой вакансии; решения не переписываются.""" + pending = list( + VacancyResponse.objects.select_for_update().filter( + vacancy=vacancy, is_approved__isnull=True + ) + ) + VacancyResponse.objects.filter(pk__in=[response.pk for response in pending]).update( + is_approved=False, datetime_updated=timezone.now() + ) + for response in pending: + response.vacancy = vacancy + payload = _email_payload(response, MessageTypeEnum.REJECTED.value) + transaction.on_commit(lambda payload=payload: send_email.delay(payload)) + + +def ensure_no_responses(vacancy): + if VacancyResponse.objects.filter(vacancy=vacancy).exists(): + raise serializers.ValidationError( + { + "code": "vacancy_has_responses", + "detail": "Вакансию с откликами нельзя удалить. История кандидатов должна сохраниться.", + } + ) diff --git a/vacancy/managers.py b/vacancy/managers.py index da6b3805..dca6ed2b 100644 --- a/vacancy/managers.py +++ b/vacancy/managers.py @@ -22,10 +22,11 @@ def get_vacancy_for_list_view(self): ) ) .annotate( + all_response_count=Count("vacancy_requests"), pending_response_count=Count( "vacancy_requests", filter=Q(vacancy_requests__is_approved__isnull=True), - ) + ), ) .filter(datetime_created__gte=expiration_check) .only( diff --git a/vacancy/migrations/0011_vacancy_resume_on_publish.py b/vacancy/migrations/0011_vacancy_resume_on_publish.py new file mode 100644 index 00000000..e1dfb650 --- /dev/null +++ b/vacancy/migrations/0011_vacancy_resume_on_publish.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("vacancy", "0010_vacancy_city")] + + operations = [ + migrations.AddField( + model_name="vacancy", + name="resume_on_publish", + field=models.BooleanField( + default=False, + editable=False, + help_text="Временно скрыта вместе с черновиком проекта, а не перенесена в архив.", + ), + ), + ] diff --git a/vacancy/models.py b/vacancy/models.py index ba44b77f..902dff54 100644 --- a/vacancy/models.py +++ b/vacancy/models.py @@ -92,6 +92,11 @@ class Vacancy(models.Model): verbose_name="Дата закрытия", ) is_active = models.BooleanField(blank=False, default=True) + resume_on_publish = models.BooleanField( + default=False, + editable=False, + help_text="Временно скрыта вместе с черновиком проекта, а не перенесена в архив.", + ) objects = VacancyManager() diff --git a/vacancy/response_services.py b/vacancy/response_services.py index 31b19056..bf82d2b6 100644 --- a/vacancy/response_services.py +++ b/vacancy/response_services.py @@ -138,7 +138,15 @@ def accept_vacancy_response( response.is_approved = True response.save(update_fields=("is_approved", "datetime_updated")) vacancy.is_active = False - vacancy.save(update_fields=("is_active", "datetime_closed", "datetime_updated")) + vacancy.resume_on_publish = False + vacancy.save( + update_fields=( + "is_active", + "resume_on_publish", + "datetime_closed", + "datetime_updated", + ) + ) rejected = list( VacancyResponse.objects.select_for_update() diff --git a/vacancy/selectors.py b/vacancy/selectors.py index 899b5c8d..717cca8e 100644 --- a/vacancy/selectors.py +++ b/vacancy/selectors.py @@ -20,10 +20,11 @@ def _response_vacancies_queryset() -> QuerySet[Vacancy]: "project__links", ) .annotate( + all_response_count=Count("vacancy_requests"), pending_response_count=Count( "vacancy_requests", filter=Q(vacancy_requests__is_approved__isnull=True), - ) + ), ) ) diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 26efbe99..0381d4d2 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -1,5 +1,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.db.models import Count, Q from django.utils import timezone from rest_framework import serializers @@ -94,12 +96,26 @@ class AbstractVacancyReadOnlyFields(serializers.Serializer): datetime_closed = serializers.DateTimeField(read_only=True) response_count = serializers.SerializerMethodField(read_only=True) + total_response_count = serializers.SerializerMethodField(read_only=True) + + def get_total_response_count(self, obj): + """Полная история; response_count сохраняет прежний смысл ожидающих откликов.""" + if hasattr(obj, "all_response_count"): + return obj.all_response_count + if hasattr(obj, "_response_history_counts"): + return obj._response_history_counts["total"] + return obj.vacancy_requests.count() def get_response_count(self, obj): """Возвращает число откликов, которые ещё ожидают решения.""" if hasattr(obj, "pending_response_count"): return obj.pending_response_count - return obj.vacancy_requests.filter(is_approved=None).count() + counts = obj.vacancy_requests.aggregate( + pending=Count("pk", filter=Q(is_approved__isnull=True)), + total=Count("pk"), + ) + obj._response_history_counts = counts + return counts["pending"] def _format_datetime_with_seconds(value): @@ -196,6 +212,7 @@ class Meta: "is_active", "datetime_closed", "response_count", + "total_response_count", "date_create_time", "required_experience", "work_schedule", @@ -251,6 +268,7 @@ class Meta: "datetime_updated", "datetime_closed", "response_count", + "total_response_count", "date_create_time", "required_experience", "work_schedule", @@ -288,6 +306,7 @@ class Meta: "is_active", "datetime_closed", "response_count", + "total_response_count", "date_create_time", "city", ] @@ -341,17 +360,17 @@ class ProjectVacancyCreateListSerializer( AbstractVacancyEnumFields, RequiredSkillsWriteSerializerMixin[Vacancy], ): + @transaction.atomic def create(self, validated_data): - project = validated_data["project"] + project = Project.objects.select_for_update().get(pk=validated_data["project"].pk) + validated_data["project"] = project if project.leader != self.context["request"].user: raise serializers.ValidationError("You are not the leader of the project") required_skills_ids = validated_data.pop("required_skills_ids") - if validated_data["project"].draft: - validated_data["is_active"] = False - else: - validated_data["is_active"] = True + validated_data["is_active"] = not project.draft + validated_data["resume_on_publish"] = project.draft vacancy = Vacancy.objects.create(**validated_data) @@ -389,6 +408,7 @@ class Meta: "is_active", "datetime_closed", "response_count", + "total_response_count", "date_create_time", "datetime_created", "datetime_updated", @@ -592,6 +612,7 @@ class Meta: "datetime_updated", "datetime_closed", "response_count", + "total_response_count", "date_create_time", "required_experience", "work_schedule", diff --git a/vacancy/services.py b/vacancy/services.py index 9fbe6391..89a8c94f 100644 --- a/vacancy/services.py +++ b/vacancy/services.py @@ -1,16 +1,14 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import status +from rest_framework.response import Response from core.models import Skill, SkillToObject -from rest_framework.response import Response from vacancy.models import Vacancy def update_vacancy_skills(request, vacancy: Vacancy): if "required_skills_ids" in request.data and request.data.get("required_skills_ids"): - vacancy.required_skills.all().delete() - needed_skills: list[int] = Skill.objects.filter( id__in=request.data["required_skills_ids"] ).values_list("id", flat=True) @@ -22,6 +20,7 @@ def update_vacancy_skills(request, vacancy: Vacancy): status=status.HTTP_400_BAD_REQUEST, ) + vacancy.required_skills.all().delete() vacancy_content_type = ContentType.objects.get_for_model(Vacancy) to_create = [ SkillToObject( diff --git a/vacancy/tests/test_archive_migration_0011.py b/vacancy/tests/test_archive_migration_0011.py new file mode 100644 index 00000000..a1d8f70d --- /dev/null +++ b/vacancy/tests/test_archive_migration_0011.py @@ -0,0 +1,42 @@ +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase +from django.utils import timezone + +from vacancy.tests.helpers import create_project + + +class VacancyArchiveMigrationTests(TransactionTestCase): + def test_existing_inactive_vacancies_remain_archived_without_changing_history(self): + executor = MigrationExecutor(connection) + latest = executor.loader.graph.leaf_nodes() + before = [("vacancy", "0010_vacancy_city")] + after = [("vacancy", "0011_vacancy_resume_on_publish")] + try: + executor.migrate(before) + old_apps = executor.loader.project_state(before).apps + OldVacancy = old_apps.get_model("vacancy", "Vacancy") + closed_at = timezone.now() + records = [] + for draft, active in [(False, True), (False, False), (True, False)]: + project = create_project(draft=draft) + vacancy = OldVacancy.objects.create( + project_id=project.pk, + role="Существующая вакансия", + is_active=active, + datetime_closed=None if active else closed_at, + ) + records.append((vacancy.pk, active, vacancy.datetime_closed)) + + executor = MigrationExecutor(connection) + executor.migrate(after) + NewVacancy = executor.loader.project_state(after).apps.get_model( + "vacancy", "Vacancy" + ) + for pk, active, date in records: + vacancy = NewVacancy.objects.get(pk=pk) + self.assertEqual(vacancy.is_active, active) + self.assertEqual(vacancy.datetime_closed, date) + self.assertFalse(vacancy.resume_on_publish) + finally: + MigrationExecutor(connection).migrate(latest) diff --git a/vacancy/tests/test_vacancy_archive_api.py b/vacancy/tests/test_vacancy_archive_api.py new file mode 100644 index 00000000..44f8bad8 --- /dev/null +++ b/vacancy/tests/test_vacancy_archive_api.py @@ -0,0 +1,241 @@ +from unittest.mock import patch +from types import SimpleNamespace + +from django.test import TestCase +from rest_framework.test import APIClient + +from vacancy.models import Vacancy, VacancyResponse +from vacancy.serializers import ProjectVacancyListSerializer +from projects.serializers import ProjectDetailSerializer +from vacancy.tests.helpers import ( + create_project, + create_user, + create_vacancy, + create_vacancy_response, + vacancy_payload, +) + + +class VacancyArchiveAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.project = create_project() + self.vacancy = create_vacancy(project=self.project) + self.url = f"/vacancies/{self.vacancy.pk}/" + self.client.force_authenticate(self.project.leader) + + @patch("vacancy.lifecycle.send_email.delay") + def test_archive_preserves_history_and_notifies_pending_once(self, email): + pending = create_vacancy_response(vacancy=self.vacancy) + accepted = create_vacancy_response(vacancy=self.vacancy, is_approved=True) + rejected = create_vacancy_response(vacancy=self.vacancy, is_approved=False) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.patch(self.url, {"is_active": False}, format="json") + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["is_active"]) + self.assertEqual(response.data["total_response_count"], 3) + self.assertEqual(response.data["response_count"], 0) + self.assertIsNotNone(response.data["datetime_closed"]) + for item in [pending, accepted, rejected]: + item.refresh_from_db() + self.assertFalse(pending.is_approved) + self.assertTrue(accepted.is_approved) + self.assertFalse(rejected.is_approved) + email.assert_called_once() + with self.captureOnCommitCallbacks(execute=True): + again = self.client.patch(self.url, {"is_active": False}, format="json") + self.assertEqual(again.status_code, 200) + self.assertEqual(again.data["datetime_closed"], response.data["datetime_closed"]) + email.assert_called_once() + + def test_reopen_preserves_decisions_and_clears_closed_date(self): + self.vacancy.is_active = False + self.vacancy.save() + response = create_vacancy_response(vacancy=self.vacancy, is_approved=False) + result = self.client.patch(self.url, {"is_active": True}, format="json") + self.assertEqual(result.status_code, 200) + self.assertTrue(result.data["is_active"]) + self.assertIsNone(result.data["datetime_closed"]) + response.refresh_from_db() + self.assertFalse(response.is_approved) + + def test_reopen_requires_public_published_project(self): + self.vacancy.is_active = False + self.vacancy.save() + for draft, public in [(True, True), (False, False)]: + self.project.draft = draft + self.project.is_public = public + self.project.save() + response = self.client.patch(self.url, {"is_active": True}, format="json") + self.assertEqual(response.status_code, 400) + self.vacancy.refresh_from_db() + self.assertFalse(self.vacancy.is_active) + + def test_delete_blocks_all_response_states_in_both_lists(self): + for active in [True, False]: + self.vacancy.is_active = active + self.vacancy.save() + for decision in [None, True, False]: + reply = create_vacancy_response( + vacancy=self.vacancy, is_approved=decision + ) + response = self.client.delete(self.url) + self.assertEqual(response.status_code, 400) + self.assertEqual(str(response.data["code"]), "vacancy_has_responses") + self.assertTrue(Vacancy.objects.filter(pk=self.vacancy.pk).exists()) + self.assertTrue(VacancyResponse.objects.filter(pk=reply.pk).exists()) + reply.delete() + + def test_delete_empty_vacancy(self): + self.assertEqual(self.client.delete(self.url).status_code, 204) + self.assertFalse(Vacancy.objects.filter(pk=self.vacancy.pk).exists()) + + def test_invalid_archive_is_atomic(self): + reply = create_vacancy_response(vacancy=self.vacancy) + response = self.client.patch( + self.url, {"is_active": False, "role": ""}, format="json" + ) + self.assertEqual(response.status_code, 400) + self.vacancy.refresh_from_db() + reply.refresh_from_db() + self.assertTrue(self.vacancy.is_active) + self.assertIsNone(reply.is_approved) + + def test_edit_without_lifecycle_field_keeps_pending_responses(self): + reply = create_vacancy_response(vacancy=self.vacancy) + response = self.client.put(self.url, vacancy_payload(self.project), format="json") + self.assertEqual(response.status_code, 200) + reply.refresh_from_db() + self.assertIsNone(reply.is_approved) + + def test_foreign_user_cannot_archive_reopen_or_delete(self): + self.client.force_authenticate(create_user()) + for method, payload in [ + ("patch", {"is_active": False}), + ("patch", {"is_active": True}), + ("delete", None), + ]: + response = getattr(self.client, method)(self.url, payload, format="json") + self.assertEqual(response.status_code, 403) + self.vacancy.refresh_from_db() + self.assertTrue(self.vacancy.is_active) + + def test_pending_count_remains_compatible_with_total_history(self): + create_vacancy_response(vacancy=self.vacancy) + create_vacancy_response(vacancy=self.vacancy, is_approved=False) + data = ProjectVacancyListSerializer(self.vacancy).data + self.assertEqual(data["response_count"], 1) + self.assertEqual(data["total_response_count"], 2) + + def test_embedded_archive_visible_only_to_managers(self): + archived = create_vacancy(project=self.project, is_active=False) + for user, expected in [ + (self.project.leader, {self.vacancy.pk, archived.pk}), + (create_user(), {self.vacancy.pk}), + (None, {self.vacancy.pk}), + ]: + serializer = ProjectDetailSerializer( + context={"request": SimpleNamespace(user=user)} + ) + data = serializer.get_vacancies(self.project) + self.assertEqual({item["id"] for item in data}, expected) + + def test_invalid_skills_do_not_destroy_existing_skills_or_close_vacancy(self): + from vacancy.tests.helpers import create_skill + + skill = create_skill() + self.vacancy.required_skills.create(skill=skill) + response = self.client.patch( + self.url, + {"is_active": False, "required_skills_ids": [999999]}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.vacancy.refresh_from_db() + self.assertTrue(self.vacancy.is_active) + self.assertEqual(self.vacancy.required_skills.get().skill_id, skill.pk) + + def test_archive_survives_republish_until_manual_restore(self): + rejected = create_vacancy_response(vacancy=self.vacancy, is_approved=False) + archived = self.client.patch(self.url, {"is_active": False}, format="json") + self.assertEqual(archived.status_code, 200) + self.project.draft = True + self.project.save() + self.project.draft = False + self.project.save() + + self.vacancy.refresh_from_db() + self.assertFalse(self.vacancy.is_active) + self.assertFalse(self.vacancy.resume_on_publish) + self.assertEqual( + self.client.get(self.url).data["datetime_closed"], + archived.data["datetime_closed"], + ) + restored = self.client.patch(self.url, {"is_active": True}, format="json") + self.assertEqual(restored.status_code, 200) + self.assertTrue(restored.data["is_active"]) + rejected.refresh_from_db() + self.assertFalse(rejected.is_approved) + + @patch("vacancy.lifecycle.send_email.delay") + def test_explicit_archive_of_suspended_vacancy_cancels_automatic_resume(self, email): + pending = create_vacancy_response(vacancy=self.vacancy) + self.project.draft = True + self.project.save() + with self.captureOnCommitCallbacks(execute=True): + archived = self.client.patch(self.url, {"is_active": False}, format="json") + self.assertEqual(archived.status_code, 200) + pending.refresh_from_db() + self.assertFalse(pending.is_approved) + email.assert_called_once() + + self.project.draft = False + self.project.save() + self.vacancy.refresh_from_db() + self.assertFalse(self.vacancy.is_active) + self.assertFalse(self.vacancy.resume_on_publish) + + def test_new_draft_vacancy_publishes_with_project(self): + self.project.draft = True + self.project.save() + result = self.client.post( + "/vacancies/", vacancy_payload(self.project), format="json" + ) + self.assertEqual(result.status_code, 201) + vacancy = Vacancy.objects.get(pk=result.data["id"]) + self.assertFalse(vacancy.is_active) + self.assertTrue(vacancy.resume_on_publish) + + self.project.draft = False + self.project.save() + vacancy.refresh_from_db() + self.assertTrue(vacancy.is_active) + self.assertFalse(vacancy.resume_on_publish) + self.assertIsNone(vacancy.datetime_closed) + + def test_accepting_response_in_suspended_project_keeps_vacancy_archived(self): + from vacancy.response_services import accept_vacancy_response + + pending = create_vacancy_response(vacancy=self.vacancy) + self.project.draft = True + self.project.save() + accept_vacancy_response(pending.pk, actor=self.project.leader) + self.project.draft = False + self.project.save() + + self.vacancy.refresh_from_db() + pending.refresh_from_db() + self.assertFalse(self.vacancy.is_active) + self.assertFalse(self.vacancy.resume_on_publish) + self.assertTrue(pending.is_approved) + + def test_editing_suspended_vacancy_does_not_cancel_project_publication(self): + self.project.draft = True + self.project.save() + response = self.client.patch(self.url, {"role": "Новая роль"}, format="json") + self.assertEqual(response.status_code, 200) + self.project.draft = False + self.project.save() + self.vacancy.refresh_from_db() + self.assertTrue(self.vacancy.is_active) + self.assertEqual(self.vacancy.role, "Новая роль") diff --git a/vacancy/views.py b/vacancy/views.py index 737a32bf..69dded1e 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -34,6 +34,12 @@ with_applicant_state, ) from vacancy.services import update_vacancy_skills +from vacancy.lifecycle import ( + ensure_no_responses, + lock_managed_vacancy, + resolve_pending_responses, + validate_reopening, +) @swagger_auto_schema( @@ -85,23 +91,37 @@ class VacancyDetail(generics.RetrieveUpdateDestroyAPIView): def get_queryset(self): return with_applicant_state(super().get_queryset(), self.request.user) - def patch(self, request, *args, **kwargs): - update_vacancy_skills(request, self.get_object()) - return self.partial_update(request, *args, **kwargs) - - def put(self, request, *args, **kwargs): - """updating the vacancy""" - vacancy = self.get_object() - - if not request.data.get("is_active"): - # automatically declining every vacancy response if the vacancy is not active - VacancyResponse.objects.filter(vacancy=vacancy, is_approved=None).update( - is_approved=False - ) - - update_vacancy_skills(request, vacancy) + def perform_update(self, serializer): + if "is_active" in serializer.validated_data: + serializer.save(resume_on_publish=False) + else: + serializer.save() - return self.update(request, *args, **kwargs) + @transaction.atomic + def update(self, request, *args, **kwargs): + visible = self.get_object() + vacancy = lock_managed_vacancy(visible.pk, request.user) + serializer = self.get_serializer( + vacancy, data=request.data, partial=kwargs.pop("partial", False) + ) + serializer.is_valid(raise_exception=True) + target_active = serializer.validated_data.get("is_active", vacancy.is_active) + validate_reopening(vacancy, target_active) + skills_error = update_vacancy_skills(request, vacancy) + if skills_error is not None: + raise serializers.ValidationError(skills_error.data) + self.perform_update(serializer) + if "is_active" in serializer.validated_data and not target_active: + resolve_pending_responses(vacancy) + return Response(self.get_serializer(self.get_object()).data) + + @transaction.atomic + def destroy(self, request, *args, **kwargs): + visible = self.get_object() + vacancy = lock_managed_vacancy(visible.pk, request.user) + ensure_no_responses(vacancy) + vacancy.delete() + return Response(status=status.HTTP_204_NO_CONTENT) class VacancyResponseList(mixins.ListModelMixin, mixins.CreateModelMixin, GenericAPIView):