Files
ACORE/apps/hiring/services.py
T
lokses cfafe2f0b3 hiring: логин в формате <буква имени>.<фамилия>
Иван Иванов -> i.ivanov (транслит pytils, нижний регистр, коллизия +счётчик).
Раньше slugify('Фамилия.Имя') терял точку и давал ivanovivan. Единый формат
в найме (_generate_username) и в импорте (transliterate_username).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 15:52:57 +03:00

330 lines
12 KiB
Python

"""Бізнес-логіка найму: інтерн-слот + IT Ticket на випробуванні;
персональний Employee і PDF — лише після затвердження."""
import secrets
from django.db import transaction
from django.utils import timezone
from django.utils.translation import gettext as _
from pytils.translit import slugify
from apps.employees.models.base import Employee
from apps.hiring.models import HiringRequest, InternAccount
def _username_base(last_name, first_name):
"""Формат логина: <первая буква имени>.<фамилия>, транслит, нижний регистр.
Иван Иванов -> i.ivanov."""
ln = slugify(last_name)
fn = slugify(first_name)
initial = fn[:1]
return f'{initial}.{ln}'.strip('.') or 'user'
def _generate_username(last_name, first_name):
base = _username_base(last_name, first_name)
username = base
counter = 1
while Employee.objects.filter(username=username).exists():
counter += 1
username = f'{base}{counter}'
return username
def _generate_password():
return secrets.token_urlsafe(12)
def _coerce_location_fields(user_location_type, department_id, user_pharmacy_id, user_location_id):
"""Приводить поля локації до правил Employee.clean(): аптека XOR офіс/склад."""
if user_location_type == 'pharmacy':
return None, user_pharmacy_id, None
if user_location_type in ('office', 'warehouse'):
return department_id, None, user_location_id
return department_id, user_pharmacy_id, user_location_id
def _filter_access_ids_for_location(user_location_type, requested_access_ids):
"""Залишає лише системи доступу, дозволені для типу локації.
Порожній location_types у системи = доступна скрізь (як у посад).
"""
if not requested_access_ids:
return []
from core.models import Directory
allowed = []
for access in Directory.objects.filter(
pk__in=requested_access_ids,
directory_type__code='access_system',
is_active=True,
).prefetch_related('location_types'):
types = [lt.code for lt in access.location_types.all()]
if not types or user_location_type in types:
allowed.append(access.pk)
return allowed
def _validate_hire_location(*, user_location_type, department_id, user_pharmacy_id,
user_location_id, post_id, hire_date):
"""Перевіряє правила локації Employee.clean() без збереження в БД."""
emp = Employee(
username='__hire_validate__',
last_name='_',
first_name='_',
hire_date=hire_date,
user_location_type=user_location_type,
department_id=department_id,
user_pharmacy_id=user_pharmacy_id,
user_location_id=user_location_id,
post_id=post_id,
)
emp.clean()
def _directory(type_code, code):
from core.models import Directory
return Directory.objects.get(directory_type__code=type_code, code=code)
def _ticket_location(hiring_request):
if hiring_request.user_location_type == 'pharmacy' and hiring_request.user_pharmacy_id:
return hiring_request.user_pharmacy
return hiring_request.user_location
def _build_ticket_description(hiring_request, intern):
loc_label = hiring_request.get_user_location_type_display()
if hiring_request.user_pharmacy_id:
loc_detail = str(hiring_request.user_pharmacy)
elif hiring_request.user_location_id:
loc_detail = str(hiring_request.user_location)
elif hiring_request.department_id:
loc_detail = hiring_request.department.name
else:
loc_detail = '—'
lines = [
f"{_('Кандидат')}: {hiring_request.last_name} {hiring_request.first_name} {hiring_request.middle_name}".strip(),
f"{_('Должность')}: {hiring_request.post.name if hiring_request.post_id else '—'}",
f"{_('Тип локации')}: {loc_label}",
f"{_('Локация / отдел')}: {loc_detail}",
f"{_('Дата выхода')}: {hiring_request.hire_date:%d.%m.%Y}",
f"{_('Испытательный срок')}: {hiring_request.trial_days} {_('дн.')}",
f"{_('Интерн-аккаунт')}: {intern.label}",
f"{_('Логин')}: {intern.username}",
f"{_('Пароль')}: {intern.password}",
'',
f"{_('Требуемые доступы')}:",
]
accesses = list(hiring_request.requested_access.all())
if accesses:
for access in accesses:
lines.append(f' • {access.name}')
else:
lines.append(f" {_('(не указано)')}")
if hiring_request.access_note:
lines.append('')
lines.append(f"{_('Примечание')}: {hiring_request.access_note}")
lines.append('')
lines.append(_(
'Подготовьте рабочее место и выдайте доступы к интерн-аккаунту на время '
'испытания. После утверждения HR будет создан персональный аккаунт.'
))
return '\n'.join(lines)
def _create_access_ticket(hiring_request, requester, intern):
from apps.it_manager.models import Ticket
fio = f'{hiring_request.last_name} {hiring_request.first_name}'.strip()
ticket = Ticket.objects.create(
title=f"{_('Подготовка рабочего места')}: {fio}",
ticket_type=_directory('ticket_type', 'access'),
priority=_directory('ticket_priority', 'high'),
requester=requester,
location=_ticket_location(hiring_request),
description=_build_ticket_description(hiring_request, intern),
)
hiring_request.granted_ticket = ticket
hiring_request.save(update_fields=['granted_ticket', 'updated_at'])
return ticket
def _close_ticket(ticket):
if ticket is None:
return
closed = _directory('ticket_status', 'closed')
if ticket.status_id == closed.pk:
return
ticket.status = closed
ticket.save(update_fields=['status', 'closed_at', 'updated_at'])
@transaction.atomic
def create_hiring_request(*, requester, last_name, first_name, middle_name='',
candidate_email='', candidate_phone='',
user_location_type, department_id=None, user_pharmacy_id=None,
user_location_id=None, post_id=None,
hire_date, trial_days, requested_access_ids=None, access_note=''):
"""Створює заявку на випробування: виділяє інтерн-слот і IT Ticket.
Персональний Employee ще не створюється.
"""
department_id, user_pharmacy_id, user_location_id = _coerce_location_fields(
user_location_type, department_id, user_pharmacy_id, user_location_id,
)
_validate_hire_location(
user_location_type=user_location_type,
department_id=department_id,
user_pharmacy_id=user_pharmacy_id,
user_location_id=user_location_id,
post_id=post_id,
hire_date=hire_date,
)
requested_access_ids = _filter_access_ids_for_location(
user_location_type, requested_access_ids or [],
)
hiring_request = HiringRequest.objects.create(
last_name=last_name,
first_name=first_name,
middle_name=middle_name,
candidate_email=candidate_email,
candidate_phone=candidate_phone,
user_location_type=user_location_type,
department_id=department_id,
user_pharmacy_id=user_pharmacy_id,
user_location_id=user_location_id,
post_id=post_id,
hire_date=hire_date,
trial_days=trial_days,
access_note=access_note,
status='trial',
requested_by=requester,
)
if requested_access_ids:
hiring_request.requested_access.set(requested_access_ids)
intern = InternAccount.allocate(hiring_request)
_create_access_ticket(hiring_request, requester, intern)
return hiring_request
def _should_assign_office_email(user_location_type) -> bool:
return user_location_type == 'office'
def _office_email_for(username: str) -> str:
from apps.employees.models import EmployeeDefaults
domain = EmployeeDefaults.load().normalized_office_domain
return f'{username}@{domain}'
@transaction.atomic
def approve_hiring_request(hiring_request, decided_by, note=''):
locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk)
if locked.status != 'trial':
return locked
username = _generate_username(locked.last_name, locked.first_name)
employee = Employee(
username=username,
last_name=locked.last_name,
first_name=locked.first_name,
middle_name=locked.middle_name,
personal_phone=locked.candidate_phone or '',
personal_email=locked.candidate_email or '',
is_active=True,
hire_date=locked.hire_date,
probation_end_date=timezone.localdate(),
user_location_type=locked.user_location_type,
department_id=locked.department_id,
user_pharmacy_id=locked.user_pharmacy_id,
user_location_id=locked.user_location_id,
post_id=locked.post_id,
ad_sync_enabled=False,
)
if _should_assign_office_email(locked.user_location_type):
employee.email = _office_email_for(username)
employee.set_unusable_password()
employee.full_clean()
employee.save()
from apps.employees.services.password_invite import create_password_invite
invite = create_password_invite(employee, created_by=decided_by)
locked.employee = employee
locked.issued_password = ''
locked.status = 'approved'
locked.decision_by = decided_by
locked.decision_at = timezone.now()
locked.decision_note = note
locked.save(update_fields=[
'employee', 'issued_password', 'status',
'decision_by', 'decision_at', 'decision_note', 'updated_at',
])
locked.release_intern()
_close_ticket(locked.granted_ticket)
from core.security import log_security_event
log_security_event(
'employee.created_via_hiring',
actor=decided_by,
target=str(employee.pk),
details=f'username={employee.username} location={employee.user_location_type} invite={invite.pk}',
)
locked._password_invite = invite # для view/шаблону (не зберігається в БД)
return locked
@transaction.atomic
def reject_hiring_request(hiring_request, decided_by, note=''):
locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk)
if locked.status != 'trial':
return locked
locked.release_intern()
_close_ticket(locked.granted_ticket)
locked.status = 'rejected'
locked.decision_by = decided_by
locked.decision_at = timezone.now()
locked.decision_note = note
locked.save(update_fields=[
'status', 'decision_by', 'decision_at', 'decision_note', 'updated_at',
])
return locked
@transaction.atomic
def build_hiring_credentials_pdf(hiring_request, request=None):
"""PDF з логіном і посиланням на встановлення пароля."""
from apps.hiring.pdf import build_credentials_pdf
from apps.employees.services.password_invite import (
build_invite_absolute_url,
create_password_invite,
get_active_invite_for_employee,
)
locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk)
if locked.status != 'approved' or locked.employee_id is None:
return None
invite = get_active_invite_for_employee(locked.employee)
if invite is None and not locked.employee.has_usable_password():
invite = create_password_invite(locked.employee, created_by=None)
invite_url = build_invite_absolute_url(request, invite) if invite else ''
locked = (
HiringRequest.objects.select_related('employee', 'post')
.prefetch_related('requested_access')
.get(pk=locked.pk)
)
return build_credentials_pdf(locked, invite_url=invite_url)
@transaction.atomic
def consume_issued_password_for_pdf(hiring_request):
"""Зворотна сумісність: PDF без одноразового пароля."""
return build_hiring_credentials_pdf(hiring_request)