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:
2026-08-09 14:58:17 +03:00
parent 237275abd0
commit ae5c1f1848
63 changed files with 5987 additions and 2217 deletions
+91 -22
View File
@@ -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()