Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 50 additions & 19 deletions docs/modules/vacancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ Vacancy отвечает за вакансии внутри проектов Pro

## Статус модуля

Модуль используется в продуктовых сценариях проектов, ленты и откликов, но
находится в состоянии технического долга. Основная бизнес-логика все еще
сосредоточена во `views.py` и serializers.
Модуль используется в продуктовых сценариях проектов, ленты и откликов.
Security-critical lifecycle откликов вынесен в транзакционные services и
selectors, а views отвечают за HTTP orchestration и выбор безопасного контракта.

Критичные API-flow закрыты regression-тестами: создание вакансии, фильтрация
списка, отклик, повторный отклик, accept/decline, закрытие вакансии,
Expand Down Expand Up @@ -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` - вспомогательная логика обновления навыков вакансии.
Expand Down Expand Up @@ -72,19 +74,21 @@ email-уведомления и permissions. Текущий coverage по мод
- `PUT /vacancies/<id>/` - полное обновление вакансии.
- `PATCH /vacancies/<id>/` - частичное обновление вакансии.
- `DELETE /vacancies/<id>/` - удаление вакансии.
- `GET /vacancies/<vacancy_id>/responses/` - список откликов на вакансию.
- `GET /vacancies/<vacancy_id>/responses/` - безопасный список откликов для
руководителя проекта, staff и superuser.
- `POST /vacancies/<vacancy_id>/responses/` - отклик на вакансию.
- `GET /vacancies/responses/<id>/` - детали отклика.
- `PUT /vacancies/responses/<id>/` - обновление отклика.
- `PATCH /vacancies/responses/<id>/` - частичное обновление отклика.
- `DELETE /vacancies/responses/<id>/` - удаление отклика.
- `GET /vacancies/responses/self` - отклики текущего пользователя.
- `GET /vacancies/responses/self` - отклики только текущего пользователя.
- `POST /vacancies/responses/<id>/accept/` - принять отклик.
- `POST /vacancies/responses/<id>/decline/` - отклонить отклик.

Связанные endpoints и сценарии:

- `GET /projects/<id>/responses/` - отклики по всем вакансиям проекта.
- `GET /projects/<id>/responses/` - совместимый manager-only список откликов по
всем вакансиям проекта; доступен руководителю, staff и superuser.
- `GET /projects/?any_vacancies=true` - проекты с активными вакансиями.
- `GET /feed/?type=vacancy` - служебные записи активных вакансий в ленте.

Expand Down Expand Up @@ -129,13 +133,30 @@ Queryset списка дополнительно ограничен ваканс

При отклике:

- вакансия должна быть активной;
- пользователь подставляется из `request.user`;
- требуется аутентификация;
- вакансия должна быть активной, а проект - опубликованным и публичным;
- пользователь всегда подставляется из `request.user`; поля `user`, `user_id`
и `vacancy` из payload не участвуют в создании;
- лидер и участники этого проекта не могут откликнуться на его вакансию;
- повторный отклик на ту же вакансию запрещен;
- можно передать `why_me`;
- можно приложить `accompanying_file`;
- можно приложить только собственный `accompanying_file`;
- лидеру проекта отправляется email о новом отклике.

`GET /vacancies/<id>/` дополнительно возвращает read-only UI-hints:

- `has_responded` - у текущего пользователя уже есть отклик;
- `can_respond` - текущая вакансия доступна этому пользователю для отклика;
- `can_manage_responses` - пользователь может управлять откликами.

Эти признаки не являются границей безопасности: `POST` независимо повторяет
все проверки внутри транзакции.

Manager endpoints используют явный allow-list. Карточка кандидата содержит
только `id`, имя, фамилию, аватар, специализацию, навыки и описание. Email,
телефон, дата рождения и административные поля в ответ не включаются. Метаданные
файла не содержат владельца и служебные поля.

### 4. Лидер принимает отклик

Лидер проекта вызывает `POST /vacancies/responses/<id>/accept/`.
Expand All @@ -147,6 +168,11 @@ Queryset списка дополнительно ограничен ваканс
- пользователю отправляется email;
- вакансия закрывается через `is_active=False`;
- `datetime_closed` обновляется автоматически в модели.
- остальные ожидающие отклики этой вакансии получают `is_approved=False`.

Вакансия, выбранный отклик и остальные ожидающие отклики блокируются в одной
транзакции. Повторная обработка запрещена, а уникальное ограничение
`Collaborator(project, user)` не допускает дублирования участника.

### 5. Лидер отклоняет отклик

Expand Down Expand Up @@ -199,21 +225,19 @@ 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`.
- `required_skills_ids` в serializer отмечен как необязательный, но create-flow
ожидает его наличие.
- `update_vacancy_skills()` может вернуть `Response`, но callers в `views.py`
этот результат не обрабатывают.
- `GET /vacancies/<vacancy_id>/responses/` для несуществующей вакансии сейчас
возвращает пустой список, а не 404.
- `accompanying_file` ищется по всем `UserFile`, без явной проверки, что файл
принадлежит текущему пользователю.
- Существующие legacy serializers откликов сохранены для совместимости кода,
но manager/self endpoints используют отдельные безопасные serializers.
- Схема данных в этом этапе не менялась; `Vacancy.city` и миграция `0010`
сохраняются без изменений.

## Тесты

Expand Down Expand Up @@ -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()`;
- контролируемую ошибку при передаче несуществующего навыка;
Expand All @@ -252,5 +284,4 @@ Celery-задача `email_notificate_vacancy_outdated()` выбирает ак
Пока не покрыты точечными тестами:

- admin export email лидеров;
- запрет прикрепления чужого `UserFile` к отклику;
- контракт `GET /vacancies/<vacancy_id>/responses/` для отсутствующей вакансии.
- интеграция с внешним SMTP-брокером (в API-тестах Celery task мокируется).
24 changes: 17 additions & 7 deletions projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@
)
from users.models import LikesOnProject
from users.serializers import UserListSerializer
from vacancy.models import VacancyResponse
from vacancy.serializers import VacancyResponseFullFileInfoListSerializer
from vacancy.serializers import VacancyResponseManagerSerializer
from vacancy.selectors import can_manage_project, get_response_queryset

logger = logging.getLogger()

Expand Down Expand Up @@ -339,15 +339,25 @@ class AchievementDetail(generics.RetrieveUpdateDestroyAPIView):


class ProjectVacancyResponses(generics.GenericAPIView):
serializer_class = VacancyResponseFullFileInfoListSerializer
permission_classes = [IsAuthenticated, ProjectVisibilityPermission]
serializer_class = VacancyResponseManagerSerializer
permission_classes = [IsAuthenticated]

def get_queryset(self):
return VacancyResponse.objects.filter(vacancy__project_id=self.kwargs["id"])
return get_response_queryset().filter(vacancy__project_id=self.kwargs["id"])

def get(self, *args, **kwargs):
def get(self, request, *args, **kwargs):
project = get_object_or_404(
Project.objects.only("id", "leader_id"),
pk=self.kwargs["id"],
)
if not can_manage_project(request.user, project):
return Response(status=status.HTTP_403_FORBIDDEN)
queryset = self.get_queryset()
serializer = self.get_serializer(queryset, many=True)
serializer = self.get_serializer(
queryset,
many=True,
context={"request": request},
)
return Response(serializer.data)


Expand Down
3 changes: 3 additions & 0 deletions vacancy/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
169 changes: 169 additions & 0 deletions vacancy/response_services.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading