Skip to content
Merged
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
39 changes: 39 additions & 0 deletions docs/vacancy-archive-api.md
Original file line number Diff line number Diff line change
@@ -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 выполняет пользователь.
19 changes: 18 additions & 1 deletion projects/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 с системными обложками, а не с владельцем или доменом.

Expand Down
35 changes: 14 additions & 21 deletions projects/signals.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions projects/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
39 changes: 37 additions & 2 deletions projects/tests/test_project_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
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
project.save()

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())
Expand All @@ -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())

Expand Down Expand Up @@ -72,11 +74,44 @@ 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()
project.save()

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)
66 changes: 66 additions & 0 deletions vacancy/lifecycle.py
Original file line number Diff line number Diff line change
@@ -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": "Вакансию с откликами нельзя удалить. История кандидатов должна сохраниться.",
}
)
3 changes: 2 additions & 1 deletion vacancy/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions vacancy/migrations/0011_vacancy_resume_on_publish.py
Original file line number Diff line number Diff line change
@@ -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="Временно скрыта вместе с черновиком проекта, а не перенесена в архив.",
),
),
]
5 changes: 5 additions & 0 deletions vacancy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
10 changes: 9 additions & 1 deletion vacancy/response_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion vacancy/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
),
)
)

Expand Down
Loading
Loading