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() 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 '—'