diff --git a/projects/views.py b/projects/views.py index 45da647d..6bad51b0 100644 --- a/projects/views.py +++ b/projects/views.py @@ -67,8 +67,8 @@ ) from users.models import LikesOnProject from users.serializers import UserListSerializer -from vacancy.models import VacancyResponse -from vacancy.serializers import VacancyResponseFullFileInfoListSerializer +from vacancy.serializers import VacancyResponseManagerSerializer +from vacancy.selectors import can_manage_project, get_response_queryset logger = logging.getLogger() @@ -339,15 +339,25 @@ class AchievementDetail(generics.RetrieveUpdateDestroyAPIView): class ProjectVacancyResponses(generics.GenericAPIView): - serializer_class = VacancyResponseFullFileInfoListSerializer - permission_classes = [IsAuthenticated, ProjectVisibilityPermission] + serializer_class = VacancyResponseManagerSerializer + permission_classes = [IsAuthenticated] def get_queryset(self): - return VacancyResponse.objects.filter(vacancy__project_id=self.kwargs["id"]) + return get_response_queryset().filter(vacancy__project_id=self.kwargs["id"]) - def get(self, *args, **kwargs): + def get(self, request, *args, **kwargs): + project = get_object_or_404( + Project.objects.only("id", "leader_id"), + pk=self.kwargs["id"], + ) + if not can_manage_project(request.user, project): + return Response(status=status.HTTP_403_FORBIDDEN) queryset = self.get_queryset() - serializer = self.get_serializer(queryset, many=True) + serializer = self.get_serializer( + queryset, + many=True, + context={"request": request}, + ) return Response(serializer.data) diff --git a/vacancy/selectors.py b/vacancy/selectors.py index b4c8ae5f..0ee4ca4c 100644 --- a/vacancy/selectors.py +++ b/vacancy/selectors.py @@ -1,6 +1,7 @@ -from django.db.models import Count, Prefetch, Q, QuerySet +from django.db.models import Count, Exists, OuterRef, Prefetch, Q, QuerySet from core.models import SkillToObject +from projects.models import Collaborator, Project from users.public_profile_selectors import get_public_profiles_queryset from vacancy.models import Vacancy, VacancyResponse @@ -68,6 +69,27 @@ def get_self_response_queryset() -> QuerySet[VacancyResponse]: ) +def with_applicant_state(queryset: QuerySet[Vacancy], user) -> QuerySet[Vacancy]: + """Добавляет UI-подсказки одним запросом, не заменяя серверную проверку POST.""" + + if not user or not user.is_authenticated: + return queryset + return queryset.annotate( + current_user_has_responded=Exists( + VacancyResponse.objects.filter( + vacancy_id=OuterRef("pk"), + user_id=user.id, + ) + ), + current_user_is_collaborator=Exists( + Collaborator.objects.filter( + project_id=OuterRef("project_id"), + user_id=user.id, + ) + ), + ) + + def is_staff(user) -> bool: return bool( user @@ -76,12 +98,16 @@ def is_staff(user) -> bool: ) -def can_manage_vacancy(user, vacancy: Vacancy) -> bool: +def can_manage_project(user, project: Project) -> bool: return is_staff(user) or ( - bool(user and user.is_authenticated) and vacancy.project.leader_id == user.id + bool(user and user.is_authenticated) and project.leader_id == user.id ) +def can_manage_vacancy(user, vacancy: Vacancy) -> bool: + return can_manage_project(user, vacancy.project) + + def can_view_vacancy(user, vacancy: Vacancy) -> bool: return can_manage_vacancy(user, vacancy) or ( vacancy.is_active and not vacancy.project.draft and vacancy.project.is_public diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 8a4e9dd8..62e69854 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -175,6 +175,40 @@ class VacancyDetailSerializer( RequiredSkillsWriteSerializerMixin[Vacancy], ): project = ProjectForVacancySerializer(many=False, read_only=True) + has_responded = 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_can_respond(self, vacancy: Vacancy) -> bool: + request = self.context.get("request") + user = getattr(request, "user", None) + return bool( + user + and user.is_authenticated + and vacancy.is_active + and vacancy.project.is_public + and not vacancy.project.draft + and vacancy.project.leader_id != user.id + and not getattr(vacancy, "current_user_is_collaborator", False) + and not self.get_has_responded(vacancy) + ) + + def get_can_manage_responses(self, vacancy: Vacancy) -> bool: + request = self.context.get("request") + user = getattr(request, "user", None) + return bool( + user + and user.is_authenticated + and ( + vacancy.project.leader_id == user.id + or getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + ) + ) class Meta: model = Vacancy @@ -197,8 +231,16 @@ class Meta: "work_format", "salary", "city", + "has_responded", + "can_respond", + "can_manage_responses", + ] + read_only_fields = [ + "project", + "has_responded", + "can_respond", + "can_manage_responses", ] - read_only_fields = ["project"] class VacancyListSerializer( diff --git a/vacancy/tests/test_vacancy_contract_api.py b/vacancy/tests/test_vacancy_contract_api.py index b058b5d3..a9c258b4 100644 --- a/vacancy/tests/test_vacancy_contract_api.py +++ b/vacancy/tests/test_vacancy_contract_api.py @@ -165,6 +165,73 @@ def test_leader_staff_and_superuser_can_open_inactive_detail(self): ) +class VacancyApplicantStateContractTests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="leader") + self.project = create_project(leader=self.leader) + self.vacancy = create_vacancy(project=self.project) + + def get_detail(self, user=None, vacancy=None): + self.client.force_authenticate(user) + vacancy = vacancy or self.vacancy + return self.client.get(f"/vacancies/{vacancy.id}/") + + def test_outsider_state_changes_after_first_response(self): + outsider = create_user(prefix="outsider") + + before = self.get_detail(outsider) + self.assertEqual(before.status_code, status.HTTP_200_OK) + self.assertFalse(before.data["has_responded"]) + 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.assertFalse(after.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.assertFalse(leader_response.data["can_respond"]) + self.assertTrue(leader_response.data["can_manage_responses"]) + + collaborator = create_user(prefix="collaborator") + Collaborator.objects.create( + project=self.project, + user=collaborator, + role="Developer", + ) + collaborator_response = self.get_detail(collaborator) + self.assertFalse(collaborator_response.data["can_respond"]) + self.assertFalse(collaborator_response.data["can_manage_responses"]) + + def test_inactive_and_anonymous_states(self): + staff = create_user(prefix="staff", is_staff=True) + inactive = create_vacancy(project=self.project, is_active=False) + inactive_response = self.get_detail(staff, inactive) + self.assertEqual(inactive_response.status_code, status.HTTP_200_OK) + self.assertFalse(inactive_response.data["can_respond"]) + self.assertTrue(inactive_response.data["can_manage_responses"]) + + anonymous_response = self.get_detail() + self.assertEqual(anonymous_response.status_code, status.HTTP_200_OK) + self.assertFalse(anonymous_response.data["has_responded"]) + self.assertFalse(anonymous_response.data["can_respond"]) + self.assertFalse(anonymous_response.data["can_manage_responses"]) + + def test_staff_and_superuser_can_manage_responses(self): + for user in ( + create_user(prefix="staff", is_staff=True), + create_user(prefix="superuser", is_superuser=True), + ): + with self.subTest(user=user.id): + response = self.get_detail(user) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["can_manage_responses"]) + + class VacancyResponseContractTests(TestCase): def setUp(self): self.client = APIClient() @@ -204,17 +271,21 @@ def test_request_user_is_used_and_foreign_file_is_rejected(self, send_email): self.assertEqual(VacancyResponse.objects.get().user, applicant) send_email.assert_called_once() - def test_member_and_duplicate_response_are_rejected(self): + def test_leader_member_and_duplicate_response_are_rejected(self): leader = create_user(prefix="leader") member = create_user(prefix="member") project = create_project(leader=leader) Collaborator.objects.create(project=project, user=member, role="Developer") vacancy = create_vacancy(project=project) - self.client.force_authenticate(member) - response = self.client.post( - f"/vacancies/{vacancy.id}/responses/", {"why_me": "Member"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + for user in (leader, member): + with self.subTest(user=user.id): + self.client.force_authenticate(user) + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"why_me": "Project member"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) outsider = create_user(prefix="outsider") create_vacancy_response(user=outsider, vacancy=vacancy) @@ -224,6 +295,41 @@ def test_member_and_duplicate_response_are_rejected(self): ) self.assertEqual(duplicate.status_code, status.HTTP_400_BAD_REQUEST) + def test_legacy_project_response_list_requires_manager_and_is_safe(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + applicant = create_user(prefix="applicant") + vacancy = create_vacancy(project=project) + create_vacancy_response(user=applicant, vacancy=vacancy) + url = f"/projects/{project.id}/responses/" + + collaborator = create_user(prefix="collaborator") + Collaborator.objects.create( + project=project, + user=collaborator, + role="Developer", + ) + outsider = create_user(prefix="outsider") + for user in (collaborator, outsider): + with self.subTest(forbidden_user=user.id): + self.client.force_authenticate(user) + self.assertEqual( + self.client.get(url).status_code, + status.HTTP_403_FORBIDDEN, + ) + + for user in ( + leader, + create_user(prefix="staff", is_staff=True), + create_user(prefix="superuser", is_superuser=True), + ): + with self.subTest(manager=user.id): + self.client.force_authenticate(user) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["user"]["id"], applicant.id) + assert_private_fields_absent(self, response.data) + def test_response_list_requires_manager_and_never_exposes_private_profile_fields( self, ): @@ -234,15 +340,30 @@ def test_response_list_requires_manager_and_never_exposes_private_profile_fields create_vacancy_response(user=applicant, vacancy=vacancy) outsider = create_user(prefix="outsider") - self.client.force_authenticate(outsider) - self.assertEqual( - self.client.get(f"/vacancies/{vacancy.id}/responses/").status_code, - status.HTTP_403_FORBIDDEN, + collaborator = create_user(prefix="collaborator") + Collaborator.objects.create( + project=project, + user=collaborator, + role="Developer", ) - self.client.force_authenticate(leader) - response = self.client.get(f"/vacancies/{vacancy.id}/responses/") - self.assertEqual(response.status_code, status.HTTP_200_OK) - assert_private_fields_absent(self, response.data) + for user in (outsider, collaborator): + with self.subTest(forbidden_user=user.id): + self.client.force_authenticate(user) + self.assertEqual( + self.client.get(f"/vacancies/{vacancy.id}/responses/").status_code, + status.HTTP_403_FORBIDDEN, + ) + + for user in ( + leader, + create_user(prefix="staff", is_staff=True), + create_user(prefix="superuser", is_superuser=True), + ): + with self.subTest(manager=user.id): + self.client.force_authenticate(user) + response = self.client.get(f"/vacancies/{vacancy.id}/responses/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + assert_private_fields_absent(self, response.data) def test_response_detail_is_visible_only_to_owner_or_vacancy_manager(self): leader = create_user(prefix="leader") diff --git a/vacancy/views.py b/vacancy/views.py index f7c53262..7a33e4ee 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -31,6 +31,7 @@ get_response_queryset, get_self_response_queryset, get_vacancy_queryset, + with_applicant_state, ) from vacancy.response_services import ( accept_vacancy_response, @@ -73,7 +74,10 @@ class VacancyDetail(generics.RetrieveUpdateDestroyAPIView): permission_classes = [IsVacancyProjectLeader] def get_object(self): - vacancy = get_object_or_404(get_vacancy_queryset(), pk=self.kwargs["pk"]) + vacancy = get_object_or_404( + with_applicant_state(get_vacancy_queryset(), self.request.user), + pk=self.kwargs["pk"], + ) if not can_view_vacancy(self.request.user, vacancy): raise Http404 self.check_object_permissions(self.request, vacancy)