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
24 changes: 17 additions & 7 deletions projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)


Expand Down
32 changes: 29 additions & 3 deletions vacancy/selectors.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
44 changes: 43 additions & 1 deletion vacancy/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
149 changes: 135 additions & 14 deletions vacancy/tests/test_vacancy_contract_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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,
):
Expand All @@ -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")
Expand Down
6 changes: 5 additions & 1 deletion vacancy/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading