31eecb5001
Infra/leadership channel no longer looks like on-site visits; labels clarify remote-only infrastructure. Co-authored-by: Cursor <cursoragent@cursor.com>
354 lines
16 KiB
Python
354 lines
16 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=_('Только для офисных сотрудников'))
|
||
|
||
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-отчёте. '
|
||
'«Удалённо» и «Инфраструктура» — без выезда '
|
||
'(объект аптеки в заявке сам по себе выездом не считается). '
|
||
'«С выездом» — только для выездных инженеров.'
|
||
),
|
||
)
|
||
|
||
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) |