diff --git a/docs/modules/vacancy.md b/docs/modules/vacancy.md index a9b2b416..cdc0e743 100644 --- a/docs/modules/vacancy.md +++ b/docs/modules/vacancy.md @@ -1,5 +1,9 @@ # Vacancy +> Актуальный безопасный контракт для React, права и транзакционные правила +> описаны в [vacancies-react-api.md](../vacancies-react-api.md). Ниже сохранено +> описание legacy-контура и исторических ограничений модуля. + ## Назначение Vacancy отвечает за вакансии внутри проектов Procollab: публикацию ролей в diff --git a/docs/vacancies-react-api.md b/docs/vacancies-react-api.md new file mode 100644 index 00000000..fede48fc --- /dev/null +++ b/docs/vacancies-react-api.md @@ -0,0 +1,131 @@ +# API вакансий для React + +## Назначение + +Контур вакансий позволяет искать открытые роли в публичных проектах, отправлять +и отзывать собственный отклик, а руководителю проекта — рассматривать кандидатов. +Модели `Vacancy` и `VacancyResponse` и legacy API сохранены без миграций. + +## Публичный каталог + +`GET /vacancies/` возвращает только активные вакансии проектов, у которых +`draft=false` и `is_public=true`. Возраст вакансии выдачу не ограничивает. + +Поддерживаются параметры: + +- `search` — поиск без учёта регистра по роли, специализации, описанию и названию + проекта; +- `required_experience` — один или несколько вариантов опыта; +- `work_format` — один или несколько форматов работы; +- `work_schedule` — один или несколько вариантов графика; +- `salary_min` и `salary_max` — границы указанного вознаграждения; +- `project_id` — проект; +- `limit` и `offset` — пагинация. + +Фильтр `is_active=false` не позволяет получить закрытые вакансии через публичный +список. Стабильная сортировка выполняется по дате создания и идентификатору в +обратном порядке. + +`GET /vacancies//` открывает активную вакансию публичного +опубликованного проекта. Закрытую вакансию могут получить только руководитель её +проекта, staff и superuser. Для остальных закрытая или связанная с недоступным +проектом запись скрывается через `404`. + +## Отклик пользователя + +`POST /vacancies//responses/` требует авторизации и принимает: + +```json +{ + "why_me": "Почему я хочу присоединиться к проекту", + "accompanying_file": "https://cdn.example.test/cv.pdf" +} +``` + +Пользователь всегда определяется из сессии. Значение из payload не используется. +Сопроводительный `UserFile` должен принадлежать текущему пользователю. + +Отклик запрещён, если: + +- вакансия закрыта или её проект не является публичным и опубликованным; +- пользователь уже является руководителем или участником проекта; +- пользователь уже откликался на эту вакансию. + +Проверка и создание выполняются в одной транзакции под блокировкой вакансии, +поэтому параллельные запросы не создают два отклика. Уведомление руководителю +отправляется только после успешного завершения транзакции. + +## Свои отклики + +`GET /vacancies/responses/self` возвращает пагинированный список только текущего +пользователя вместе с безопасными данными вакансии и проекта. + +Статус определяется полем `is_approved`: + +- `null` — на рассмотрении; +- `true` — принят; +- `false` — отклонён. + +Пока значение равно `null`, владелец может обновить сообщение или файл через +`PATCH /vacancies/responses//` и отозвать отклик через +`DELETE /vacancies/responses//`. `null` в поле +`accompanying_file` удаляет связь с файлом. Обработанный отклик остаётся в +истории и больше не редактируется и не удаляется. + +Detail отклика доступен только его владельцу, руководителю соответствующего +проекта, staff и superuser. Для постороннего пользователя используется безопасный +`404`. + +## Работа руководителя + +`GET /vacancies//responses/` доступен руководителю проекта, staff и +superuser. Остальные авторизованные пользователи получают `403`, неизвестная +вакансия — `404`. + +Карточка кандидата построена по явному публичному allow-list и содержит только: + +- идентификатор; +- имя и фамилию; +- аватар; +- специализацию; +- публичные навыки; +- краткое описание профиля. + +Email, телефон, дата рождения, административные признаки и другие закрытые поля +не возвращаются. Сопроводительный файл сериализуется без данных его владельца и +доступен только кандидату и пользователям с правом управления вакансией. + +Решение принимается через: + +- `POST /vacancies/responses//accept/`; +- `POST /vacancies/responses//decline/`. + +При принятии одной атомарной операцией: + +1. отклик получает `is_approved=true`; +2. кандидат добавляется в `Collaborator` с ролью из вакансии; +3. вакансия закрывается; +4. остальные ожидающие отклики получают `is_approved=false`; +5. после commit отправляются уведомления принятому и отклонённым кандидатам. + +Повторная обработка уже принятого или отклонённого отклика возвращает `400` и не +создаёт дубликат участника. + +## Закрытие, возобновление и удаление + +- `POST /vacancies//close/` закрывает вакансию и отклоняет ожидающие + отклики; +- `POST /vacancies//reopen/` возобновляет вакансию только в публичном + опубликованном проекте; +- `DELETE /vacancies//` удаляет вакансию только при полном отсутствии + откликов. + +Эти операции доступны руководителю проекта, staff и superuser. История откликов +не удаляется вместе с вакансией. Проверка удаления и само удаление выполняются +под блокировкой одной транзакции. + +## Совместимость и ограничения + +Существующие модели, миграции, CRUD вакансий, workspace detail проекта, +служебные записи ленты и legacy endpoints не переименованы. Контур не добавляет +чат с кандидатом, собеседования, рейтинг кандидатов или внешние интеграции. diff --git a/vacancy/filters.py b/vacancy/filters.py index 17e4159c..a654eede 100644 --- a/vacancy/filters.py +++ b/vacancy/filters.py @@ -1,4 +1,4 @@ -from django.db.models import QuerySet, Q, F +from django.db.models import Q, QuerySet from django_filters import rest_framework as filters @@ -11,91 +11,62 @@ def project_id_filter(queryset, name, value) -> QuerySet: - return queryset.filter( - **{ - "project_id": value[0], - } - ) + return queryset.filter(project_id=value) class VacancyFilter(filters.FilterSet): - """Filter for Vacancies - - Adds filtering to DRF list retrieve views - - Parameters to filter by: - project_id (int), - is_active (boolean) (default to True if not set otherwise), - required_experience (multiple choise) - work_schedule (multiple choise) - work_format (multiple choise) - salary_min (int) - salary_max (int) - - Examples: - ?project_id=1 equals to .filter(project_id=1) - (no params passed) equals to .filter(is_active=True) - ?is_active=false equals to .filter(is_active=False) - ?work_schedule=full_time&work_schedule=part_time equals to .filter(required_experience__in=value) - ?salary_min=100&salary_max=150 equals to .filter(salary__range=(100, 150)) - """ - - def __init__(self, *args, **kwargs): - """if is_active filter is not passed, default to True""" - super().__init__(*args, **kwargs) - if self.data.get("is_active") is None: - self.data = dict(self.data) - self.data["is_active"] = True + """Фильтрует уже ограниченный безопасный queryset публичного каталога.""" def filter_by_experience( self, queryset: QuerySet[Vacancy], name, value: list[str] ) -> QuerySet[Vacancy]: - return queryset.filter( - Q(required_experience__in=value) | Q(required_experience=None) - ).order_by(F("required_experience").asc(nulls_last=True)) + return queryset.filter(required_experience__in=value) def filter_by_schedule( self, queryset: QuerySet[Vacancy], name, value: list[str] ) -> QuerySet[Vacancy]: - return queryset.filter( - Q(work_schedule__in=value) | Q(work_schedule=None) - ).order_by(F("work_schedule").asc(nulls_last=True)) + return queryset.filter(work_schedule__in=value) def filter_by_format( self, queryset: QuerySet[Vacancy], name, value: list[str] ) -> QuerySet[Vacancy]: - return queryset.filter(Q(work_format__in=value) | Q(work_format=None)).order_by( - F("work_format").asc(nulls_last=True) - ) + return queryset.filter(work_format__in=value) def filter_by_salary_min( - self, queryset: QuerySet[Vacancy], name, value: list[str] + self, queryset: QuerySet[Vacancy], name, value: str ) -> QuerySet[Vacancy]: try: - min_salary = int(value[0]) - return queryset.filter(Q(salary__gte=min_salary) | Q(salary=None)).order_by( - F("salary").asc(nulls_last=True) - ) - except ValueError: + min_salary = int(value) + return queryset.filter(salary__gte=min_salary) + except (TypeError, ValueError): return queryset def filter_by_salary_max( - self, queryset: QuerySet[Vacancy], name, value: list[str] + self, queryset: QuerySet[Vacancy], name, value: str ) -> QuerySet[Vacancy]: try: - max_salary = int(value[0]) - return queryset.filter(Q(salary__lte=max_salary) | Q(salary=None)).order_by( - F("salary").asc(nulls_last=True) - ) - except ValueError: + max_salary = int(value) + return queryset.filter(salary__lte=max_salary) + except (TypeError, ValueError): return queryset def filter_by_role( - self, queryset: QuerySet[Vacancy], name, value: list[str] + self, queryset: QuerySet[Vacancy], name, value: str ) -> QuerySet[Vacancy]: - if not value: + return queryset.filter(role__icontains=value) + + def filter_by_search(self, queryset, name, value): + """Ищет вакансию по роли, специализации, описанию и названию проекта.""" + + search = value.strip() + if not search: return queryset - return queryset.filter(role__icontains=value[0]) + return queryset.filter( + Q(role__icontains=search) + | Q(specialization__icontains=search) + | Q(description__icontains=search) + | Q(project__name__icontains=search) + ) project_id = filters.Filter(method=project_id_filter) is_active = filters.BooleanFilter(field_name="is_active") @@ -114,6 +85,7 @@ def filter_by_role( ) role_contains = filters.Filter(method="filter_by_role") + search = filters.CharFilter(method="filter_by_search") salary_min = filters.Filter(method="filter_by_salary_min") salary_max = filters.Filter(method="filter_by_salary_max") @@ -121,6 +93,7 @@ class Meta: model = Vacancy fields = ( "role_contains", + "search", "project_id", "is_active", "required_experience", diff --git a/vacancy/permissions.py b/vacancy/permissions.py index c145ebb2..78fbf0d9 100644 --- a/vacancy/permissions.py +++ b/vacancy/permissions.py @@ -9,18 +9,28 @@ def has_object_permission(self, request, view, obj): class IsVacancyProjectLeader(BasePermission): - """ - Allows access to vacancy update only to project leader. - """ + """Разрешает изменение вакансии руководителю проекта и администрации.""" def has_object_permission(self, request, view, obj): - if request.method in SAFE_METHODS or obj.project.leader == request.user: + if request.method in SAFE_METHODS: + return True + user = request.user + if ( + obj.project.leader == user + or getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + ): return True return False class IsProjectLeaderForVacancyResponse(BasePermission): def has_object_permission(self, request, view, obj): - if obj.vacancy.project.leader == request.user: + user = request.user + if ( + obj.vacancy.project.leader == user + or getattr(user, "is_staff", False) + or getattr(user, "is_superuser", False) + ): return True return False diff --git a/vacancy/response_services.py b/vacancy/response_services.py new file mode 100644 index 00000000..a12325b1 --- /dev/null +++ b/vacancy/response_services.py @@ -0,0 +1,212 @@ +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: + """Создаёт отклик под блокировкой вакансии и не доверяет user из payload.""" + + vacancy = ( + Vacancy.objects.select_for_update().select_related("project").get(pk=vacancy_id) + ) + 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 + + +@transaction.atomic +def close_vacancy(vacancy_id: int, *, actor) -> Vacancy: + """Закрывает вакансию и сохраняет историю, отклоняя ожидающие отклики.""" + + vacancy = ( + Vacancy.objects.select_for_update().select_related("project").get(pk=vacancy_id) + ) + _ensure_can_manage(vacancy, actor) + if vacancy.is_active: + vacancy.is_active = False + vacancy.save(update_fields=("is_active", "datetime_closed", "datetime_updated")) + VacancyResponse.objects.filter( + vacancy=vacancy, + is_approved__isnull=True, + ).update(is_approved=False, datetime_updated=timezone.now()) + return vacancy + + +@transaction.atomic +def reopen_vacancy(vacancy_id: int, *, actor) -> Vacancy: + """Возобновляет вакансию только у опубликованного публичного проекта.""" + + vacancy = ( + Vacancy.objects.select_for_update().select_related("project").get(pk=vacancy_id) + ) + _ensure_can_manage(vacancy, actor) + if vacancy.project.draft or not vacancy.project.is_public: + raise serializers.ValidationError( + "Возобновить вакансию можно только в опубликованном публичном проекте." + ) + if not vacancy.is_active: + vacancy.is_active = True + vacancy.save(update_fields=("is_active", "datetime_closed", "datetime_updated")) + return vacancy + + +@transaction.atomic +def delete_vacancy_without_responses(vacancy_id: int, *, actor) -> None: + """Удаляет вакансию под блокировкой, не допуская потери истории откликов.""" + + vacancy = ( + Vacancy.objects.select_for_update().select_related("project").get(pk=vacancy_id) + ) + _ensure_can_manage(vacancy, actor) + if VacancyResponse.objects.filter(vacancy=vacancy).exists(): + raise serializers.ValidationError( + "Вакансию с откликами нельзя удалить. Закройте её." + ) + vacancy.delete() diff --git a/vacancy/selectors.py b/vacancy/selectors.py new file mode 100644 index 00000000..b4c8ae5f --- /dev/null +++ b/vacancy/selectors.py @@ -0,0 +1,88 @@ +from django.db.models import Count, Prefetch, Q, QuerySet + +from core.models import SkillToObject +from users.public_profile_selectors import get_public_profiles_queryset +from vacancy.models import Vacancy, VacancyResponse + + +def _vacancy_queryset() -> QuerySet[Vacancy]: + skills = SkillToObject.objects.select_related("skill", "skill__category") + return ( + Vacancy.objects.select_related( + "project", + "project__industry", + ) + .prefetch_related( + Prefetch("required_skills", queryset=skills), + "project__links", + ) + .annotate( + pending_response_count=Count( + "vacancy_requests", + filter=Q(vacancy_requests__is_approved__isnull=True), + ) + ) + ) + + +def get_public_vacancies_queryset() -> QuerySet[Vacancy]: + """Возвращает только активные вакансии опубликованных публичных проектов.""" + + return ( + _vacancy_queryset() + .filter( + is_active=True, + project__draft=False, + project__is_public=True, + ) + .order_by("-datetime_created", "-id") + ) + + +def get_vacancy_queryset() -> QuerySet[Vacancy]: + return _vacancy_queryset().order_by("-datetime_created", "-id") + + +def get_response_queryset() -> QuerySet[VacancyResponse]: + candidate_profiles = get_public_profiles_queryset() + return ( + VacancyResponse.objects.select_related( + "vacancy", + "vacancy__project", + "accompanying_file", + ) + .prefetch_related(Prefetch("user", queryset=candidate_profiles)) + .order_by("datetime_created", "id") + ) + + +def get_self_response_queryset() -> QuerySet[VacancyResponse]: + """Загружает отклики пользователя вместе с карточками вакансий без N+1.""" + + return ( + VacancyResponse.objects.select_related("accompanying_file") + .prefetch_related( + Prefetch("vacancy", queryset=_vacancy_queryset()), + ) + .order_by("datetime_created", "id") + ) + + +def is_staff(user) -> bool: + return bool( + user + and user.is_authenticated + and (getattr(user, "is_staff", False) or getattr(user, "is_superuser", False)) + ) + + +def can_manage_vacancy(user, vacancy: Vacancy) -> bool: + return is_staff(user) or ( + bool(user and user.is_authenticated) and vacancy.project.leader_id == user.id + ) + + +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 5dda57c7..ad758b71 100644 --- a/vacancy/serializers.py +++ b/vacancy/serializers.py @@ -11,6 +11,7 @@ from projects.models import Project from projects.validators import validate_project from users.serializers import UserDetailSerializer +from users.public_profile_serializers import PublicProfileListSerializer from vacancy.constants import WorkExperience, WorkFormat, WorkSchedule from vacancy.models import Vacancy, VacancyResponse @@ -55,21 +56,21 @@ def to_representation(self, instance): representation["required_experience"] = WorkExperience.to_display( instance.required_experience ) - representation["work_schedule"] = WorkSchedule.to_display( - instance.work_schedule - ) + representation["work_schedule"] = WorkSchedule.to_display(instance.work_schedule) representation["work_format"] = WorkFormat.to_display(instance.work_format) return representation class AbstractVacancyReadOnlyFields(serializers.Serializer): - """Abstract read-only fields for Vacancy.""" + """Общие вычисляемые поля read-only контрактов вакансии.""" datetime_closed = serializers.DateTimeField(read_only=True) 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() @@ -99,7 +100,6 @@ class ProjectVacancyListSerializer( AbstractVacancyReadOnlyFields, RequiredSkillsSerializerMixin[Vacancy], ): - class Meta: model = Vacancy fields = [ @@ -175,7 +175,6 @@ class VacancyListSerializer( RequiredSkillsSerializerMixin[Vacancy], AbstractVacancyReadOnlyFields, ): - class Meta: model = Vacancy fields = [ @@ -189,9 +188,40 @@ class Meta: "response_count", "date_create_time", ] - read_only_fields = [ + + +class VacancyCatalogSerializer( + 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", ] + read_only_fields = fields # TODO FIX This (Copied serializer from projects) - hotfix: rename @@ -237,10 +267,10 @@ class ProjectVacancyCreateListSerializer( AbstractVacancyEnumFields, RequiredSkillsWriteSerializerMixin[Vacancy], ): - def create(self, validated_data): project = validated_data["project"] - if project.leader != self.context["request"].user: + user = self.context["request"].user + if project.leader != user and not (user.is_staff or user.is_superuser): raise serializers.ValidationError("You are not the leader of the project") required_skills_ids = validated_data.pop("required_skills_ids") @@ -386,3 +416,82 @@ 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 VacancyCandidateSerializer(PublicProfileListSerializer): + """Безопасная карточка кандидата для руководителя проекта.""" + + class Meta(PublicProfileListSerializer.Meta): + 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 VacancyResponseSelfSerializer(serializers.ModelSerializer): + vacancy = VacancyCatalogSerializer(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/helpers.py b/vacancy/tests/helpers.py index cc337c95..c1118bcb 100644 --- a/vacancy/tests/helpers.py +++ b/vacancy/tests/helpers.py @@ -15,6 +15,8 @@ def create_user( *, prefix: str = "vacancy-user", is_active: bool = True, + is_staff: bool = False, + is_superuser: bool = False, ) -> CustomUser: user = CustomUser.objects.create_user( email=f"{prefix}-{uuid4().hex}@example.com", @@ -24,7 +26,9 @@ def create_user( birthday=date(2000, 1, 1), ) user.is_active = is_active - user.save(update_fields=["is_active"]) + user.is_staff = is_staff + user.is_superuser = is_superuser + user.save(update_fields=["is_active", "is_staff", "is_superuser"]) return user @@ -54,9 +58,10 @@ def create_user_file( user: CustomUser, link: str | None = None, ) -> UserFile: + file_link = link or "https://cdn.example.com/{}.pdf".format(uuid4().hex) return UserFile.objects.create( user=user, - link=link or f"https://cdn.example.com/{uuid4().hex}.pdf", + link=file_link, name="cv", extension="pdf", mime_type="application/pdf", diff --git a/vacancy/tests/test_vacancies_api.py b/vacancy/tests/test_vacancies_api.py index 012ceb8b..c536e93e 100644 --- a/vacancy/tests/test_vacancies_api.py +++ b/vacancy/tests/test_vacancies_api.py @@ -79,16 +79,16 @@ def test_public_list_returns_only_active_vacancies_by_default(self): [active_vacancy.id], ) - def test_list_can_include_inactive_vacancies_by_filter(self): + def test_public_list_cannot_include_inactive_vacancies_by_filter(self): create_vacancy(role="Active vacancy", is_active=True) - inactive_vacancy = create_vacancy(role="Inactive vacancy", is_active=False) + create_vacancy(role="Inactive vacancy", is_active=False) response = self.client.get("/vacancies/", {"is_active": "false"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual( [item["id"] for item in response.data["results"]], - [inactive_vacancy.id], + [], ) def test_list_filters_by_project_role_salary_and_work_conditions(self): @@ -116,8 +116,8 @@ def test_list_filters_by_project_role_salary_and_work_conditions(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual([item["id"] for item in response.data["results"]], [target.id]) - def test_list_excludes_vacancies_older_than_90_days(self): - create_vacancy( + def test_list_includes_active_vacancies_older_than_90_days(self): + old = create_vacancy( role="Old vacancy", datetime_created=timezone.now() - timedelta(days=91), ) @@ -126,7 +126,10 @@ def test_list_excludes_vacancies_older_than_90_days(self): response = self.client.get("/vacancies/") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual([item["id"] for item in response.data["results"]], [fresh.id]) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [fresh.id, old.id], + ) def test_detail_returns_vacancy_with_project_info(self): vacancy = create_vacancy(role="Detail vacancy") diff --git a/vacancy/tests/test_vacancy_contract_api.py b/vacancy/tests/test_vacancy_contract_api.py new file mode 100644 index 00000000..074e996d --- /dev/null +++ b/vacancy/tests/test_vacancy_contract_api.py @@ -0,0 +1,494 @@ +from datetime import timedelta +from unittest.mock import patch + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from projects.models import Collaborator +from vacancy.constants import WorkExperience, WorkFormat, WorkSchedule +from vacancy.models import Vacancy, VacancyResponse +from vacancy.tests.helpers import ( + create_project, + create_skill, + create_user, + create_user_file, + create_vacancy, + create_vacancy_response, +) + + +PRIVATE_FIELDS = { + "auth_token", + "date_joined", + "email", + "groups", + "phone", + "phone_number", + "birthday", + "is_staff", + "is_superuser", + "last_login", + "onboarding_stage", + "password", + "user_permissions", +} + + +def assert_private_fields_absent(test_case, value): + if isinstance(value, dict): + test_case.assertTrue(PRIVATE_FIELDS.isdisjoint(value)) + 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) + + +class VacancyCatalogContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_catalog_isolated_to_active_public_published_projects(self): + visible = create_vacancy(role="Visible") + create_vacancy(project=create_project(draft=True), role="Draft") + create_vacancy(project=create_project(is_public=False), role="Private") + create_vacancy(role="Closed", is_active=False) + + response = self.client.get("/vacancies/", {"is_active": "false"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"], []) + response = self.client.get("/vacancies/") + self.assertEqual([item["id"] for item in response.data["results"]], [visible.id]) + + def test_search_covers_role_specialization_description_and_project_name(self): + project = create_project(name="арктическая платформа") + vacancy = create_vacancy(project=project, role="Python инженер") + Vacancy.objects.filter(pk=vacancy.pk).update( + specialization="Data science", + description="Разработка рекомендательной системы", + ) + for search in ("python", "DATA SCIENCE", "рекомендательной", "арктическая"): + with self.subTest(search=search): + response = self.client.get("/vacancies/", {"search": f" {search} "}) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [vacancy.id], + ) + + def test_filters_and_limit_offset_pagination(self): + target = create_vacancy( + required_experience=WorkExperience.FROM_THREE_YEARS.name.lower(), + work_format=WorkFormat.HYBRID.name.lower(), + work_schedule=WorkSchedule.PART_TIME.name.lower(), + salary=180000, + ) + create_vacancy(salary=90000) + + response = self.client.get( + "/vacancies/", + { + "required_experience": "from_three_years", + "work_format": "hybrid", + "work_schedule": "part_time", + "salary_min": "150000", + "salary_max": "200000", + "limit": "1", + "offset": "0", + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["id"], target.id) + + def test_old_vacancy_is_visible_and_list_query_count_is_constant(self): + old = create_vacancy( + role="Old", + datetime_created=timezone.now() - timedelta(days=120), + ) + skill = create_skill(name="Analytics") + old.required_skills.create(skill=skill) + + def query_count(count): + for index in range(count): + vacancy = create_vacancy(role=f"Vacancy {count}-{index}") + vacancy.required_skills.create( + skill=create_skill(name=f"Skill {count}-{index}") + ) + with CaptureQueriesContext(connection) as queries: + response = self.client.get("/vacancies/", {"limit": "100"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + return len(queries) + + one_count = query_count(1) + five_count = query_count(5) + self.assertLessEqual(five_count, one_count) + + +class VacancyDetailContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_inactive_or_private_detail_is_hidden_from_outsider(self): + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + for vacancy in ( + create_vacancy(is_active=False), + create_vacancy(project=create_project(is_public=False)), + create_vacancy(project=create_project(draft=True)), + ): + with self.subTest(vacancy=vacancy.id): + response = self.client.get(f"/vacancies/{vacancy.id}/") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_leader_staff_and_superuser_can_open_inactive_detail(self): + leader = create_user(prefix="leader") + vacancy = create_vacancy(project=create_project(leader=leader), is_active=False) + for user in ( + leader, + create_user(prefix="staff", is_staff=True), + create_user(prefix="superuser", is_superuser=True), + ): + with self.subTest(user=user.id): + self.client.force_authenticate(user) + self.assertEqual( + self.client.get(f"/vacancies/{vacancy.id}/").status_code, + status.HTTP_200_OK, + ) + + +class VacancyResponseContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("vacancy.response_services.send_email.delay") + def test_request_user_is_used_and_foreign_file_is_rejected(self, send_email): + applicant = create_user(prefix="applicant") + other = create_user(prefix="other") + vacancy = create_vacancy() + foreign_file = create_user_file(user=other) + self.client.force_authenticate(applicant) + + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + { + "user_id": other.id, + "why_me": "Подхожу", + "accompanying_file": foreign_file.link, + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(VacancyResponse.objects.exists()) + own_file = create_user_file(user=applicant) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + { + "user_id": other.id, + "why_me": "Подхожу", + "accompanying_file": own_file.link, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(VacancyResponse.objects.get().user, applicant) + send_email.assert_called_once() + + def test_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) + + outsider = create_user(prefix="outsider") + create_vacancy_response(user=outsider, vacancy=vacancy) + self.client.force_authenticate(outsider) + duplicate = self.client.post( + f"/vacancies/{vacancy.id}/responses/", {"why_me": "Again"}, format="json" + ) + self.assertEqual(duplicate.status_code, status.HTTP_400_BAD_REQUEST) + + def test_response_list_requires_manager_and_never_exposes_private_profile_fields( + self, + ): + leader = create_user(prefix="leader") + applicant = create_user(prefix="applicant") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project) + 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, + ) + 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) + + def test_response_detail_is_visible_only_to_owner_or_vacancy_manager(self): + leader = create_user(prefix="leader") + applicant = create_user(prefix="applicant") + project = create_project(leader=leader) + vacancy_response = create_vacancy_response( + user=applicant, + vacancy=create_vacancy(project=project), + ) + + self.assertEqual( + self.client.get(f"/vacancies/responses/{vacancy_response.id}/").status_code, + status.HTTP_401_UNAUTHORIZED, + ) + self.client.force_authenticate(create_user(prefix="outsider")) + self.assertEqual( + self.client.get(f"/vacancies/responses/{vacancy_response.id}/").status_code, + status.HTTP_404_NOT_FOUND, + ) + for user in (applicant, leader, create_user(prefix="staff", is_staff=True)): + with self.subTest(user=user.id): + self.client.force_authenticate(user) + response = self.client.get(f"/vacancies/responses/{vacancy_response.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + assert_private_fields_absent(self, response.data) + + def test_unknown_vacancy_and_response_return_not_found(self): + user = create_user(prefix="user") + self.client.force_authenticate(user) + self.assertEqual( + self.client.get("/vacancies/999999/responses/").status_code, + status.HTTP_404_NOT_FOUND, + ) + self.assertEqual( + self.client.get("/vacancies/responses/999999/").status_code, + status.HTTP_404_NOT_FOUND, + ) + + def test_manager_response_list_query_count_is_constant(self): + leader = create_user(prefix="leader") + vacancy = create_vacancy(project=create_project(leader=leader)) + self.client.force_authenticate(leader) + + def query_count(count): + for index in range(count): + user = create_user(prefix=f"candidate-{count}-{index}") + user.skills.create(skill=create_skill(name=f"Candidate {count}-{index}")) + create_vacancy_response(user=user, vacancy=vacancy) + 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) + + one_count = query_count(1) + five_count = query_count(5) + self.assertLessEqual(five_count, one_count) + + def test_pending_owner_can_edit_replace_remove_file_and_withdraw(self): + applicant = create_user(prefix="applicant") + first_file = create_user_file(user=applicant) + second_file = create_user_file(user=applicant) + vacancy_response = create_vacancy_response( + user=applicant, + accompanying_file=first_file, + ) + self.client.force_authenticate(applicant) + + updated = self.client.patch( + f"/vacancies/responses/{vacancy_response.id}/", + {"why_me": "Обновлено", "accompanying_file": second_file.link}, + format="json", + ) + self.assertEqual(updated.status_code, status.HTTP_200_OK) + self.assertEqual(updated.data["why_me"], "Обновлено") + removed = self.client.patch( + f"/vacancies/responses/{vacancy_response.id}/", + {"accompanying_file": None}, + format="json", + ) + self.assertIsNone(removed.data["accompanying_file"]) + withdrawn = self.client.delete(f"/vacancies/responses/{vacancy_response.id}/") + self.assertEqual(withdrawn.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(VacancyResponse.objects.filter(pk=vacancy_response.pk).exists()) + + def test_processed_response_cannot_be_edited_or_withdrawn(self): + applicant = create_user(prefix="applicant") + vacancy_response = create_vacancy_response(user=applicant, is_approved=True) + self.client.force_authenticate(applicant) + self.assertEqual( + self.client.patch( + f"/vacancies/responses/{vacancy_response.id}/", + {"why_me": "Changed"}, + format="json", + ).status_code, + status.HTTP_400_BAD_REQUEST, + ) + self.assertEqual( + self.client.delete( + f"/vacancies/responses/{vacancy_response.id}/" + ).status_code, + status.HTTP_400_BAD_REQUEST, + ) + + def test_self_list_requires_auth_and_contains_vacancy_project(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.data["results"][0]["id"], own.id) + self.assertEqual( + response.data["results"][0]["vacancy"]["project"]["id"], + own.vacancy.project_id, + ) + + def test_anonymous_user_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()) + + +class VacancyDecisionContractTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("vacancy.response_services.send_email.delay") + def test_accept_is_atomic_closes_vacancy_and_declines_other_pending(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(send_email.call_count, 2) + self.assertEqual( + self.client.post(f"/vacancies/responses/{accepted.id}/accept/").status_code, + status.HTTP_400_BAD_REQUEST, + ) + self.assertEqual( + Collaborator.objects.filter( + project=project, + user=accepted_user, + ).count(), + 1, + ) + + def test_failed_accept_rolls_back_all_changes(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) + + def test_close_reopen_and_safe_delete(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project) + pending = create_vacancy_response(vacancy=vacancy) + self.client.force_authenticate(leader) + + closed = self.client.post(f"/vacancies/{vacancy.id}/close/") + self.assertEqual(closed.status_code, status.HTTP_200_OK) + pending.refresh_from_db() + self.assertFalse(pending.is_approved) + self.assertEqual( + self.client.delete(f"/vacancies/{vacancy.id}/").status_code, + status.HTTP_400_BAD_REQUEST, + ) + reopened = self.client.post(f"/vacancies/{vacancy.id}/reopen/") + self.assertEqual(reopened.status_code, status.HTTP_200_OK) + vacancy.refresh_from_db() + self.assertTrue(vacancy.is_active) + + project.draft = True + project.save(update_fields=("draft",)) + self.client.post(f"/vacancies/{vacancy.id}/close/") + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/reopen/").status_code, + status.HTTP_400_BAD_REQUEST, + ) + + def test_only_manager_can_close_or_reopen_vacancy(self): + leader = create_user(prefix="leader") + vacancy = create_vacancy(project=create_project(leader=leader)) + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/close/").status_code, + status.HTTP_403_FORBIDDEN, + ) + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/reopen/").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(user=user.id): + self.client.force_authenticate(user) + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/close/").status_code, + status.HTTP_200_OK, + ) + self.assertEqual( + self.client.post(f"/vacancies/{vacancy.id}/reopen/").status_code, + status.HTTP_200_OK, + ) diff --git a/vacancy/tests/test_vacancy_responses_api.py b/vacancy/tests/test_vacancy_responses_api.py index ed28e868..937e8c60 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) @@ -54,11 +55,11 @@ def test_user_cannot_apply_to_closed_vacancy(self, send_email_delay): format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) 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() @@ -75,16 +76,19 @@ def test_user_cannot_apply_twice_to_same_vacancy(self, send_email_delay): self.assertEqual(VacancyResponse.objects.count(), 1) send_email_delay.assert_not_called() - def test_response_list_returns_responses_for_vacancy(self): - vacancy = create_vacancy() + def test_project_leader_gets_safe_responses_for_vacancy(self): + leader = create_user(prefix="leader") + vacancy = create_vacancy(project=create_project(leader=leader)) target_response = create_vacancy_response(vacancy=vacancy, why_me="Target") create_vacancy_response(why_me="Other") + self.client.force_authenticate(leader) response = self.client.get(f"/vacancies/{vacancy.id}/responses/") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual([item["id"] for item in response.data], [target_response.id]) self.assertEqual(response.data[0]["why_me"], "Target") + self.assertNotIn("email", response.data[0]["user"]) def test_current_user_responses_returns_only_own_responses(self): user = create_user(prefix="applicant") @@ -116,7 +120,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 +129,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 +149,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 +158,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/urls.py b/vacancy/urls.py index aa46cd4d..4900f782 100644 --- a/vacancy/urls.py +++ b/vacancy/urls.py @@ -9,6 +9,8 @@ VacancyResponseAccept, VacancyResponseDecline, UserVacancyResponses, + VacancyClose, + VacancyReopen, ) app_name = "vacancies" @@ -16,6 +18,8 @@ urlpatterns = [ path("", VacancyList.as_view()), path("/", VacancyDetail.as_view()), + path("/close/", VacancyClose.as_view()), + path("/reopen/", VacancyReopen.as_view()), path("/responses/", VacancyResponseList.as_view()), path("responses//", VacancyResponseDetail.as_view()), path("responses/self", UserVacancyResponses.as_view()), diff --git a/vacancy/views.py b/vacancy/views.py index 2fe403f8..f7c53262 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -1,34 +1,46 @@ +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.serializers import ( + VacancyCatalogSerializer, VacancyDetailSerializer, - VacancyResponseAcceptSerializer, - VacancyResponseDetailSerializer, - VacancyResponseDetailReadSerializer, - VacancyResponseFullFileInfoListSerializer, - VacancyResponseListSerializer, ProjectVacancyCreateListSerializer, + VacancyResponseManagerSerializer, + VacancyResponseSelfSerializer, + VacancyResponseWriteSerializer, +) +from vacancy.selectors import ( + can_manage_vacancy, + can_view_vacancy, + get_public_vacancies_queryset, + get_response_queryset, + get_self_response_queryset, + get_vacancy_queryset, +) +from vacancy.response_services import ( + accept_vacancy_response, + close_vacancy, + create_vacancy_response, + delete_vacancy_without_responses, + decline_vacancy_response, + reopen_vacancy, ) from vacancy.services import update_vacancy_skills -from vacancy.tasks import send_email @swagger_auto_schema( @@ -40,29 +52,42 @@ ], ) class VacancyList(generics.ListCreateAPIView): - queryset = Vacancy.objects.get_vacancy_for_list_view() serializer_class = ProjectVacancyCreateListSerializer permission_classes = [permissions.IsAuthenticatedOrReadOnly] filter_backends = (filters.DjangoFilterBackend,) filterset_class = VacancyFilter pagination_class = VacancyPagination + def get_queryset(self): + return get_public_vacancies_queryset() + + def get_serializer_class(self): + if self.request.method == "GET": + return VacancyCatalogSerializer + return super().get_serializer_class() + class VacancyDetail(generics.RetrieveUpdateDestroyAPIView): - queryset = Vacancy.objects.get_vacancy_for_detail_view() + queryset = Vacancy.objects.all() serializer_class = VacancyDetailSerializer permission_classes = [IsVacancyProjectLeader] + def get_object(self): + vacancy = get_object_or_404(get_vacancy_queryset(), pk=self.kwargs["pk"]) + if not can_view_vacancy(self.request.user, vacancy): + raise Http404 + self.check_object_permissions(self.request, vacancy) + return vacancy + def patch(self, request, *args, **kwargs): update_vacancy_skills(request, self.get_object()) return self.partial_update(request, *args, **kwargs) def put(self, request, *args, **kwargs): - """updating the vacancy""" vacancy = self.get_object() if not request.data.get("is_active"): - # automatically declining every vacancy response if the vacancy is not active + # Legacy PUT сохраняет прежнее поведение: закрытие завершает ожидающие отклики. VacancyResponse.objects.filter(vacancy=vacancy, is_approved=None).update( is_approved=False ) @@ -71,163 +96,167 @@ def put(self, request, *args, **kwargs): return self.update(request, *args, **kwargs) + def destroy(self, request, *args, **kwargs): + vacancy = self.get_object() + delete_vacancy_without_responses(vacancy.id, actor=request.user) + return Response(status=status.HTTP_204_NO_CONTENT) -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, 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 + vacancy = get_object_or_404(get_public_vacancies_queryset(), 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 + 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(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: + return 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) + + +class VacancyClose(GenericAPIView): + queryset = Vacancy.objects.all() + permission_classes = [permissions.IsAuthenticated] + serializer_class = VacancyDetailSerializer + + def post(self, request, pk): + vacancy = get_object_or_404(get_vacancy_queryset(), pk=pk) + if not can_manage_vacancy(request.user, vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) + close_vacancy(pk, actor=request.user) + closed = get_vacancy_queryset().get(pk=pk) + return Response(VacancyDetailSerializer(closed).data) + + +class VacancyReopen(GenericAPIView): + queryset = Vacancy.objects.all() + permission_classes = [permissions.IsAuthenticated] + serializer_class = VacancyDetailSerializer + + def post(self, request, pk): + vacancy = get_object_or_404(get_vacancy_queryset(), pk=pk) + if not can_manage_vacancy(request.user, vacancy): + return Response(status=status.HTTP_403_FORBIDDEN) + reopen_vacancy(pk, actor=request.user) + reopened = get_vacancy_queryset().get(pk=pk) + return Response(VacancyDetailSerializer(reopened).data)