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
4 changes: 4 additions & 0 deletions docs/modules/vacancy.md
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
# Vacancy

> Актуальный безопасный контракт для React, права и транзакционные правила
> описаны в [vacancies-react-api.md](../vacancies-react-api.md). Ниже сохранено
> описание legacy-контура и исторических ограничений модуля.

## Назначение

Vacancy отвечает за вакансии внутри проектов Procollab: публикацию ролей в
Expand Down
131 changes: 131 additions & 0 deletions docs/vacancies-react-api.md
Original file line numberDiff line numberDiff line change
@@ -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/<vacancy_id>/` открывает активную вакансию публичного
опубликованного проекта. Закрытую вакансию могут получить только руководитель её
проекта, staff и superuser. Для остальных закрытая или связанная с недоступным
проектом запись скрывается через `404`.

## Отклик пользователя

`POST /vacancies/<vacancy_id>/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/<response_id>/` и отозвать отклик через
`DELETE /vacancies/responses/<response_id>/`. `null` в поле
`accompanying_file` удаляет связь с файлом. Обработанный отклик остаётся в
истории и больше не редактируется и не удаляется.

Detail отклика доступен только его владельцу, руководителю соответствующего
проекта, staff и superuser. Для постороннего пользователя используется безопасный
`404`.

## Работа руководителя

`GET /vacancies/<vacancy_id>/responses/` доступен руководителю проекта, staff и
superuser. Остальные авторизованные пользователи получают `403`, неизвестная
вакансия — `404`.

Карточка кандидата построена по явному публичному allow-list и содержит только:

- идентификатор;
- имя и фамилию;
- аватар;
- специализацию;
- публичные навыки;
- краткое описание профиля.

Email, телефон, дата рождения, административные признаки и другие закрытые поля
не возвращаются. Сопроводительный файл сериализуется без данных его владельца и
доступен только кандидату и пользователям с правом управления вакансией.

Решение принимается через:

- `POST /vacancies/responses/<response_id>/accept/`;
- `POST /vacancies/responses/<response_id>/decline/`.

При принятии одной атомарной операцией:

1. отклик получает `is_approved=true`;
2. кандидат добавляется в `Collaborator` с ролью из вакансии;
3. вакансия закрывается;
4. остальные ожидающие отклики получают `is_approved=false`;
5. после commit отправляются уведомления принятому и отклонённым кандидатам.

Повторная обработка уже принятого или отклонённого отклика возвращает `400` и не
создаёт дубликат участника.

## Закрытие, возобновление и удаление

- `POST /vacancies/<vacancy_id>/close/` закрывает вакансию и отклоняет ожидающие
отклики;
- `POST /vacancies/<vacancy_id>/reopen/` возобновляет вакансию только в публичном
опубликованном проекте;
- `DELETE /vacancies/<vacancy_id>/` удаляет вакансию только при полном отсутствии
откликов.

Эти операции доступны руководителю проекта, staff и superuser. История откликов
не удаляется вместе с вакансией. Проверка удаления и само удаление выполняются
под блокировкой одной транзакции.

## Совместимость и ограничения

Существующие модели, миграции, CRUD вакансий, workspace detail проекта,
служебные записи ленты и legacy endpoints не переименованы. Контур не добавляет
чат с кандидатом, собеседования, рейтинг кандидатов или внешние интеграции.
87 changes: 30 additions & 57 deletions vacancy/filters.py
Original file line numberDiff line numberDiff line change
@@ -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

Expand All@@ -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")
Expand All@@ -114,13 +85,15 @@ 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")

class Meta:
model = Vacancy
fields = (
"role_contains",
"search",
"project_id",
"is_active",
"required_experience",
Expand Down
20 changes: 15 additions & 5 deletions vacancy/permissions.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading
Loading