Files
ACORE/apps/employees/models/base.py
T

382 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
EMPLOYMENT_STAFF = 'staff'
EMPLOYMENT_CONTRACT = 'contract'
EMPLOYMENT_TYPE_CHOICES = [
(EMPLOYMENT_STAFF, _('Официально (трудовой договор)')),
(EMPLOYMENT_CONTRACT, _('По договору (ЦПХ)')),
]
employment_type = models.CharField(
_('Оформление'),
max_length=16,
choices=EMPLOYMENT_TYPE_CHOICES,
default=EMPLOYMENT_STAFF,
db_index=True,
help_text=_('Штат по трудовому договору или работа по гражданско-правовому договору.'),
)
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=_('Только для офисных сотрудников'))
IT_WORK_CHANNEL_CHOICES = [
('', _('— не задано (авто из роли IT-отчёта)')),
('remote', _('Удалённо (без выезда)')),
('field', _('С выездом')),
('office', _('Офис')),
('infra', _('Инфраструктура / руководство (удалённо)')),
]
it_work_channel = models.CharField(
_('Канал работ (IT-отчёт)'),
max_length=16,
choices=IT_WORK_CHANNEL_CHOICES,
blank=True,
default='',
help_text=_(
'Как учитывать задачи в месячном IT-отчёте. '
'«Удалённо» и «Инфраструктура» — без выезда '
'(объект аптеки в заявке сам по себе выездом не считается). '
'«С выездом» — только для выездных инженеров.'
),
)
it_responsibility_zone = models.TextField(
_('Зона ответственности (IT-отчёт)'),
blank=True,
default='',
help_text=_(
'Текст для таблицы «Состав отдела» в месячном IT-отчёте Word/PDF. '
'Если пусто — берётся из company.json (fallback).'
),
)
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_contract_worker(self):
return self.employment_type == self.EMPLOYMENT_CONTRACT
@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)