Files
ACORE/apps/hiring/models.py
T

284 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.
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.employees.models.base import Employee
from core.models import BaseModel
from core.models.fields import EncryptedCharField
from core.utils.validators import validate_ukrainian_phone, validate_ukrainian_tax_number
class InternAccount(BaseModel):
"""Спільний інтерн-обліковий запис з пулу (5 слотів). Призначається заявці
на час випробування; після approve/reject звільняється."""
label = models.CharField(_('Метка'), max_length=50, unique=True)
username = EncryptedCharField(_('Логин'), max_length=512)
password = EncryptedCharField(_('Пароль'), max_length=512)
is_active = models.BooleanField(_('Активный'), default=True)
assigned_to = models.OneToOneField(
'hiring.HiringRequest', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='intern_account', verbose_name=_('Назначено заявке'),
)
class Meta:
verbose_name = _('Интерн-аккаунт')
verbose_name_plural = _('Пул интерн-аккаунтов')
ordering = ['label']
def __str__(self):
status = _('занят') if self.assigned_to_id else _('свободен')
return f'{self.label} ({status})'
@classmethod
def allocate(cls, hiring_request):
"""Виділяє свободен слот під select_for_update (викликати всередині atomic)."""
account = (
cls.objects.select_for_update()
.filter(is_active=True, assigned_to__isnull=True)
.order_by('label')
.first()
)
if account is None:
raise ValidationError({
'__all__': _(
'Нет свободных интерн-аккаунтов. '
'Дождитесь завершения стажировки или добавьте слот в админке.'
),
})
account.assigned_to = hiring_request
account.save(update_fields=['assigned_to', 'updated_at'])
return account
def release(self):
if self.assigned_to_id is None:
return
self.assigned_to = None
self.save(update_fields=['assigned_to', 'updated_at'])
class HiringRequest(BaseModel):
"""Заявка HR на найм кандидата: інтерн-слот + IT Ticket на випробуванні,
персональний Employee — лише після затвердження."""
STATUS_CHOICES = [
('trial', _('На испытании')),
('approved', _('Утверждено')),
('rejected', _('Отклонено')),
]
TRIAL_DAYS_CHOICES = [(i, _('%(n)d дн.') % {'n': i}) for i in range(1, 6)]
last_name = models.CharField(_('Фамилия'), max_length=150)
first_name = models.CharField(_('Имя'), max_length=150)
middle_name = models.CharField(_('Отчество'), max_length=150, blank=True)
candidate_email = models.EmailField(_('Личный email кандидата'), blank=True)
candidate_phone = models.CharField(
_('Телефон кандидата'), max_length=20, blank=True,
validators=[validate_ukrainian_phone],
)
inn_code = models.CharField(
_('ИНН'), max_length=10, blank=True,
help_text=_('Нужен для фоторегистратора (учёт посещаемости).'),
validators=[validate_ukrainian_tax_number],
)
LOCATION_TYPES = [
('pharmacy', _('Аптека')),
('warehouse', _('Склад')),
('office', _('Офис')),
]
user_location_type = models.CharField(
_('Тип локации'), max_length=20, choices=LOCATION_TYPES,
)
department = models.ForeignKey(
'core.Directory', on_delete=models.SET_NULL,
null=True, blank=True,
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
related_name='hiring_requests_department', verbose_name=_('Отдел'),
)
user_pharmacy = models.ForeignKey(
'locations.Pharmacy', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='hiring_requests', verbose_name=_('Аптека'),
)
user_location = models.ForeignKey(
'locations.Location', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='hiring_requests_location', verbose_name=_('Локация (офис/склад)'),
)
post = models.ForeignKey(
'core.Directory', on_delete=models.SET_NULL,
null=True, blank=True,
limit_choices_to={'directory_type__code': 'post', 'is_active': True},
related_name='hiring_requests_post', verbose_name=_('Должность'),
)
hire_date = models.DateField(_('Дата виходу'), default=timezone.localdate)
employment_type = models.CharField(
_('Оформление'),
max_length=16,
choices=Employee.EMPLOYMENT_TYPE_CHOICES,
default=Employee.EMPLOYMENT_STAFF,
help_text=_('Штат по трудовому договору или работа по гражданско-правовому договору.'),
)
trial_days = models.PositiveSmallIntegerField(
_('Срок испытания, дней'), choices=TRIAL_DAYS_CHOICES, default=3,
validators=[MinValueValidator(1), MaxValueValidator(5)],
)
requested_access = models.ManyToManyField(
'core.Directory', blank=True,
limit_choices_to={'directory_type__code': 'access_system', 'is_active': True},
related_name='hiring_requests_access', verbose_name=_('Требуемые доступы'),
)
access_note = models.TextField(_('Примечание по доступам'), blank=True)
status = models.CharField(_('Статус'), max_length=10, choices=STATUS_CHOICES, default='trial')
requested_by = models.ForeignKey(
'employees.Employee', on_delete=models.PROTECT,
related_name='hiring_requests_submitted', verbose_name=_('Подал HR'),
)
employee = models.OneToOneField(
'employees.Employee', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='hiring_request', verbose_name=_('Учётная запись'),
)
decision_by = models.ForeignKey(
'employees.Employee', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='hiring_requests_decided', verbose_name=_('Решение принял'),
)
decision_at = models.DateTimeField(_('Дата решения'), null=True, blank=True)
decision_note = models.TextField(_('Комментарий к решению'), blank=True)
granted_ticket = models.ForeignKey(
'it_manager.Ticket', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='hiring_requests', verbose_name=_('Заявка в IT'),
)
access_task = models.ForeignKey(
'tasks.Task', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='+', verbose_name=_('Задача на выдачу доступов'),
)
# Пароль персонального акаунта після approve — для одноразового PDF.
issued_password = EncryptedCharField(
_('Выданный пароль (PDF)'), max_length=512, blank=True,
)
class Meta:
verbose_name = _('Заявка на найм')
verbose_name_plural = _('Заявки на найм')
ordering = ['-created_at']
indexes = [
models.Index(fields=['status']),
]
def __str__(self):
return f'{self.last_name} {self.first_name} ({self.get_status_display()})'
@property
def trial_end_date(self):
return self.hire_date + timedelta(days=self.trial_days)
@property
def is_overdue(self):
return self.status == 'trial' and self.trial_end_date < timezone.localdate()
def release_intern(self):
try:
account = self.intern_account
except Exception:
return
account.release()
class Vacancy(BaseModel):
"""Открытая вакансия (рекрутинг в HR-портале)."""
STATUS_CHOICES = [
('open', _('Открыта')),
('closed', _('Закрыта')),
('filled', _('Закрыта наймом')),
]
PRIORITY_CHOICES = [
('urgent', _('Срочно')),
('medium', _('Средний')),
('normal', _('Обычный')),
]
LOCATION_TYPES = [
('pharmacy', _('Аптека')),
('warehouse', _('Склад')),
('office', _('Офис')),
]
title = models.CharField(_('Название'), max_length=200)
post = models.ForeignKey(
'core.Directory', on_delete=models.SET_NULL,
null=True, blank=True,
limit_choices_to={'directory_type__code': 'post', 'is_active': True},
related_name='vacancies', verbose_name=_('Должность'),
)
user_location_type = models.CharField(
_('Тип локации'), max_length=20, choices=LOCATION_TYPES, default='pharmacy',
)
department = models.ForeignKey(
'core.Directory', on_delete=models.SET_NULL,
null=True, blank=True,
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
related_name='vacancies_department', verbose_name=_('Отдел'),
)
pharmacy = models.ForeignKey(
'locations.Pharmacy', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='pharmacy_vacancies', verbose_name=_('Аптека'),
)
location = models.ForeignKey(
'locations.Location', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='location_vacancies', verbose_name=_('Локация'),
)
priority = models.CharField(
_('Приоритет'), max_length=16, choices=PRIORITY_CHOICES, default='normal',
)
status = models.CharField(
_('Статус'), max_length=16, choices=STATUS_CHOICES, default='open',
)
opened_at = models.DateField(_('Открыта с'), default=timezone.localdate)
description = models.TextField(_('Описание'), blank=True)
candidates_count = models.PositiveIntegerField(_('Кандидатов'), default=0)
created_by = models.ForeignKey(
'employees.Employee', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='vacancies_created', verbose_name=_('Создал'),
)
class Meta:
verbose_name = _('Вакансия')
verbose_name_plural = _('Вакансии')
ordering = ['-opened_at', '-created_at']
indexes = [
models.Index(fields=['status', 'priority']),
]
def __str__(self):
return self.title
@property
def place_label(self) -> str:
if self.user_location_type == 'pharmacy' and self.pharmacy_id:
return str(self.pharmacy)
if self.department_id:
return self.department.name
if self.location_id:
return str(self.location)
return '—'