ae5c1f1848
Ship cash-register registry, section-based portal access, configurable dislocation columns, and trial-hire intern accounts with credentials PDF.
333 lines
15 KiB
Python
333 lines
15 KiB
Python
from django.db import models
|
|
from django.utils import timezone
|
|
from django.core.exceptions import ValidationError
|
|
from django.utils.translation import gettext_lazy as _
|
|
from core.models import BaseModel
|
|
from core.models.fields import EncryptedCharField
|
|
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
|
|
from core.utils.validators import validate_ukrainian_phone, validate_birth_date, validate_ukrainian_tax_number
|
|
from core.utils.pagination import PAGE_SIZE_CHOICES, DEFAULT_PAGE_SIZE
|
|
|
|
class EmployeeManager(BaseUserManager):
|
|
def create_user(self, email=None, password=None, **extra_fields):
|
|
if not email:
|
|
username = extra_fields.get('username')
|
|
if username:
|
|
email = f"{username}@local.domain"
|
|
else:
|
|
raise ValueError('Необходимо указать email или username')
|
|
email = self.normalize_email(email)
|
|
user = self.model(email=email, **extra_fields)
|
|
user.set_password(password)
|
|
user.save(using=self._db)
|
|
return user
|
|
|
|
def create_superuser(self, email=None, password=None, **extra_fields):
|
|
extra_fields.setdefault('is_staff', True)
|
|
extra_fields.setdefault('is_superuser', True)
|
|
extra_fields.setdefault('is_active', True)
|
|
return self.create_user(email, password, **extra_fields)
|
|
|
|
|
|
class Employee(AbstractBaseUser, PermissionsMixin, BaseModel):
|
|
username = models.CharField(
|
|
_('Логин'), max_length=150, unique=True,
|
|
help_text=_('Уникальный логин для входа в систему')
|
|
)
|
|
email = models.EmailField(
|
|
_('Email адрес'), unique=True, blank=True, null=True,
|
|
help_text=_('Рабочий email. Если не указан — генерируется из username@local.domain')
|
|
)
|
|
|
|
last_name = models.CharField(_('Фамилия'), max_length=150)
|
|
first_name = models.CharField(_('Имя'), max_length=150)
|
|
middle_name = models.CharField(_('Отчество'), max_length=150, blank=True)
|
|
|
|
is_staff = models.BooleanField(_('Доступ к панели администратора'), default=False,
|
|
help_text=_('Разрешает вход в Django admin. Только для IT-администраторов системы.'))
|
|
is_active = models.BooleanField(_('Активный'), default=True,
|
|
help_text=_('Работает ли сотрудник сейчас'))
|
|
date_joined = models.DateTimeField(_('Дата создания аккаунта'), default=timezone.now)
|
|
|
|
birth_date = models.DateField(
|
|
_('Дата рождения'), null=True, blank=True,
|
|
validators=[validate_birth_date]
|
|
)
|
|
inn_code = models.CharField(
|
|
_('ИНН'), max_length=10, blank=True, null=True,
|
|
help_text=_('Идентификационный номер налогоплательщика'),
|
|
validators=[validate_ukrainian_tax_number]
|
|
)
|
|
|
|
phone = models.CharField(
|
|
_('Рабочий телефон'), max_length=20, blank=True,
|
|
validators=[validate_ukrainian_phone]
|
|
)
|
|
internal_number = models.CharField(
|
|
_('Внутрішній номер'), max_length=10, blank=True,
|
|
help_text=_('Короткий внутрішній (додатковий) номер АТС')
|
|
)
|
|
personal_phone = models.CharField(
|
|
_('Личный телефон'), max_length=20, blank=True,
|
|
validators=[validate_ukrainian_phone]
|
|
)
|
|
personal_email = models.EmailField(_('Личный email'), blank=True)
|
|
|
|
emergency_contact_name = models.CharField(_('Контакт в экстренных случаях'), max_length=200, blank=True)
|
|
emergency_contact_phone = models.CharField(_('Телефон экстренного контакта'), max_length=20, blank=True,
|
|
validators=[validate_ukrainian_phone])
|
|
emergency_contact_relation = models.CharField(_('Степень родства'), max_length=100, blank=True)
|
|
|
|
LOCATION_TYPES = [
|
|
('pharmacy', _('Аптека')),
|
|
('warehouse', _('Склад')),
|
|
('office', _('Офис')),
|
|
]
|
|
user_location_type = models.CharField(
|
|
_('Тип локации'), max_length=20, choices=LOCATION_TYPES,
|
|
help_text=_('Где работает: аптека / склад / офис'),
|
|
null=True, blank=True
|
|
)
|
|
user_organization = models.ForeignKey(
|
|
'core.Directory', on_delete=models.SET_NULL,
|
|
null=True, blank=True,
|
|
limit_choices_to={'directory_type__code': 'organization', 'is_active': True},
|
|
related_name='organization_employees', verbose_name=_('Организация'),
|
|
help_text=_('Юрлицо: АКС, АКС Одесса, Тира, Пентальфа...')
|
|
)
|
|
user_pharmacy = models.ForeignKey(
|
|
'locations.Pharmacy', on_delete=models.SET_NULL,
|
|
null=True, blank=True,
|
|
related_name='pharmacy_employees', verbose_name=_('Аптека'),
|
|
help_text=_('Заполняется только для location_type=pharmacy')
|
|
)
|
|
user_location = models.ForeignKey(
|
|
'locations.Location', on_delete=models.SET_NULL,
|
|
null=True, blank=True,
|
|
related_name='location_employees', verbose_name=_('Локация (офис/склад)'),
|
|
help_text=_('Заполняется для location_type=office или warehouse')
|
|
)
|
|
department = models.ForeignKey(
|
|
'core.Directory', on_delete=models.SET_NULL,
|
|
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
|
|
related_name='department_employees', verbose_name=_('Отдел'),
|
|
null=True, blank=True,
|
|
help_text=_('Заполняется для office / warehouse')
|
|
)
|
|
post = models.ForeignKey(
|
|
'core.Directory', on_delete=models.SET_NULL,
|
|
limit_choices_to={'directory_type__code': 'post', 'is_active': True},
|
|
related_name='post_employees', verbose_name=_('Должность'),
|
|
null=True, blank=True
|
|
)
|
|
manager = models.ForeignKey(
|
|
'self', on_delete=models.SET_NULL,
|
|
null=True, blank=True,
|
|
related_name='subordinates', verbose_name=_('Руководитель')
|
|
)
|
|
|
|
hire_date = models.DateField(_('Дата приёма на работу'))
|
|
termination_date = models.DateField(_('Дата увольнения'), null=True, blank=True)
|
|
probation_end_date = models.DateField(_('Конец испытательного срока'), null=True, blank=True)
|
|
|
|
photo = models.ImageField(
|
|
_('Фото'), upload_to='employees/photos/%Y/%m/',
|
|
blank=True, null=True
|
|
)
|
|
|
|
mattermost_user_id = models.CharField(_('ID Mattermost'), max_length=50, blank=True, null=True)
|
|
mattermost_username = models.CharField(_('Логин Mattermost'), max_length=50, blank=True, null=True)
|
|
mattermost_sync_status = models.CharField(
|
|
_('Статус синхронизации Mattermost'), max_length=20,
|
|
choices=[
|
|
('pending', _('Ожидает')),
|
|
('synced', _('Синхронизирован')),
|
|
('error', _('Ошибка')),
|
|
('disabled', _('Отключена')),
|
|
],
|
|
default='pending'
|
|
)
|
|
mattermost_last_sync = models.DateTimeField(_('Последняя синхронизация Mattermost'), null=True, blank=True)
|
|
mattermost_sync_enabled = models.BooleanField(_('Синхронизация Mattermost'), default=True)
|
|
|
|
ad_user_dn = models.CharField('AD User DN', max_length=500, blank=True,
|
|
help_text='CN=Ivanov Ivan,OU=Users,DC=company,DC=local')
|
|
ad_sync_status = models.CharField(
|
|
_('Статус синхронизации AD'), max_length=20,
|
|
choices=[
|
|
('not_synced', _('Не синхронизирован')),
|
|
('synced', _('Синхронизирован')),
|
|
('error', _('Ошибка')),
|
|
('disabled', _('Отключена')),
|
|
],
|
|
default='not_synced'
|
|
)
|
|
ad_sync_error = models.TextField(_('Ошибка синхронизации AD'), blank=True)
|
|
ad_last_sync = models.DateTimeField(_('Последняя синхронизация AD'), null=True, blank=True)
|
|
ad_sync_enabled = models.BooleanField(_('Синхронизация AD'), default=False,
|
|
help_text=_('Только для офисных сотрудников'))
|
|
|
|
default_page_size = models.PositiveSmallIntegerField(
|
|
_('Записей на странице'), choices=[(n, str(n)) for n in PAGE_SIZE_CHOICES],
|
|
default=DEFAULT_PAGE_SIZE,
|
|
help_text=_('Сколько строк показывать в таблицах портала по умолчанию'),
|
|
)
|
|
|
|
dislocation_columns = models.JSONField(
|
|
_('Колонки дислокации'),
|
|
null=True, blank=True,
|
|
help_text=_(
|
|
'Персональный набор колонок таблицы /dislocation/. '
|
|
'Пусто — пресет отдела или системный «Стандарт».'
|
|
),
|
|
)
|
|
|
|
last_seen = models.DateTimeField(
|
|
_('Был онлайн'), null=True, blank=True,
|
|
help_text=_('Обновляется автоматически при активности в портале (не в AD/Mattermost)'),
|
|
)
|
|
|
|
# --- Двухфакторная аутентификация (TOTP) ---
|
|
totp_secret = EncryptedCharField(
|
|
_('TOTP-секрет'), max_length=255, blank=True,
|
|
help_text=_('Секрет TOTP (шифруется). Заполняется при настройке 2FA.'),
|
|
)
|
|
totp_enabled = models.BooleanField(_('2FA включена'), default=False)
|
|
totp_backup_codes = models.JSONField(
|
|
_('Резервные коды 2FA (хеши)'), default=list, blank=True,
|
|
help_text=_('Хеши одноразовых резервных кодов; использованный код удаляется.'),
|
|
)
|
|
|
|
objects = EmployeeManager()
|
|
|
|
USERNAME_FIELD = 'username'
|
|
REQUIRED_FIELDS = ['first_name', 'last_name', 'hire_date']
|
|
|
|
class Meta:
|
|
verbose_name = _('Сотрудник')
|
|
verbose_name_plural = _('Сотрудники')
|
|
indexes = [
|
|
models.Index(fields=['last_name', 'first_name', 'middle_name']),
|
|
models.Index(fields=['email']),
|
|
models.Index(fields=['is_active']),
|
|
]
|
|
|
|
def __str__(self):
|
|
if self.middle_name:
|
|
return f"{self.last_name} {self.first_name} {self.middle_name}"
|
|
return f"{self.last_name} {self.first_name}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.email and self.username:
|
|
self.email = f"{self.username}@local.domain"
|
|
super().save(*args, **kwargs)
|
|
|
|
def clean(self):
|
|
super().clean()
|
|
if self.user_location_type:
|
|
loc = self.user_location_type
|
|
if loc == 'pharmacy':
|
|
if not self.user_pharmacy_id:
|
|
raise ValidationError({'user_pharmacy': 'Для фармацевта обязательно укажите аптеку'})
|
|
if self.department_id:
|
|
raise ValidationError({'department': 'Для фармацевта отдел не указывается'})
|
|
if self.user_location_id:
|
|
raise ValidationError({'user_location': 'Локация (офис/склад) — только для office/warehouse'})
|
|
elif loc in ['office', 'warehouse']:
|
|
if not self.department_id:
|
|
raise ValidationError({'department': f'Для сотрудника {loc} обязателен отдел'})
|
|
if not self.user_location_id:
|
|
raise ValidationError({'user_location': 'Укажите конкретную локацию (офис или склад)'})
|
|
if self.user_location_id and self.user_location.location_type != loc:
|
|
raise ValidationError({'user_location': f'Локация должна быть типа «{self.get_user_location_type_display()}»'})
|
|
if self.user_pharmacy_id:
|
|
raise ValidationError({'user_pharmacy': 'Аптека только для фармацевтов'})
|
|
|
|
if self.post_id and self.user_location_type:
|
|
if self.post.location_types.exists():
|
|
if not self.post.location_types.filter(code=self.user_location_type).exists():
|
|
names = ', '.join(lt.name for lt in self.post.location_types.all())
|
|
raise ValidationError({'post': f'Должность доступна только для: {names}'})
|
|
|
|
if self.termination_date and self.hire_date:
|
|
if self.termination_date < self.hire_date:
|
|
raise ValidationError({'termination_date': 'Не может быть раньше даты приёма'})
|
|
|
|
if self.manager_id and self.manager_id == self.pk:
|
|
raise ValidationError({'manager': 'Сотрудник не может быть своим руководителем'})
|
|
|
|
@property
|
|
def short_name(self):
|
|
s = self.last_name
|
|
if self.first_name:
|
|
s += f" {self.first_name[0]}."
|
|
if self.middle_name:
|
|
s += f"{self.middle_name[0]}."
|
|
return s
|
|
|
|
@property
|
|
def age(self):
|
|
if not self.birth_date:
|
|
return None
|
|
today = timezone.now().date()
|
|
age = today.year - self.birth_date.year
|
|
if today < self.birth_date.replace(year=today.year):
|
|
age -= 1
|
|
return age
|
|
|
|
@property
|
|
def work_experience(self):
|
|
if not self.hire_date:
|
|
return 0
|
|
end = self.termination_date or timezone.now().date()
|
|
exp = end.year - self.hire_date.year
|
|
if end < self.hire_date.replace(year=end.year):
|
|
exp -= 1
|
|
return max(0, exp)
|
|
|
|
@property
|
|
def is_on_probation(self):
|
|
if not self.probation_end_date:
|
|
return False
|
|
return timezone.now().date() <= self.probation_end_date
|
|
|
|
@property
|
|
def is_manager(self):
|
|
return self.subordinates.exists()
|
|
|
|
@property
|
|
def subordinate_count(self):
|
|
return self.subordinates.filter(is_active=True).count()
|
|
|
|
def get_manager_chain(self):
|
|
managers = []
|
|
current = self.manager
|
|
while current and len(managers) < 10:
|
|
managers.append(current)
|
|
current = current.manager
|
|
return managers
|
|
|
|
def get_full_name(self):
|
|
return self.__str__()
|
|
|
|
def get_short_name(self):
|
|
return self.short_name
|
|
|
|
def has_perm(self, perm, obj=None):
|
|
today = timezone.now().date()
|
|
codename = perm.split('.')[-1]
|
|
override = self.permission_overrides.filter(
|
|
permission__codename=codename,
|
|
is_active=True,
|
|
).filter(
|
|
models.Q(valid_from__isnull=True) | models.Q(valid_from__lte=today)
|
|
).filter(
|
|
models.Q(valid_until__isnull=True) | models.Q(valid_until__gte=today)
|
|
).values_list('override_type', flat=True).first()
|
|
|
|
if override == 'revoke':
|
|
return False
|
|
if override == 'grant':
|
|
return True
|
|
|
|
return super().has_perm(perm, obj) |