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..fcd0df80 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 @@ -16,6 +18,7 @@ def test_publish_project_activates_vacancies_creates_feed_news_and_chat(self): 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 +33,62 @@ 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_draft_transition_updates_feed_only_for_changed_vacancies(self): + project = create_project(draft=False) + active = create_vacancy(project, is_active=True) + closed = create_vacancy(project, is_active=False) + + project.draft = True + with patch("projects.signals.create_news_for_model") as create_news, patch( + "projects.signals.delete_news_for_model" + ) as delete_news: + project.save(update_fields=("draft",)) + + active.refresh_from_db() + closed.refresh_from_db() + self.assertFalse(active.is_active) + self.assertFalse(closed.is_active) + self.assertIsNotNone(active.datetime_closed) + self.assertIsNotNone(closed.datetime_closed) + create_news.assert_not_called() + delete_news.assert_called_once_with(active) + 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/selectors.py b/vacancy/selectors.py index 0ee4ca4c..eb85e461 100644 --- a/vacancy/selectors.py +++ b/vacancy/selectors.py @@ -1,4 +1,4 @@ -from django.db.models import Count, Exists, OuterRef, Prefetch, Q, QuerySet +from django.db.models import Count, Exists, OuterRef, Prefetch, Q, QuerySet, Subquery from core.models import SkillToObject from projects.models import Collaborator, Project @@ -74,12 +74,16 @@ def with_applicant_state(queryset: QuerySet[Vacancy], user) -> QuerySet[Vacancy] if not user or not user.is_authenticated: return queryset + + current_user_response = VacancyResponse.objects.filter( + vacancy_id=OuterRef("pk"), + user_id=user.id, + ).order_by("-datetime_created", "-pk") + return queryset.annotate( - current_user_has_responded=Exists( - VacancyResponse.objects.filter( - vacancy_id=OuterRef("pk"), - user_id=user.id, - ) + current_user_has_responded=Exists(current_user_response), + current_user_response_is_approved=Subquery( + current_user_response.values("is_approved")[:1] ), current_user_is_collaborator=Exists( Collaborator.objects.filter( diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 62e69854..efbe7b67 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -176,6 +176,7 @@ class VacancyDetailSerializer( ): project = ProjectForVacancySerializer(many=False, read_only=True) has_responded = serializers.SerializerMethodField(read_only=True) + response_status = serializers.SerializerMethodField(read_only=True) can_respond = serializers.SerializerMethodField(read_only=True) can_manage_responses = serializers.SerializerMethodField(read_only=True) @@ -183,6 +184,21 @@ class VacancyDetailSerializer( def get_has_responded(vacancy: Vacancy) -> bool: return bool(getattr(vacancy, "current_user_has_responded", False)) + def get_response_status(self, vacancy: Vacancy) -> str | None: + if not self.get_has_responded(vacancy): + return None + + is_approved = getattr( + vacancy, + "current_user_response_is_approved", + None, + ) + if is_approved is True: + return "accepted" + if is_approved is False: + return "rejected" + return "pending" + def get_can_respond(self, vacancy: Vacancy) -> bool: request = self.context.get("request") user = getattr(request, "user", None) @@ -232,12 +248,14 @@ class Meta: "salary", "city", "has_responded", + "response_status", "can_respond", "can_manage_responses", ] read_only_fields = [ "project", "has_responded", + "response_status", "can_respond", "can_manage_responses", ] diff --git a/vacancy/tests/test_vacancy_contract_api.py b/vacancy/tests/test_vacancy_contract_api.py index a9c258b4..022f07a2 100644 --- a/vacancy/tests/test_vacancy_contract_api.py +++ b/vacancy/tests/test_vacancy_contract_api.py @@ -65,6 +65,8 @@ def test_catalog_isolated_to_active_public_published_projects(self): self.assertEqual(response.data["results"], []) response = self.client.get("/vacancies/") self.assertEqual([item["id"] for item in response.data["results"]], [visible.id]) + self.assertIn("datetime_created", response.data["results"][0]) + self.assertIn("datetime_updated", response.data["results"][0]) def test_search_covers_role_specialization_description_and_project_name(self): project = create_project(name="арктическая платформа") @@ -183,17 +185,40 @@ def test_outsider_state_changes_after_first_response(self): before = self.get_detail(outsider) self.assertEqual(before.status_code, status.HTTP_200_OK) self.assertFalse(before.data["has_responded"]) + self.assertIsNone(before.data["response_status"]) self.assertTrue(before.data["can_respond"]) self.assertFalse(before.data["can_manage_responses"]) create_vacancy_response(user=outsider, vacancy=self.vacancy) after = self.get_detail(outsider) self.assertTrue(after.data["has_responded"]) + self.assertEqual(after.data["response_status"], "pending") self.assertFalse(after.data["can_respond"]) + def test_response_status_maps_processed_responses(self): + for is_approved, expected_status in ( + (True, "accepted"), + (False, "rejected"), + ): + with self.subTest(is_approved=is_approved): + applicant = create_user(prefix=f"applicant-{expected_status}") + create_vacancy_response( + user=applicant, + vacancy=self.vacancy, + is_approved=is_approved, + ) + + response = self.get_detail(applicant) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["has_responded"]) + self.assertEqual(response.data["response_status"], expected_status) + self.assertFalse(response.data["can_respond"]) + def test_leader_and_collaborator_states(self): leader_response = self.get_detail(self.leader) self.assertFalse(leader_response.data["has_responded"]) + self.assertIsNone(leader_response.data["response_status"]) self.assertFalse(leader_response.data["can_respond"]) self.assertTrue(leader_response.data["can_manage_responses"]) @@ -218,6 +243,7 @@ def test_inactive_and_anonymous_states(self): anonymous_response = self.get_detail() self.assertEqual(anonymous_response.status_code, status.HTTP_200_OK) self.assertFalse(anonymous_response.data["has_responded"]) + self.assertIsNone(anonymous_response.data["response_status"]) self.assertFalse(anonymous_response.data["can_respond"]) self.assertFalse(anonymous_response.data["can_manage_responses"]) @@ -231,6 +257,31 @@ def test_staff_and_superuser_can_manage_responses(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(response.data["can_manage_responses"]) + def test_response_status_does_not_add_separate_detail_query(self): + applicant = create_user(prefix="query-count-applicant") + create_vacancy_response(user=applicant, vacancy=self.vacancy) + self.client.force_authenticate(applicant) + + with CaptureQueriesContext(connection) as queries: + response = self.client.get(f"/vacancies/{self.vacancy.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_table = VacancyResponse._meta.db_table + response_queries = [ + query["sql"] + for query in queries.captured_queries + if response_table in query["sql"] + ] + self.assertEqual(len(response_queries), 1) + self.assertEqual( + sum( + 'AS "current_user_response_is_approved"' in query + for query in response_queries + ), + 1, + ) + self.assertEqual(response.data["response_status"], "pending") + class VacancyResponseContractTests(TestCase): def setUp(self): diff --git a/vacancy/tests/test_vacancy_responses_api.py b/vacancy/tests/test_vacancy_responses_api.py index 2d35cc16..51c0c519 100644 --- a/vacancy/tests/test_vacancy_responses_api.py +++ b/vacancy/tests/test_vacancy_responses_api.py @@ -162,6 +162,47 @@ def test_project_leader_can_accept_response(self, send_email_delay): ).exists() ) + @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")