ae5c1f1848
Ship cash-register registry, section-based portal access, configurable dislocation columns, and trial-hire intern accounts with credentials PDF.
189 lines
7.7 KiB
Python
189 lines
7.7 KiB
Python
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 core.models import BaseModel
|
|
from core.models.fields import EncryptedCharField
|
|
from core.utils.validators import validate_ukrainian_phone
|
|
|
|
|
|
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],
|
|
)
|
|
|
|
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)
|
|
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()
|