diff --git a/docs/modules/vacancy.md b/docs/modules/vacancy.md index a9b2b416..7d4d7881 100644 --- a/docs/modules/vacancy.md +++ b/docs/modules/vacancy.md @@ -9,9 +9,9 @@ Vacancy отвечает за вакансии внутри проектов Pro ## Статус модуля -Модуль используется в продуктовых сценариях проектов, ленты и откликов, но -находится в состоянии технического долга. Основная бизнес-логика все еще -сосредоточена во `views.py` и serializers. +Модуль используется в продуктовых сценариях проектов, ленты и откликов. +Security-critical lifecycle откликов вынесен в транзакционные services и +selectors, а views отвечают за HTTP orchestration и выбор безопасного контракта. Критичные API-flow закрыты regression-тестами: создание вакансии, фильтрация списка, отклик, повторный отклик, accept/decline, закрытие вакансии, @@ -40,9 +40,11 @@ email-уведомления и permissions. Текущий coverage по мод ## Архитектура - `vacancy/models.py` - модели `Vacancy` и `VacancyResponse`. -- `vacancy/views.py` - API endpoints и основная orchestration logic. +- `vacancy/views.py` - API endpoints и HTTP orchestration logic. - `vacancy/serializers.py` - request/response serializers, validation и часть create/update logic. +- `vacancy/response_services.py` - атомарное создание и обработка откликов. +- `vacancy/selectors.py` - оптимизированные queryset и проверки manager-доступа. - `vacancy/filters.py` - фильтры списка вакансий. - `vacancy/managers.py` - queryset helpers для вакансий и откликов. - `vacancy/services.py` - вспомогательная логика обновления навыков вакансии. @@ -72,19 +74,21 @@ email-уведомления и permissions. Текущий coverage по мод - `PUT /vacancies//` - полное обновление вакансии. - `PATCH /vacancies//` - частичное обновление вакансии. - `DELETE /vacancies//` - удаление вакансии. -- `GET /vacancies//responses/` - список откликов на вакансию. +- `GET /vacancies//responses/` - безопасный список откликов для + руководителя проекта, staff и superuser. - `POST /vacancies//responses/` - отклик на вакансию. - `GET /vacancies/responses//` - детали отклика. - `PUT /vacancies/responses//` - обновление отклика. - `PATCH /vacancies/responses//` - частичное обновление отклика. - `DELETE /vacancies/responses//` - удаление отклика. -- `GET /vacancies/responses/self` - отклики текущего пользователя. +- `GET /vacancies/responses/self` - отклики только текущего пользователя. - `POST /vacancies/responses//accept/` - принять отклик. - `POST /vacancies/responses//decline/` - отклонить отклик. Связанные endpoints и сценарии: -- `GET /projects//responses/` - отклики по всем вакансиям проекта. +- `GET /projects//responses/` - совместимый manager-only список откликов по + всем вакансиям проекта; доступен руководителю, staff и superuser. - `GET /projects/?any_vacancies=true` - проекты с активными вакансиями. - `GET /feed/?type=vacancy` - служебные записи активных вакансий в ленте. @@ -129,13 +133,30 @@ Queryset списка дополнительно ограничен ваканс При отклике: -- вакансия должна быть активной; -- пользователь подставляется из `request.user`; +- требуется аутентификация; +- вакансия должна быть активной, а проект - опубликованным и публичным; +- пользователь всегда подставляется из `request.user`; поля `user`, `user_id` + и `vacancy` из payload не участвуют в создании; +- лидер и участники этого проекта не могут откликнуться на его вакансию; - повторный отклик на ту же вакансию запрещен; - можно передать `why_me`; -- можно приложить `accompanying_file`; +- можно приложить только собственный `accompanying_file`; - лидеру проекта отправляется email о новом отклике. +`GET /vacancies//` дополнительно возвращает read-only UI-hints: + +- `has_responded` - у текущего пользователя уже есть отклик; +- `can_respond` - текущая вакансия доступна этому пользователю для отклика; +- `can_manage_responses` - пользователь может управлять откликами. + +Эти признаки не являются границей безопасности: `POST` независимо повторяет +все проверки внутри транзакции. + +Manager endpoints используют явный allow-list. Карточка кандидата содержит +только `id`, имя, фамилию, аватар, специализацию, навыки и описание. Email, +телефон, дата рождения и административные поля в ответ не включаются. Метаданные +файла не содержат владельца и служебные поля. + ### 4. Лидер принимает отклик Лидер проекта вызывает `POST /vacancies/responses//accept/`. @@ -147,6 +168,11 @@ Queryset списка дополнительно ограничен ваканс - пользователю отправляется email; - вакансия закрывается через `is_active=False`; - `datetime_closed` обновляется автоматически в модели. +- остальные ожидающие отклики этой вакансии получают `is_approved=False`. + +Вакансия, выбранный отклик и остальные ожидающие отклики блокируются в одной +транзакции. Повторная обработка запрещена, а уникальное ограничение +`Collaborator(project, user)` не допускает дублирования участника. ### 5. Лидер отклоняет отклик @@ -199,10 +225,8 @@ Celery-задача `email_notificate_vacancy_outdated()` выбирает ак ## Ограничения и риски -- `vacancy/views.py` содержит много бизнес-логики: отклик, accept/decline, - закрытие вакансии, создание collaborator и отправка email. - `vacancy/serializers.py` содержит не только contracts, но и create/update - orchestration. + orchestration legacy CRUD вакансий. - `send_email` находится в `vacancy.tasks`, но используется также `partner_programs` и `project_rates`; это общий notification helper, который нужно вынести ближе к `mailing`. @@ -210,10 +234,10 @@ Celery-задача `email_notificate_vacancy_outdated()` выбирает ак ожидает его наличие. - `update_vacancy_skills()` может вернуть `Response`, но callers в `views.py` этот результат не обрабатывают. -- `GET /vacancies//responses/` для несуществующей вакансии сейчас - возвращает пустой список, а не 404. -- `accompanying_file` ищется по всем `UserFile`, без явной проверки, что файл - принадлежит текущему пользователю. +- Существующие legacy serializers откликов сохранены для совместимости кода, + но manager/self endpoints используют отдельные безопасные serializers. +- Схема данных в этом этапе не менялась; `Vacancy.city` и миграция `0010` + сохраняются без изменений. ## Тесты @@ -242,6 +266,14 @@ Celery-задача `email_notificate_vacancy_outdated()` выбирает ак - decline-flow: отклик отклоняется, письмо отправляется пользователю; - запрет accept не-лидером; - запрет повторного accept/decline; +- запрет отклика лидера и collaborator собственного проекта; +- запрет отклика на draft/private проект; +- запрет прикрепления чужого `UserFile`; +- безопасные manager/self contracts без приватных данных; +- manager-only доступ к vacancy/project response lists; +- applicant state в detail вакансии; +- атомарность accept и отклонение остальных ожидающих откликов; +- постоянное число запросов manager-list при росте числа кандидатов; - обновление `datetime_closed` при смене активности вакансии; - замену `required_skills` через `update_vacancy_skills()`; - контролируемую ошибку при передаче несуществующего навыка; @@ -252,5 +284,4 @@ Celery-задача `email_notificate_vacancy_outdated()` выбирает ак Пока не покрыты точечными тестами: - admin export email лидеров; -- запрет прикрепления чужого `UserFile` к отклику; -- контракт `GET /vacancies//responses/` для отсутствующей вакансии. +- интеграция с внешним SMTP-брокером (в API-тестах Celery task мокируется). diff --git a/projects/views.py b/projects/views.py index aa252fc2..8a16f146 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/managers.py b/vacancy/managers.py index 7b371d1f..27869373 100644 --- a/vacancy/managers.py +++ b/vacancy/managers.py @@ -37,6 +37,9 @@ def get_vacancy_for_detail_view(self): "project__is_company", "project__industry", "project__links__link", + "project__leader", + "project__draft", + "project__is_public", "is_active", "datetime_created", "datetime_updated", diff --git a/vacancy/response_services.py b/vacancy/response_services.py new file mode 100644 index 00000000..68f99dc5 --- /dev/null +++ b/vacancy/response_services.py @@ -0,0 +1,169 @@ +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers +from rest_framework.exceptions import NotFound, PermissionDenied + +from projects.models import Collaborator +from vacancy.mapping import CeleryEmailParams, MessageTypeEnum +from vacancy.models import Vacancy, VacancyResponse +from vacancy.tasks import send_email + + +def _ensure_can_manage(vacancy: Vacancy, user) -> None: + """Повторно проверяет право после получения блокировки вакансии.""" + + if not ( + vacancy.project.leader_id == user.id + or getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + ): + raise PermissionDenied() + + +def _lock_vacancy_then_response( + response_id: int, +) -> tuple[Vacancy, VacancyResponse]: + """Берёт блокировки в едином порядке для параллельных решений менеджера.""" + + try: + vacancy_id = ( + VacancyResponse.objects.only("vacancy_id").get(pk=response_id).vacancy_id + ) + vacancy = ( + Vacancy.objects.select_for_update() + .select_related("project") + .get(pk=vacancy_id) + ) + response = VacancyResponse.objects.select_for_update().get( + pk=response_id, + vacancy_id=vacancy.id, + ) + except (Vacancy.DoesNotExist, VacancyResponse.DoesNotExist) as error: + raise NotFound() from error + response.vacancy = vacancy + return vacancy, response + + +@transaction.atomic +def create_vacancy_response( + *, vacancy_id: int, user, validated_data: dict +) -> VacancyResponse: + """Создаёт отклик от request.user под блокировкой вакансии.""" + + try: + vacancy = ( + Vacancy.objects.select_for_update() + .select_related("project") + .get(pk=vacancy_id) + ) + except Vacancy.DoesNotExist as error: + raise NotFound() from error + + if not vacancy.is_active or vacancy.project.draft or not vacancy.project.is_public: + raise serializers.ValidationError("На эту вакансию больше нельзя откликнуться.") + if ( + vacancy.project.leader_id == user.id + or Collaborator.objects.filter( + project=vacancy.project, + user=user, + ).exists() + ): + raise serializers.ValidationError( + "Участник проекта не может откликнуться на его вакансию." + ) + if VacancyResponse.objects.filter(vacancy=vacancy, user=user).exists(): + raise serializers.ValidationError("Вы уже откликнулись на эту вакансию.") + + response = VacancyResponse.objects.create( + vacancy=vacancy, + user=user, + **validated_data, + ) + transaction.on_commit( + lambda: send_email.delay( + CeleryEmailParams( + message_type=MessageTypeEnum.RESPONDED.value, + user_id=vacancy.project.leader_id, + project_name=vacancy.project.name, + project_id=vacancy.project_id, + vacancy_role=vacancy.role, + schema_id=2, + ) + ) + ) + return response + + +def _email_payload(response: VacancyResponse, message_type: str) -> CeleryEmailParams: + project = response.vacancy.project + return CeleryEmailParams( + message_type=message_type, + user_id=response.user_id, + project_name=project.name, + project_id=project.id, + vacancy_role=response.vacancy.role, + schema_id=2, + ) + + +@transaction.atomic +def accept_vacancy_response(response_id: int, *, actor) -> VacancyResponse: + """Принимает кандидата, закрывает вакансию и отклоняет остальные отклики.""" + + vacancy, response = _lock_vacancy_then_response(response_id) + _ensure_can_manage(vacancy, actor) + if response.is_approved is not None: + raise serializers.ValidationError("Отклик уже обработан.") + if Collaborator.objects.filter( + project=vacancy.project, + user_id=response.user_id, + ).exists(): + raise serializers.ValidationError("Пользователь уже состоит в команде проекта.") + + Collaborator.objects.create( + project=vacancy.project, + user_id=response.user_id, + role=vacancy.role, + ) + response.is_approved = True + response.save(update_fields=("is_approved", "datetime_updated")) + vacancy.is_active = False + vacancy.save(update_fields=("is_active", "datetime_closed", "datetime_updated")) + + rejected = list( + VacancyResponse.objects.select_for_update() + .filter(vacancy=vacancy, is_approved__isnull=True) + .exclude(pk=response.pk) + ) + VacancyResponse.objects.filter(pk__in=[item.pk for item in rejected]).update( + is_approved=False, + datetime_updated=timezone.now(), + ) + + transaction.on_commit( + lambda: send_email.delay(_email_payload(response, MessageTypeEnum.ACCEPTED.value)) + ) + for rejected_response in rejected: + rejected_response.vacancy = vacancy + transaction.on_commit( + lambda item=rejected_response: send_email.delay( + _email_payload(item, MessageTypeEnum.REJECTED.value) + ) + ) + return response + + +@transaction.atomic +def decline_vacancy_response(response_id: int, *, actor) -> VacancyResponse: + """Отклоняет только ещё не обработанный отклик.""" + + vacancy, response = _lock_vacancy_then_response(response_id) + _ensure_can_manage(vacancy, actor) + if response.is_approved is not None: + raise serializers.ValidationError("Отклик уже обработан.") + response.is_approved = False + response.save(update_fields=("is_approved", "datetime_updated")) + transaction.on_commit( + lambda: send_email.delay(_email_payload(response, MessageTypeEnum.REJECTED.value)) + ) + return response diff --git a/vacancy/selectors.py b/vacancy/selectors.py new file mode 100644 index 00000000..b735e62b --- /dev/null +++ b/vacancy/selectors.py @@ -0,0 +1,100 @@ +from django.db.models import Count, Exists, OuterRef, Prefetch, Q, QuerySet + +from core.models import SkillToObject +from projects.models import Collaborator, Project +from vacancy.models import Vacancy, VacancyResponse + + +def _skill_relations_queryset() -> QuerySet[SkillToObject]: + return SkillToObject.objects.select_related("skill", "skill__category") + + +def _response_vacancies_queryset() -> QuerySet[Vacancy]: + return ( + Vacancy.objects.select_related("project", "project__industry") + .prefetch_related( + Prefetch( + "required_skills", + queryset=_skill_relations_queryset(), + ), + "project__links", + ) + .annotate( + pending_response_count=Count( + "vacancy_requests", + filter=Q(vacancy_requests__is_approved__isnull=True), + ) + ) + ) + + +def get_response_queryset() -> QuerySet[VacancyResponse]: + """Загружает manager response contract без запросов на каждого кандидата.""" + + return ( + VacancyResponse.objects.select_related( + "user", + "user__v2_speciality", + "user__v2_speciality__category", + "vacancy", + "vacancy__project", + "vacancy__project__leader", + "accompanying_file", + ) + .prefetch_related( + Prefetch( + "user__skills", + queryset=_skill_relations_queryset(), + ) + ) + .order_by("datetime_created", "id") + ) + + +def get_self_response_queryset() -> QuerySet[VacancyResponse]: + """Загружает собственные отклики вместе с безопасной карточкой вакансии.""" + + return ( + VacancyResponse.objects.select_related("accompanying_file") + .prefetch_related( + Prefetch("vacancy", queryset=_response_vacancies_queryset()), + ) + .order_by("datetime_created", "id") + ) + + +def with_applicant_state(queryset: QuerySet[Vacancy], user) -> QuerySet[Vacancy]: + """Добавляет UI-hints одним SQL-запросом, не превращая их в границу доступа.""" + + 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 can_manage_project(user, project: Project) -> bool: + return bool( + user + and user.is_authenticated + and ( + project.leader_id == user.id + or getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + ) + ) + + +def can_manage_vacancy(user, vacancy: Vacancy) -> bool: + return can_manage_project(user, vacancy.project) diff --git a/vacancy/serializers.py b/vacancy/serializers.py index 694b49b9..0a5533a0 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -3,7 +3,12 @@ from django.utils import timezone from rest_framework import serializers -from core.models import Skill, SkillToObject +from core.models import ( + Skill, + SkillToObject, + Specialization, + SpecializationCategory, +) from core.serializers import SkillToObjectSerializer from core.services import get_views_count from files.models import UserFile @@ -91,7 +96,9 @@ class AbstractVacancyReadOnlyFields(serializers.Serializer): response_count = serializers.SerializerMethodField(read_only=True) def get_response_count(self, obj): - """Returns count non status responses.""" + """Возвращает число откликов, которые ещё ожидают решения.""" + if hasattr(obj, "pending_response_count"): + return obj.pending_response_count return obj.vacancy_requests.filter(is_approved=None).count() @@ -172,6 +179,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 @@ -194,8 +235,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( @@ -416,3 +465,134 @@ class VacancyResponseDetailReadSerializer(VacancyResponseDetailSerializer): """Returns full file info for detail view without breaking writes.""" accompanying_file = UserFileSerializer(read_only=True) + + +class VacancyResponseFileSerializer(serializers.ModelSerializer): + """Метаданные файла без владельца и служебных полей.""" + + class Meta: + model = UserFile + fields = ("link", "name", "extension", "mime_type", "size") + + +class VacancyCandidateSpecializationCategorySerializer(serializers.ModelSerializer): + class Meta: + model = SpecializationCategory + fields = ("id", "name") + + +class VacancyCandidateSpecializationSerializer(serializers.ModelSerializer): + category = VacancyCandidateSpecializationCategorySerializer(read_only=True) + + class Meta: + model = Specialization + fields = ("id", "name", "category") + + +class VacancyCandidateSerializer(serializers.ModelSerializer): + """Явный публичный allow-list кандидата для руководителя проекта.""" + + specialization = VacancyCandidateSpecializationSerializer( + source="v2_speciality", + read_only=True, + ) + skills = SkillToObjectSerializer(many=True, read_only=True) + + class Meta: + model = User + fields = ( + "id", + "first_name", + "last_name", + "avatar", + "specialization", + "skills", + "about_me", + ) + + +class VacancyResponseWriteSerializer(serializers.ModelSerializer): + accompanying_file = serializers.SlugRelatedField( + slug_field="link", + queryset=UserFile.objects.all(), + required=False, + allow_null=True, + ) + + class Meta: + model = VacancyResponse + fields = ("why_me", "accompanying_file") + + def validate_accompanying_file(self, value): + if value is not None and value.user_id != self.context["request"].user.id: + raise serializers.ValidationError("Можно прикрепить только собственный файл.") + return value + + +class VacancyResponseVacancySerializer( + VacancyCreationDateSerializerMixin, + serializers.ModelSerializer, + AbstractVacancyReadOnlyFields, + AbstractVacancyEnumFields, + RequiredSkillsSerializerMixin[Vacancy], +): + project = ProjectForVacancySerializer(read_only=True) + + class Meta: + model = Vacancy + fields = ( + "id", + "role", + "specialization", + "required_skills", + "description", + "project", + "is_active", + "datetime_created", + "datetime_updated", + "datetime_closed", + "response_count", + "date_create_time", + "required_experience", + "work_schedule", + "work_format", + "salary", + "city", + ) + read_only_fields = fields + + +class VacancyResponseSelfSerializer(serializers.ModelSerializer): + vacancy = VacancyResponseVacancySerializer(read_only=True) + accompanying_file = VacancyResponseFileSerializer(read_only=True) + + class Meta: + model = VacancyResponse + fields = ( + "id", + "vacancy", + "why_me", + "accompanying_file", + "is_approved", + "datetime_created", + "datetime_updated", + ) + + +class VacancyResponseManagerSerializer(serializers.ModelSerializer): + user = VacancyCandidateSerializer(read_only=True) + accompanying_file = VacancyResponseFileSerializer(read_only=True) + + class Meta: + model = VacancyResponse + fields = ( + "id", + "user", + "why_me", + "accompanying_file", + "is_approved", + "vacancy", + "datetime_created", + "datetime_updated", + ) + read_only_fields = fields diff --git a/vacancy/tests/test_vacancy_response_contract_api.py b/vacancy/tests/test_vacancy_response_contract_api.py new file mode 100644 index 00000000..f30e083c --- /dev/null +++ b/vacancy/tests/test_vacancy_response_contract_api.py @@ -0,0 +1,490 @@ +from unittest.mock import patch + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework import status +from rest_framework.test import APIClient + +from core.models import Specialization, SpecializationCategory +from projects.models import Collaborator +from vacancy.models import VacancyResponse +from vacancy.tests.helpers import ( + create_project, + create_skill, + create_user, + create_user_file, + create_vacancy, + create_vacancy_response, +) + + +PRIVATE_FIELDS = { + "email", + "phone", + "phone_number", + "birthday", + "password", + "is_staff", + "is_superuser", + "onboarding_stage", +} + + +def assert_private_fields_absent(test_case: TestCase, value) -> None: + if isinstance(value, dict): + test_case.assertTrue(PRIVATE_FIELDS.isdisjoint(value.keys())) + for nested in value.values(): + assert_private_fields_absent(test_case, nested) + elif isinstance(value, list): + for nested in value: + assert_private_fields_absent(test_case, nested) + + +def make_staff(*, superuser: bool = False): + user = create_user(prefix="superuser" if superuser else "staff") + user.is_staff = True + user.is_superuser = superuser + user.save(update_fields=("is_staff", "is_superuser")) + return user + + +class VacancyResponseCreateContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("vacancy.response_services.send_email.delay") + def test_request_user_is_used_and_own_file_is_accepted(self, send_email): + applicant = create_user(prefix="applicant") + payload_user = create_user(prefix="payload-user") + vacancy = create_vacancy() + own_file = create_user_file(user=applicant) + self.client.force_authenticate(applicant) + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + { + "user": payload_user.id, + "user_id": payload_user.id, + "vacancy": 999999, + "why_me": "Подхожу", + "accompanying_file": own_file.link, + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + created = VacancyResponse.objects.get() + self.assertEqual(created.user, applicant) + self.assertEqual(created.vacancy, vacancy) + self.assertEqual(created.accompanying_file, own_file) + send_email.assert_called_once() + + def test_anonymous_cannot_create_response(self): + vacancy = create_vacancy() + + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"why_me": "Подхожу"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertFalse(VacancyResponse.objects.exists()) + + def test_leader_and_collaborator_cannot_respond_but_outsider_can(self): + leader = create_user(prefix="leader") + collaborator = create_user(prefix="collaborator") + outsider = create_user(prefix="outsider") + project = create_project(leader=leader) + Collaborator.objects.create( + project=project, + user=collaborator, + role="Developer", + ) + vacancy = create_vacancy(project=project) + + for blocked_user in (leader, collaborator): + with self.subTest(user=blocked_user.id): + self.client.force_authenticate(blocked_user) + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"why_me": "Нельзя"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.client.force_authenticate(outsider) + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"why_me": "Можно"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(VacancyResponse.objects.get().user, outsider) + + def test_duplicate_response_is_rejected_without_new_row(self): + applicant = create_user(prefix="applicant") + vacancy = create_vacancy() + original = create_vacancy_response(user=applicant, vacancy=vacancy) + self.client.force_authenticate(applicant) + + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"why_me": "Повтор"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + list(VacancyResponse.objects.values_list("id", flat=True)), [original.id] + ) + + def test_unavailable_vacancy_is_rejected(self): + applicant = create_user(prefix="applicant") + self.client.force_authenticate(applicant) + scenarios = ( + create_vacancy(is_active=False), + create_vacancy(project=create_project(draft=True), is_active=True), + create_vacancy(project=create_project(is_public=False), is_active=True), + ) + + for vacancy in scenarios: + with self.subTest(vacancy=vacancy.id): + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"why_me": "Подхожу"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(VacancyResponse.objects.exists()) + + def test_foreign_file_is_rejected_without_disclosure(self): + applicant = create_user(prefix="applicant") + foreign_file = create_user_file(user=create_user(prefix="file-owner")) + vacancy = create_vacancy() + self.client.force_authenticate(applicant) + + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"accompanying_file": foreign_file.link}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["accompanying_file"], + ["Можно прикрепить только собственный файл."], + ) + self.assertNotIn("user", response.data) + self.assertFalse(VacancyResponse.objects.exists()) + + +class VacancyResponseManagerContractTests(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) + self.applicant = create_user(prefix="applicant") + specialization_category = SpecializationCategory.objects.create(name="Design") + self.specialization = Specialization.objects.create( + name="UX designer", + category=specialization_category, + ) + self.applicant.v2_speciality = self.specialization + self.applicant.save(update_fields=("v2_speciality",)) + self.response = create_vacancy_response( + user=self.applicant, + vacancy=self.vacancy, + accompanying_file=create_user_file(user=self.applicant), + ) + + def test_vacancy_response_list_requires_authentication(self): + response = self.client.get(f"/vacancies/{self.vacancy.id}/responses/") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_vacancy_response_list_requires_manager(self): + collaborator = create_user(prefix="collaborator") + Collaborator.objects.create( + project=self.project, + user=collaborator, + role="Developer", + ) + outsider = create_user(prefix="outsider") + + for blocked_user in (outsider, collaborator): + with self.subTest(user=blocked_user.id): + self.client.force_authenticate(blocked_user) + response = self.client.get(f"/vacancies/{self.vacancy.id}/responses/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + for manager in (self.leader, make_staff(), make_staff(superuser=True)): + with self.subTest(manager=manager.id): + self.client.force_authenticate(manager) + response = self.client.get(f"/vacancies/{self.vacancy.id}/responses/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["id"], self.response.id) + + def test_manager_contract_contains_required_fields_and_no_private_data(self): + self.client.force_authenticate(self.leader) + + response = self.client.get(f"/vacancies/{self.vacancy.id}/responses/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + item = response.data[0] + self.assertEqual( + set(item), + { + "id", + "user", + "why_me", + "accompanying_file", + "is_approved", + "vacancy", + "datetime_created", + "datetime_updated", + }, + ) + self.assertEqual(item["vacancy"], self.vacancy.id) + self.assertEqual(item["accompanying_file"]["name"], "cv") + self.assertEqual(item["user"]["specialization"]["id"], self.specialization.id) + self.assertEqual( + item["user"]["specialization"]["category"]["name"], + "Design", + ) + assert_private_fields_absent(self, response.data) + + def test_legacy_project_response_list_requires_manager(self): + collaborator = create_user(prefix="collaborator") + Collaborator.objects.create( + project=self.project, + user=collaborator, + role="Developer", + ) + outsider = create_user(prefix="outsider") + + for blocked_user in (outsider, collaborator): + with self.subTest(user=blocked_user.id): + self.client.force_authenticate(blocked_user) + response = self.client.get(f"/projects/{self.project.id}/responses/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + for manager in (self.leader, make_staff(), make_staff(superuser=True)): + with self.subTest(manager=manager.id): + self.client.force_authenticate(manager) + response = self.client.get(f"/projects/{self.project.id}/responses/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["id"], self.response.id) + assert_private_fields_absent(self, response.data) + + def test_manager_response_query_count_is_constant(self): + def query_count(candidate_count): + vacancy = create_vacancy( + project=create_project(leader=self.leader), + role=f"Role {candidate_count}", + ) + for index in range(candidate_count): + candidate = create_user(prefix=f"candidate-{candidate_count}-{index}") + candidate.skills.create(skill=create_skill(name=f"Skill {index}")) + create_vacancy_response(user=candidate, vacancy=vacancy) + self.client.force_authenticate(self.leader) + with CaptureQueriesContext(connection) as queries: + response = self.client.get(f"/vacancies/{vacancy.id}/responses/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + return len(queries) + + self.assertEqual(query_count(1), query_count(5)) + + +class VacancyApplicantStateTests(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=user) + return self.client.get(f"/vacancies/{(vacancy or self.vacancy).id}/") + + def test_outsider_state_changes_after_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_closed_and_anonymous_states(self): + outsider = create_user(prefix="outsider") + closed = create_vacancy(is_active=False) + closed_response = self.get_detail(outsider, closed) + self.assertFalse(closed_response.data["can_respond"]) + + anonymous_response = self.get_detail(None) + self.assertFalse(anonymous_response.data["has_responded"]) + self.assertFalse(anonymous_response.data["can_respond"]) + self.assertFalse(anonymous_response.data["can_manage_responses"]) + + +class VacancyResponseDecisionContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("vacancy.response_services.send_email.delay") + def test_accept_closes_vacancy_adds_collaborator_and_declines_others( + self, + send_email, + ): + leader = create_user(prefix="leader") + accepted_user = create_user(prefix="accepted") + rejected_user = create_user(prefix="rejected") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project, role="Designer") + accepted = create_vacancy_response(user=accepted_user, vacancy=vacancy) + rejected = create_vacancy_response(user=rejected_user, vacancy=vacancy) + self.client.force_authenticate(leader) + + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post(f"/vacancies/responses/{accepted.id}/accept/") + + accepted.refresh_from_db() + rejected.refresh_from_db() + vacancy.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(accepted.is_approved) + self.assertFalse(rejected.is_approved) + self.assertFalse(vacancy.is_active) + self.assertTrue( + Collaborator.objects.filter( + project=project, + user=accepted_user, + role="Designer", + ).exists() + ) + self.assertEqual( + Collaborator.objects.filter(project=project, user=accepted_user).count(), + 1, + ) + self.assertEqual(send_email.call_count, 2) + + repeated = self.client.post(f"/vacancies/responses/{accepted.id}/accept/") + self.assertEqual(repeated.status_code, status.HTTP_400_BAD_REQUEST) + + def test_outsider_cannot_accept_or_decline(self): + vacancy_response = create_vacancy_response() + self.client.force_authenticate(create_user(prefix="outsider")) + + for action in ("accept", "decline"): + with self.subTest(action=action): + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/{action}/" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + vacancy_response.refresh_from_db() + self.assertIsNone(vacancy_response.is_approved) + + def test_staff_and_superuser_can_resolve_response(self): + scenarios = ( + (make_staff(), "accept", True), + (make_staff(superuser=True), "decline", False), + ) + + for manager, action, expected_status in scenarios: + with self.subTest(action=action): + vacancy_response = create_vacancy_response() + self.client.force_authenticate(manager) + + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/{action}/" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + vacancy_response.refresh_from_db() + self.assertEqual(vacancy_response.is_approved, expected_status) + + def test_processed_response_cannot_be_processed_again(self): + leader = create_user(prefix="leader") + vacancy = create_vacancy(project=create_project(leader=leader)) + vacancy_response = create_vacancy_response(vacancy=vacancy) + self.client.force_authenticate(leader) + + declined = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/decline/" + ) + self.assertEqual(declined.status_code, status.HTTP_200_OK) + for action in ("accept", "decline"): + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/{action}/" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_accept_rolls_back_if_collaborator_creation_fails(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project) + vacancy_response = create_vacancy_response(vacancy=vacancy) + self.client.force_authenticate(leader) + + with patch( + "vacancy.response_services.Collaborator.objects.create", + side_effect=RuntimeError("database error"), + ): + with self.assertRaises(RuntimeError): + self.client.post(f"/vacancies/responses/{vacancy_response.id}/accept/") + + vacancy.refresh_from_db() + vacancy_response.refresh_from_db() + self.assertTrue(vacancy.is_active) + self.assertIsNone(vacancy_response.is_approved) + + +class VacancyResponseSelfContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_self_endpoint_requires_auth_and_returns_only_own_safe_data(self): + self.assertEqual( + self.client.get("/vacancies/responses/self").status_code, + status.HTTP_401_UNAUTHORIZED, + ) + applicant = create_user(prefix="applicant") + own = create_vacancy_response(user=applicant) + create_vacancy_response() + self.client.force_authenticate(applicant) + + response = self.client.get("/vacancies/responses/self") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data["results"]], [own.id]) + self.assertNotIn("user", response.data["results"][0]) + self.assertEqual( + response.data["results"][0]["vacancy"]["project"]["id"], + own.vacancy.project_id, + ) + assert_private_fields_absent(self, response.data) diff --git a/vacancy/tests/test_vacancy_responses_api.py b/vacancy/tests/test_vacancy_responses_api.py index ed28e868..8d952214 100644 --- a/vacancy/tests/test_vacancy_responses_api.py +++ b/vacancy/tests/test_vacancy_responses_api.py @@ -19,21 +19,22 @@ class VacancyResponseAPITests(TestCase): def setUp(self): self.client = APIClient() - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_user_can_apply_to_active_vacancy(self, send_email_delay): user = create_user(prefix="applicant") vacancy = create_vacancy(role="Apply vacancy", is_active=True) file = create_user_file(user=user) self.client.force_authenticate(user) - response = self.client.post( - f"/vacancies/{vacancy.id}/responses/", - { - "why_me": "Есть опыт", - "accompanying_file": file.link, - }, - format="json", - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + { + "why_me": "Есть опыт", + "accompanying_file": file.link, + }, + format="json", + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) vacancy_response = VacancyResponse.objects.get() @@ -42,7 +43,7 @@ def test_user_can_apply_to_active_vacancy(self, send_email_delay): self.assertEqual(vacancy_response.accompanying_file, file) send_email_delay.assert_called_once() - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_user_cannot_apply_to_closed_vacancy(self, send_email_delay): user = create_user(prefix="applicant") vacancy = create_vacancy(is_active=False) @@ -58,7 +59,7 @@ def test_user_cannot_apply_to_closed_vacancy(self, send_email_delay): self.assertFalse(VacancyResponse.objects.exists()) send_email_delay.assert_not_called() - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_user_cannot_apply_twice_to_same_vacancy(self, send_email_delay): user = create_user(prefix="applicant") vacancy = create_vacancy() @@ -79,6 +80,7 @@ def test_response_list_returns_responses_for_vacancy(self): vacancy = create_vacancy() target_response = create_vacancy_response(vacancy=vacancy, why_me="Target") create_vacancy_response(why_me="Other") + self.client.force_authenticate(vacancy.project.leader) response = self.client.get(f"/vacancies/{vacancy.id}/responses/") @@ -116,7 +118,7 @@ class VacancyResponseDecisionAPITests(TestCase): def setUp(self): self.client = APIClient() - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_project_leader_can_accept_response(self, send_email_delay): leader = create_user(prefix="leader") applicant = create_user(prefix="applicant") @@ -125,9 +127,10 @@ def test_project_leader_can_accept_response(self, send_email_delay): vacancy_response = create_vacancy_response(user=applicant, vacancy=vacancy) self.client.force_authenticate(leader) - response = self.client.post( - f"/vacancies/responses/{vacancy_response.id}/accept/" - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/accept/" + ) vacancy_response.refresh_from_db() vacancy.refresh_from_db() @@ -144,7 +147,7 @@ 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.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_project_leader_can_decline_response(self, send_email_delay): leader = create_user(prefix="leader") applicant = create_user(prefix="applicant") @@ -153,31 +156,30 @@ def test_project_leader_can_decline_response(self, send_email_delay): vacancy_response = create_vacancy_response(user=applicant, vacancy=vacancy) self.client.force_authenticate(leader) - response = self.client.post( - f"/vacancies/responses/{vacancy_response.id}/decline/" - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/decline/" + ) vacancy_response.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(vacancy_response.is_approved) self.assertEqual(send_email_delay.call_args.args[0]["user_id"], applicant.id) - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_non_leader_cannot_accept_response(self, send_email_delay): vacancy_response = create_vacancy_response() outsider = create_user(prefix="outsider") self.client.force_authenticate(outsider) - response = self.client.post( - f"/vacancies/responses/{vacancy_response.id}/accept/" - ) + response = self.client.post(f"/vacancies/responses/{vacancy_response.id}/accept/") vacancy_response.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertIsNone(vacancy_response.is_approved) send_email_delay.assert_not_called() - @patch("vacancy.views.send_email.delay") + @patch("vacancy.response_services.send_email.delay") def test_cannot_accept_or_decline_already_processed_response(self, send_email_delay): leader = create_user(prefix="leader") project = create_project(leader=leader) diff --git a/vacancy/views.py b/vacancy/views.py index 2fe403f8..a424eb73 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -1,34 +1,37 @@ +from django.db import transaction from django.db.models import QuerySet +from django.http import Http404 from django_filters import rest_framework as filters from django.shortcuts import get_object_or_404 from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import generics, mixins, permissions, status +from rest_framework import generics, mixins, permissions, serializers, status from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.response import Response - -from projects.models import Collaborator, Project from vacancy.filters import VacancyFilter -from vacancy.mapping import CeleryEmailParams, MessageTypeEnum from vacancy.models import Vacancy, VacancyResponse from vacancy.pagination import VacancyPagination -from vacancy.permissions import ( - IsProjectLeaderForVacancyResponse, - IsVacancyResponseOwnerOrReadOnly, - IsVacancyProjectLeader, +from vacancy.permissions import IsVacancyProjectLeader +from vacancy.response_services import ( + accept_vacancy_response, + create_vacancy_response, + decline_vacancy_response, ) from vacancy.serializers import ( VacancyDetailSerializer, - VacancyResponseAcceptSerializer, - VacancyResponseDetailSerializer, - VacancyResponseDetailReadSerializer, - VacancyResponseFullFileInfoListSerializer, - VacancyResponseListSerializer, ProjectVacancyCreateListSerializer, + VacancyResponseManagerSerializer, + VacancyResponseSelfSerializer, + VacancyResponseWriteSerializer, +) +from vacancy.selectors import ( + can_manage_vacancy, + get_response_queryset, + get_self_response_queryset, + with_applicant_state, ) from vacancy.services import update_vacancy_skills -from vacancy.tasks import send_email @swagger_auto_schema( @@ -53,6 +56,9 @@ class VacancyDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = VacancyDetailSerializer permission_classes = [IsVacancyProjectLeader] + def get_queryset(self): + return with_applicant_state(super().get_queryset(), self.request.user) + def patch(self, request, *args, **kwargs): update_vacancy_skills(request, self.get_object()) return self.partial_update(request, *args, **kwargs) @@ -72,162 +78,148 @@ def put(self, request, *args, **kwargs): return self.update(request, *args, **kwargs) -class VacancyResponseList( - mixins.ListModelMixin, mixins.CreateModelMixin, GenericAPIView -): - permission_classes = [permissions.IsAuthenticatedOrReadOnly] - serializer_class = VacancyResponseListSerializer +class VacancyResponseList(mixins.ListModelMixin, mixins.CreateModelMixin, GenericAPIView): + permission_classes = [permissions.IsAuthenticated] + serializer_class = VacancyResponseWriteSerializer def get_serializer_class(self): if self.request.method == "GET": - return VacancyResponseFullFileInfoListSerializer + return VacancyResponseManagerSerializer return super().get_serializer_class() def get(self, request, *args, **kwargs): - """retrieve all responses for certain vacancy""" - # note: doesn't raise an error if the vacancy_id passed is non-existent + vacancy = get_object_or_404( + Vacancy.objects.select_related("project"), + pk=self.kwargs["vacancy_id"], + ) + if not can_manage_vacancy(request.user, vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) return self.list(request, *args, **kwargs) def get_queryset(self): - return VacancyResponse.objects.get_vacancy_response_for_list_view().filter( - vacancy__id=self.kwargs["vacancy_id"] - ) + return get_response_queryset().filter(vacancy_id=self.kwargs["vacancy_id"]) def post(self, request, vacancy_id): - vacancy = get_object_or_404(Vacancy, pk=vacancy_id) - if not vacancy.is_active: - return Response( - "You cannot apply for a closed vacancy", status.HTTP_400_BAD_REQUEST - ) - - try: - request.data["user_id"] = self.request.user.id - request.data["vacancy"] = vacancy_id - except AttributeError: - pass + get_object_or_404(Vacancy.objects.only("id"), pk=vacancy_id) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - - vacancy_response = self.create(request, vacancy_id) - - queryset = VacancyResponse.objects.get_vacancy_response_for_email().get( - vacancy__id=self.kwargs["vacancy_id"], user=self.request.user + vacancy_response = create_vacancy_response( + vacancy_id=vacancy_id, + user=request.user, + validated_data=serializer.validated_data, ) - project = queryset.vacancy.project - - send_email.delay( - CeleryEmailParams( - message_type=MessageTypeEnum.RESPONDED.value, - user_id=project.leader.id, - project_name=project.name, - project_id=project.id, - vacancy_role=queryset.vacancy.role, - schema_id=2, - ) + return Response( + VacancyResponseSelfSerializer( + vacancy_response, + context={"request": request}, + ).data, + status=status.HTTP_201_CREATED, ) - return vacancy_response - class VacancyResponseDetail(generics.RetrieveUpdateDestroyAPIView): - queryset = VacancyResponse.objects.get_vacancy_response_for_detail_view() - serializer_class = VacancyResponseDetailSerializer - permission_classes = [IsVacancyResponseOwnerOrReadOnly] + queryset = VacancyResponse.objects.all() + serializer_class = VacancyResponseWriteSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_object(self): + response = get_object_or_404(get_response_queryset(), pk=self.kwargs["pk"]) + is_owner = response.user_id == self.request.user.id + is_manager = can_manage_vacancy(self.request.user, response.vacancy) + if not (is_owner or is_manager): + raise Http404 + if self.request.method not in permissions.SAFE_METHODS: + if not is_owner: + self.permission_denied(self.request) + if response.is_approved is not None: + raise serializers.ValidationError("Обработанный отклик нельзя изменить.") + return response + + def retrieve(self, request, *args, **kwargs): + response = self.get_object() + serializer_class = ( + VacancyResponseSelfSerializer + if response.user_id == request.user.id + else VacancyResponseManagerSerializer + ) + return Response(serializer_class(response, context={"request": request}).data) - def get_serializer_class(self): - if self.request.method == "GET": - return VacancyResponseDetailReadSerializer - return super().get_serializer_class() + def update(self, request, *args, **kwargs): + visible_instance = self.get_object() + with transaction.atomic(): + instance = VacancyResponse.objects.select_for_update().get( + pk=visible_instance.pk + ) + if instance.is_approved is not None: + raise serializers.ValidationError("Обработанный отклик нельзя изменить.") + serializer = self.get_serializer( + instance, + data=request.data, + partial=kwargs.pop("partial", False), + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response( + VacancyResponseSelfSerializer( + instance, + context={"request": request}, + ).data + ) + + def destroy(self, request, *args, **kwargs): + visible_instance = self.get_object() + with transaction.atomic(): + instance = VacancyResponse.objects.select_for_update().get( + pk=visible_instance.pk + ) + if instance.is_approved is not None: + raise serializers.ValidationError("Обработанный отклик нельзя отозвать.") + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) class VacancyResponseAccept(generics.GenericAPIView): - queryset = VacancyResponse.objects.get_vacancy_response_for_detail_view() - serializer_class = VacancyResponseAcceptSerializer - permission_classes = [IsProjectLeaderForVacancyResponse] + queryset = VacancyResponse.objects.all() + serializer_class = VacancyResponseManagerSerializer + permission_classes = [permissions.IsAuthenticated] def post(self, request, pk): - """accepting the vacancy""" - vacancy_request = self.get_object() - if vacancy_request.is_approved is not None: - # can't accept a vacancy that's already declined/accepted - return Response(status=status.HTTP_400_BAD_REQUEST) - vacancy_request.is_approved = True - - vacancy = vacancy_request.vacancy - project_add_in: Project = vacancy.project - user_to_add = vacancy_request.user - role_add_as: str = vacancy.role - - # check if this person already has a collaborator role in this project - if Collaborator.objects.filter( - project=project_add_in, user=user_to_add - ).exists(): - return Response( - "You already work for this project, you can't accept a vacancy here", - status=status.HTTP_400_BAD_REQUEST, - ) - - new_collaborator = Collaborator( - user=user_to_add, - project=project_add_in, - role=role_add_as, - ) - - send_email.delay( - CeleryEmailParams( - message_type=MessageTypeEnum.ACCEPTED.value, - user_id=user_to_add.id, - project_name=project_add_in.name, - project_id=project_add_in.id, - vacancy_role=role_add_as, - schema_id=2, - ) + vacancy_response = get_object_or_404(get_response_queryset(), pk=pk) + if not can_manage_vacancy(request.user, vacancy_response.vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) + accept_vacancy_response(pk, actor=request.user) + accepted = get_response_queryset().get(pk=pk) + return Response( + VacancyResponseManagerSerializer( + accepted, + context={"request": request}, + ).data ) - # After acceptance, closes the vacancy. - vacancy.is_active = False - vacancy.save() - new_collaborator.save() - vacancy_request.save() - return Response(status=status.HTTP_200_OK) class VacancyResponseDecline(generics.GenericAPIView): - queryset = VacancyResponse.objects.get_vacancy_response_for_detail_view() - serializer_class = VacancyResponseAcceptSerializer - permission_classes = [IsProjectLeaderForVacancyResponse] + queryset = VacancyResponse.objects.all() + serializer_class = VacancyResponseManagerSerializer + permission_classes = [permissions.IsAuthenticated] def post(self, request, pk): - """declining the vacancy""" - vacancy_request = self.get_object() - if vacancy_request.is_approved is not None: - # can't decline a vacancy that's already declined/accepted - return Response(status=status.HTTP_400_BAD_REQUEST) - vacancy_request.is_approved = False - vacancy_request.save() - - project = vacancy_request.vacancy.project - send_email.delay( - CeleryEmailParams( - message_type=MessageTypeEnum.REJECTED.value, - user_id=vacancy_request.user.id, - project_name=project.name, - project_id=project.id, - vacancy_role=vacancy_request.vacancy.role, - schema_id=2, - ) + vacancy_response = get_object_or_404(get_response_queryset(), pk=pk) + if not can_manage_vacancy(request.user, vacancy_response.vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) + decline_vacancy_response(pk, actor=request.user) + declined = get_response_queryset().get(pk=pk) + return Response( + VacancyResponseManagerSerializer( + declined, + context={"request": request}, + ).data ) - return Response(status=status.HTTP_200_OK) - class UserVacancyResponses(ListAPIView): - serializer_class = VacancyResponseFullFileInfoListSerializer - permission_classes = [IsVacancyResponseOwnerOrReadOnly] + serializer_class = VacancyResponseSelfSerializer + permission_classes = [permissions.IsAuthenticated] pagination_class = VacancyPagination def get_queryset(self) -> QuerySet[VacancyResponse]: - return ( - VacancyResponse.objects.get_vacancy_response_for_list_view() - .filter(user=self.request.user) - .order_by("datetime_created") - ) + return get_self_response_queryset().filter(user=self.request.user)