ac0418083b
Модель вакансий, флоу рекрутинга и панели HR-портала (заявки/вакансии). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
11 KiB
Python
270 lines
11 KiB
Python
"""Рекрутинг: вакансии в HR-портале."""
|
||
from __future__ import annotations
|
||
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.core.exceptions import ValidationError
|
||
from django.db.models import Q
|
||
from django.shortcuts import get_object_or_404, redirect
|
||
from django.utils import timezone
|
||
from django.views.decorators.http import require_POST
|
||
|
||
from apps.hiring.models import Vacancy
|
||
from core.portal_access import user_can_manage_hr_portal
|
||
|
||
|
||
RECRUITING_PERM = 'core.access_recruiting'
|
||
MANAGER_GROUP_NAME = 'Заведующий аптекой'
|
||
PHARMACIST_GROUP_NAME = 'Фармацевт'
|
||
|
||
|
||
def _preview_groups(user) -> frozenset[str] | None:
|
||
return getattr(user, '_access_preview_groups', None)
|
||
|
||
|
||
def user_can_view_recruiting(user) -> bool:
|
||
if not user or not getattr(user, 'is_authenticated', False):
|
||
return False
|
||
preview = getattr(user, '_access_preview_perms', None)
|
||
if preview is not None:
|
||
return RECRUITING_PERM in preview or 'core.access_hr_portal' in preview
|
||
if getattr(user, 'is_superuser', False):
|
||
return True
|
||
if user_can_manage_hr_portal(user):
|
||
return True
|
||
return user.has_perm(RECRUITING_PERM)
|
||
|
||
|
||
def user_is_pharmacy_manager(user) -> bool:
|
||
if not user or not getattr(user, 'is_authenticated', False):
|
||
return False
|
||
groups = _preview_groups(user)
|
||
if groups is not None:
|
||
return MANAGER_GROUP_NAME in groups
|
||
return user.groups.filter(name=MANAGER_GROUP_NAME).exists()
|
||
|
||
|
||
def user_is_pharmacist(user) -> bool:
|
||
if not user or not getattr(user, 'is_authenticated', False):
|
||
return False
|
||
if user_can_manage_hr_portal(user):
|
||
return False
|
||
groups = _preview_groups(user)
|
||
if groups is not None:
|
||
return PHARMACIST_GROUP_NAME in groups and MANAGER_GROUP_NAME not in groups
|
||
return user.groups.filter(name=PHARMACIST_GROUP_NAME).exists()
|
||
|
||
|
||
def user_can_manage_vacancies(user) -> bool:
|
||
"""HR или заведующий с правом рекрутинга — создание/редактирование вакансий.
|
||
|
||
Фармацевт только смотрит вакансии. В access-preview superuser не обходит роль.
|
||
"""
|
||
if not user or not getattr(user, 'is_authenticated', False):
|
||
return False
|
||
preview = getattr(user, '_access_preview_perms', None)
|
||
if preview is not None:
|
||
if user_is_pharmacist(user):
|
||
return False
|
||
if 'core.access_hr_portal' in preview:
|
||
return True
|
||
return user_is_pharmacy_manager(user) and RECRUITING_PERM in preview
|
||
if getattr(user, 'is_superuser', False):
|
||
return True
|
||
if user_can_manage_hr_portal(user):
|
||
return True
|
||
if user_is_pharmacist(user):
|
||
return False
|
||
return user_is_pharmacy_manager(user) and user.has_perm(RECRUITING_PERM)
|
||
|
||
|
||
def vacancies_queryset_for(user):
|
||
qs = Vacancy.objects.select_related(
|
||
'post', 'department', 'pharmacy', 'location', 'created_by',
|
||
)
|
||
preview = getattr(user, '_access_preview_perms', None)
|
||
if preview is None and (
|
||
user_can_manage_hr_portal(user) or getattr(user, 'is_superuser', False)
|
||
):
|
||
return qs
|
||
if preview is not None and 'core.access_hr_portal' in preview:
|
||
return qs
|
||
# Фармацевты / заведующие — открытые + свои аптечные
|
||
pharmacy_id = getattr(user, 'user_pharmacy_id', None)
|
||
if pharmacy_id:
|
||
return qs.filter(Q(status='open') | Q(pharmacy_id=pharmacy_id))
|
||
return qs.filter(status='open')
|
||
|
||
|
||
def vacancy_stats_for(user) -> dict:
|
||
qs = vacancies_queryset_for(user)
|
||
today = timezone.localdate()
|
||
month_start = today.replace(day=1)
|
||
open_qs = qs.filter(status='open')
|
||
return {
|
||
'vacancies_open': open_qs.count(),
|
||
'vacancies_urgent': open_qs.filter(priority='urgent').count(),
|
||
'vacancies_candidates': sum(open_qs.values_list('candidates_count', flat=True)),
|
||
'vacancies_filled_month': qs.filter(
|
||
status='filled', updated_at__date__gte=month_start,
|
||
).count(),
|
||
}
|
||
|
||
|
||
def recruiting_context(request) -> dict:
|
||
from apps.locations.models.pharmacies import Pharmacy
|
||
from apps.locations.models import Location
|
||
from core.models import Directory
|
||
|
||
can_view = user_can_view_recruiting(request.user)
|
||
can_manage = user_can_manage_vacancies(request.user)
|
||
vacancies = (
|
||
list(vacancies_queryset_for(request.user).order_by('-opened_at', '-pk')[:200])
|
||
if can_view else []
|
||
)
|
||
stats = vacancy_stats_for(request.user) if can_view else {
|
||
'vacancies_open': 0,
|
||
'vacancies_urgent': 0,
|
||
'vacancies_candidates': 0,
|
||
'vacancies_filled_month': 0,
|
||
}
|
||
ctx = {
|
||
'can_view_recruiting': can_view,
|
||
'can_manage_vacancies': can_manage,
|
||
'vacancies': vacancies,
|
||
**stats,
|
||
'vacancy_message': request.session.pop('vacancy_message', ''),
|
||
'vacancy_error': request.session.pop('vacancy_error', ''),
|
||
'vacancy_posts': [],
|
||
'vacancy_departments': [],
|
||
'vacancy_pharmacies': [],
|
||
'vacancy_locations': [],
|
||
}
|
||
if can_manage:
|
||
ctx['vacancy_posts'] = list(
|
||
Directory.objects.filter(directory_type__code='post', is_active=True).order_by('name')
|
||
)
|
||
ctx['vacancy_departments'] = list(
|
||
Directory.objects.filter(directory_type__code='department', is_active=True).order_by('name')
|
||
)
|
||
if user_can_manage_hr_portal(request.user) or request.user.is_superuser:
|
||
ctx['vacancy_pharmacies'] = list(
|
||
Pharmacy.objects.filter(is_active=True).order_by('pharmacy_number')[:500]
|
||
)
|
||
ctx['vacancy_locations'] = list(
|
||
Location.objects.filter(is_active=True).order_by('street', 'house_number')[:500]
|
||
)
|
||
else:
|
||
ph = getattr(request.user, 'user_pharmacy', None)
|
||
ctx['vacancy_pharmacies'] = [ph] if ph else []
|
||
return ctx
|
||
|
||
|
||
def save_vacancy_from_post(request, vacancy: Vacancy | None = None) -> Vacancy:
|
||
user = request.user
|
||
is_hr = user_can_manage_hr_portal(user) or user.is_superuser
|
||
title = (request.POST.get('title') or '').strip()
|
||
if not title:
|
||
raise ValidationError({'title': 'Укажите название вакансии.'})
|
||
|
||
post_id = (request.POST.get('post_id') or '').strip()
|
||
loc_type = (request.POST.get('user_location_type') or 'pharmacy').strip()
|
||
if loc_type not in dict(Vacancy.LOCATION_TYPES):
|
||
loc_type = 'pharmacy'
|
||
priority = (request.POST.get('priority') or 'normal').strip()
|
||
if priority not in dict(Vacancy.PRIORITY_CHOICES):
|
||
priority = 'normal'
|
||
description = (request.POST.get('description') or '').strip()
|
||
candidates_raw = (request.POST.get('candidates_count') or '0').strip()
|
||
try:
|
||
candidates_count = max(0, int(candidates_raw))
|
||
except ValueError:
|
||
candidates_count = 0
|
||
|
||
pharmacy_id = (request.POST.get('pharmacy_id') or '').strip()
|
||
department_id = (request.POST.get('department_id') or '').strip()
|
||
location_id = (request.POST.get('location_id') or '').strip()
|
||
|
||
if not is_hr:
|
||
# Заведующий — только своя аптека
|
||
loc_type = 'pharmacy'
|
||
pharmacy_id = str(getattr(user, 'user_pharmacy_id', '') or '')
|
||
if not pharmacy_id:
|
||
raise ValidationError({'pharmacy_id': 'У вашей учётной записи не указана аптека.'})
|
||
department_id = ''
|
||
location_id = ''
|
||
|
||
obj = vacancy or Vacancy(created_by=user, opened_at=timezone.localdate())
|
||
obj.title = title
|
||
obj.post_id = int(post_id) if post_id.isdigit() else None
|
||
obj.user_location_type = loc_type
|
||
obj.priority = priority
|
||
obj.description = description
|
||
obj.candidates_count = candidates_count
|
||
obj.pharmacy_id = int(pharmacy_id) if pharmacy_id.isdigit() else None
|
||
obj.department_id = int(department_id) if department_id.isdigit() else None
|
||
obj.location_id = int(location_id) if location_id.isdigit() else None
|
||
if loc_type == 'pharmacy':
|
||
obj.department = None
|
||
obj.location = None
|
||
else:
|
||
obj.pharmacy = None
|
||
if vacancy is None:
|
||
obj.created_by = user
|
||
obj.full_clean()
|
||
obj.save()
|
||
return obj
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def vacancy_save_view(request):
|
||
if not user_can_manage_vacancies(request.user):
|
||
request.session['vacancy_error'] = 'Нет прав на изменение вакансий.'
|
||
return redirect('/hr-portal/?tab=vacancies')
|
||
|
||
pk = (request.POST.get('pk') or '').strip()
|
||
vacancy = None
|
||
if pk.isdigit():
|
||
vacancy = get_object_or_404(Vacancy, pk=int(pk))
|
||
if not (user_can_manage_hr_portal(request.user) or request.user.is_superuser):
|
||
if vacancy.pharmacy_id != getattr(request.user, 'user_pharmacy_id', None):
|
||
request.session['vacancy_error'] = 'Можно редактировать только вакансии своей аптеки.'
|
||
return redirect('/hr-portal/?tab=vacancies')
|
||
|
||
try:
|
||
save_vacancy_from_post(request, vacancy)
|
||
request.session['vacancy_message'] = 'Вакансия сохранена.'
|
||
except ValidationError as exc:
|
||
msgs = []
|
||
if hasattr(exc, 'message_dict'):
|
||
for errs in exc.message_dict.values():
|
||
msgs.extend(errs)
|
||
elif hasattr(exc, 'messages'):
|
||
msgs.extend(exc.messages)
|
||
request.session['vacancy_error'] = '; '.join(str(m) for m in msgs) or str(exc)
|
||
|
||
return redirect('/hr-portal/?tab=vacancies')
|
||
|
||
|
||
@login_required
|
||
@require_POST
|
||
def vacancy_set_status_view(request, pk: int):
|
||
if not user_can_manage_vacancies(request.user):
|
||
request.session['vacancy_error'] = 'Нет прав на изменение вакансий.'
|
||
return redirect('/hr-portal/?tab=vacancies')
|
||
|
||
vacancy = get_object_or_404(Vacancy, pk=pk)
|
||
if not (user_can_manage_hr_portal(request.user) or request.user.is_superuser):
|
||
if vacancy.pharmacy_id != getattr(request.user, 'user_pharmacy_id', None):
|
||
request.session['vacancy_error'] = 'Можно менять только вакансии своей аптеки.'
|
||
return redirect('/hr-portal/?tab=vacancies')
|
||
|
||
status = (request.POST.get('status') or '').strip()
|
||
if status not in dict(Vacancy.STATUS_CHOICES):
|
||
request.session['vacancy_error'] = 'Некорректный статус.'
|
||
return redirect('/hr-portal/?tab=vacancies')
|
||
vacancy.status = status
|
||
vacancy.save(update_fields=['status', 'updated_at'])
|
||
request.session['vacancy_message'] = 'Статус вакансии обновлён.'
|
||
return redirect('/hr-portal/?tab=vacancies')
|