Add RRO, portal section perms, dislocation presets, and hiring intern flow.
Ship cash-register registry, section-based portal access, configurable dislocation columns, and trial-hire intern accounts with credentials PDF.
This commit is contained in:
+91
-22
@@ -1,30 +1,82 @@
|
||||
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 на найм кандидата: створення тимчасового (стажерського)
|
||||
облікового запису та подальше затвердження/відхилення."""
|
||||
"""Заявка HR на найм кандидата: інтерн-слот + IT Ticket на випробуванні,
|
||||
персональний Employee — лише після затвердження."""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('trial', _('На випробуванні')),
|
||||
('approved', _('Затверджено')),
|
||||
('rejected', _('Відхилено')),
|
||||
('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)
|
||||
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_email = models.EmailField(_('Личный email кандидата'), blank=True)
|
||||
candidate_phone = models.CharField(
|
||||
_('Телефон кандидата'), max_length=20, blank=True,
|
||||
validators=[validate_ukrainian_phone],
|
||||
@@ -33,60 +85,65 @@ class HiringRequest(BaseModel):
|
||||
LOCATION_TYPES = [
|
||||
('pharmacy', _('Аптека')),
|
||||
('warehouse', _('Склад')),
|
||||
('office', _('Офіс')),
|
||||
('office', _('Офис')),
|
||||
]
|
||||
user_location_type = models.CharField(
|
||||
_('Тип локації'), max_length=20, choices=LOCATION_TYPES,
|
||||
_('Тип локации'), 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=_('Відділ'),
|
||||
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=_('Посада'),
|
||||
related_name='hiring_requests_post', verbose_name=_('Должность'),
|
||||
)
|
||||
|
||||
hire_date = models.DateField(_('Дата виходу'), default=timezone.localdate)
|
||||
trial_days = models.PositiveSmallIntegerField(
|
||||
_('Термін випробування, днів'), choices=TRIAL_DAYS_CHOICES, default=3,
|
||||
_('Срок испытания, дней'), 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=_('Потрібні доступи'),
|
||||
related_name='hiring_requests_access', verbose_name=_('Требуемые доступы'),
|
||||
)
|
||||
access_note = models.TextField(_('Примітка щодо доступів'), blank=True)
|
||||
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'),
|
||||
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=_('Обліковий запис'),
|
||||
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=_('Рішення прийняв'),
|
||||
related_name='hiring_requests_decided', verbose_name=_('Решение принял'),
|
||||
)
|
||||
decision_at = models.DateTimeField(_('Дата рішення'), null=True, blank=True)
|
||||
decision_note = models.TextField(_('Коментар до рішення'), blank=True)
|
||||
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,
|
||||
@@ -96,7 +153,12 @@ class HiringRequest(BaseModel):
|
||||
access_task = models.ForeignKey(
|
||||
'tasks.Task', on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='+', verbose_name=_('Задача на видачу доступів'),
|
||||
related_name='+', verbose_name=_('Задача на выдачу доступов'),
|
||||
)
|
||||
|
||||
# Пароль персонального акаунта після approve — для одноразового PDF.
|
||||
issued_password = EncryptedCharField(
|
||||
_('Выданный пароль (PDF)'), max_length=512, blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -117,3 +179,10 @@ class HiringRequest(BaseModel):
|
||||
@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()
|
||||
|
||||
Reference in New Issue
Block a user