From a2ae6806ad8f3ec44d2012b5e9775e64e285f69c Mon Sep 17 00:00:00 2001 From: Fedor Date: Fri, 28 Aug 2026 14:02:32 +0300 Subject: [PATCH] Prevent project saves from reopening closed vacancies --- projects/signals.py | 65 +++++++++++++++++---- projects/tests/test_project_signals.py | 38 ++++++++++++ vacancy/tests/test_vacancy_responses_api.py | 41 +++++++++++++ 3 files changed, 133 insertions(+), 11 deletions(-) diff --git a/projects/signals.py b/projects/signals.py index 09f89a5c..0f737b83 100644 --- a/projects/signals.py +++ b/projects/signals.py @@ -1,5 +1,7 @@ -from django.db.models.signals import post_save +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 from chats.models import ProjectChat from feed.services import delete_news_for_model, create_news_for_model @@ -7,6 +9,23 @@ from vacancy.models import Vacancy +@receiver(pre_save, sender=Project) +def remember_project_draft_state( + sender, instance, raw=False, update_fields=None, **kwargs +): + """Сохраняет предыдущее состояние draft для обработки реального перехода.""" + + if raw or instance._state.adding: + instance._previous_draft = None + return + if update_fields is not None and "draft" not in update_fields: + instance._previous_draft = instance.draft + return + instance._previous_draft = ( + sender.objects.filter(pk=instance.pk).values_list("draft", flat=True).first() + ) + + @receiver(post_save, sender=Project) def create_project(sender, instance, created, **kwargs): """ @@ -23,15 +42,39 @@ def create_project(sender, instance, created, **kwargs): @receiver(post_save, sender=Project) -def update_vacancy(sender, instance, created, **kwargs): - vacancies = Vacancy.objects.filter(project=instance) - old_values_by_id = dict(vacancies.values_list("id", "is_active")) - vacancies.update(is_active=False if instance.draft else True) - - for vacancy in vacancies: - old = old_values_by_id[vacancy.id] - new = vacancy.is_active - if old != new and new is False: +def update_vacancy(sender, instance, created, raw=False, **kwargs): + previous_draft = getattr(instance, "_previous_draft", None) + 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() + + 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, + ) + 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, + ) + + for vacancy in changed_vacancies: + vacancy.is_active = target_is_active + vacancy.datetime_closed = None if target_is_active else now + if target_is_active is False: delete_news_for_model(vacancy) - elif old != new and new is True: + else: create_news_for_model(vacancy) diff --git a/projects/tests/test_project_signals.py b/projects/tests/test_project_signals.py index a0319d25..6fd6f7d6 100644 --- a/projects/tests/test_project_signals.py +++ b/projects/tests/test_project_signals.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.test import TestCase from chats.models import ProjectChat @@ -10,12 +12,14 @@ 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) + self.assertIsNotNone(vacancy.datetime_closed) project.draft = False project.save() vacancy.refresh_from_db() self.assertTrue(vacancy.is_active) + self.assertIsNone(vacancy.datetime_closed) self.assertTrue(News.objects.get_news(vacancy).filter(text="").exists()) self.assertTrue(ProjectChat.objects.filter(project=project).exists()) @@ -30,8 +34,42 @@ def test_return_project_to_draft_deactivates_vacancies_and_removes_feed_news(sel vacancy.refresh_from_db() self.assertFalse(vacancy.is_active) + self.assertIsNotNone(vacancy.datetime_closed) self.assertFalse(News.objects.get_news(vacancy).filter(text="").exists()) + def test_save_published_project_does_not_change_active_vacancy(self): + project = create_project(draft=False) + vacancy = create_vacancy(project, is_active=True) + + project.description = "Updated description" + with patch("projects.signals.create_news_for_model") as create_news, patch( + "projects.signals.delete_news_for_model" + ) as delete_news: + project.save() + + vacancy.refresh_from_db() + self.assertTrue(vacancy.is_active) + self.assertIsNone(vacancy.datetime_closed) + create_news.assert_not_called() + delete_news.assert_not_called() + + def test_save_published_project_does_not_reopen_closed_vacancy(self): + project = create_project(draft=False) + vacancy = create_vacancy(project, is_active=False) + closed_at = vacancy.datetime_closed + + project.description = "Updated description" + with patch("projects.signals.create_news_for_model") as create_news, patch( + "projects.signals.delete_news_for_model" + ) as delete_news: + project.save() + + vacancy.refresh_from_db() + self.assertFalse(vacancy.is_active) + self.assertEqual(vacancy.datetime_closed, closed_at) + create_news.assert_not_called() + delete_news.assert_not_called() + def test_repeated_publish_does_not_duplicate_feed_news_or_chat(self): project = create_project(draft=True) vacancy = create_vacancy(project, is_active=False) diff --git a/vacancy/tests/test_vacancy_responses_api.py b/vacancy/tests/test_vacancy_responses_api.py index 8d952214..42334e65 100644 --- a/vacancy/tests/test_vacancy_responses_api.py +++ b/vacancy/tests/test_vacancy_responses_api.py @@ -147,6 +147,47 @@ def test_project_leader_can_accept_response(self, send_email_delay): ) self.assertEqual(send_email_delay.call_args.args[0]["user_id"], applicant.id) + @patch("vacancy.response_services.send_email.delay") + def test_project_save_does_not_reopen_vacancy_after_response_acceptance( + self, send_email_delay + ): + leader = create_user(prefix="leader") + accepted_applicant = create_user(prefix="accepted-applicant") + rejected_applicant = create_user(prefix="rejected-applicant") + project = create_project(leader=leader, draft=False) + vacancy = create_vacancy(project=project, is_active=True, role="Designer") + accepted_response = create_vacancy_response( + user=accepted_applicant, + vacancy=vacancy, + ) + rejected_response = create_vacancy_response( + user=rejected_applicant, + vacancy=vacancy, + ) + self.client.force_authenticate(leader) + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/responses/{accepted_response.id}/accept/" + ) + + accepted_response.refresh_from_db() + rejected_response.refresh_from_db() + vacancy.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(accepted_response.is_approved) + self.assertFalse(rejected_response.is_approved) + self.assertFalse(vacancy.is_active) + self.assertIsNotNone(vacancy.datetime_closed) + closed_at = vacancy.datetime_closed + + project.description = "Updated after accepting a candidate" + project.save() + + vacancy.refresh_from_db() + self.assertFalse(vacancy.is_active) + self.assertEqual(vacancy.datetime_closed, closed_at) + @patch("vacancy.response_services.send_email.delay") def test_project_leader_can_decline_response(self, send_email_delay): leader = create_user(prefix="leader")