Files
ACORE/apps/hiring/recruiting.py
T
lokses ac0418083b hiring: вакансии и найм, HR-портал
Модель вакансий, флоу рекрутинга и панели HR-портала (заявки/вакансии).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:03:32 +03:00

270 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Рекрутинг: вакансии в 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')