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
65 changes: 54 additions & 11 deletions projects/signals.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
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
from projects.models import Collaborator, Project
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):
"""
Expand All @@ -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)
57 changes: 57 additions & 0 deletions projects/tests/test_project_signals.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import patch

from django.test import TestCase

from chats.models import ProjectChat
Expand All @@ -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())

Expand All @@ -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)
Expand Down
16 changes: 10 additions & 6 deletions vacancy/selectors.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions vacancy/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,29 @@ 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)

@staticmethod
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)
Expand Down Expand Up @@ -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",
]
Expand Down
51 changes: 51 additions & 0 deletions vacancy/tests/test_vacancy_contract_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="арктическая платформа")
Expand Down Expand Up @@ -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"])

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

Expand All @@ -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):
Expand Down
41 changes: 41 additions & 0 deletions vacancy/tests/test_vacancy_responses_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading