a2fe4a49e3
Сотрудник сам подключает ящик (autoconfig, вложения, пагинация списка, надёжный IMAP-поиск); в хедере работает /api/search по разделам и сущностям с ACL. Также устойчивость Mattermost-токена для чата. Co-authored-by: Cursor <cursoragent@cursor.com>
371 lines
16 KiB
Python
Executable File
371 lines
16 KiB
Python
Executable File
# models.py
|
|
import logging
|
|
|
|
from django.contrib.postgres.fields import ArrayField
|
|
from django.db import models
|
|
from django.contrib.auth import get_user_model
|
|
from django.utils import timezone
|
|
|
|
from core.models import BaseModel
|
|
from core.models.fields import EncryptedCharField
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Типы локаций сотрудника (Employee.LOCATION_TYPES) — для фильтра синхронизации.
|
|
LOCATION_TYPE_CHOICES = [
|
|
('pharmacy', 'Аптека'),
|
|
('warehouse', 'Склад'),
|
|
('office', 'Офис'),
|
|
]
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class MattermostBot(models.Model):
|
|
"""Mattermost-бот с набором возможностей (галочки): провижининг сотрудников,
|
|
каналы, задачи/уведомления. Один бот может совмещать несколько ролей.
|
|
Токен хранится зашифрованным (EncryptedCharField)."""
|
|
|
|
ROLE_PROVISIONING = 'provisioning'
|
|
ROLE_CHANNELS = 'channels'
|
|
ROLE_TASKS = 'tasks'
|
|
ROLE_CHOICES = [
|
|
(ROLE_PROVISIONING, 'Провижининг (сотрудники)'),
|
|
(ROLE_CHANNELS, 'Каналы'),
|
|
(ROLE_TASKS, 'Задачи / уведомления портала'),
|
|
]
|
|
# Возможность (ключ get_active) → поле-галочка.
|
|
CAP_FIELD = {
|
|
ROLE_PROVISIONING: 'can_provisioning',
|
|
ROLE_CHANNELS: 'can_channels',
|
|
ROLE_TASKS: 'can_tasks',
|
|
}
|
|
|
|
display_name = models.CharField('Имя', max_length=100, blank=True)
|
|
username = models.CharField('Username в Mattermost', max_length=150, blank=True)
|
|
user_id = models.CharField('User ID в Mattermost', max_length=100, blank=True)
|
|
# Access-токен хранится зашифрованным; max_length с запасом под шифртекст.
|
|
token = EncryptedCharField('Access-токен', max_length=512, blank=True)
|
|
can_provisioning = models.BooleanField('Провижининг (сотрудники)', default=False)
|
|
can_channels = models.BooleanField('Каналы', default=False)
|
|
can_tasks = models.BooleanField('Задачи / уведомления портала', default=False)
|
|
is_active = models.BooleanField('Активен', default=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'mattermost_bots'
|
|
verbose_name = 'Mattermost-бот'
|
|
verbose_name_plural = 'Mattermost-боты'
|
|
ordering = ['display_name', 'username']
|
|
|
|
def caps(self) -> list:
|
|
"""Список названий включённых возможностей."""
|
|
return [lbl for key, lbl in self.ROLE_CHOICES if getattr(self, self.CAP_FIELD[key])]
|
|
|
|
def __str__(self):
|
|
label = self.display_name or self.username or 'Mattermost-бот'
|
|
return f'{label} ({", ".join(self.caps()) or "без ролей"})'
|
|
|
|
@classmethod
|
|
def get_active(cls, role: str) -> 'MattermostBot | None':
|
|
"""Активный бот с включённой возможностью role (provisioning/channels/tasks)."""
|
|
field = cls.CAP_FIELD.get(role)
|
|
if not field:
|
|
return None
|
|
return cls.objects.filter(is_active=True, **{field: True}).order_by('id').first()
|
|
|
|
class MattermostUser(models.Model):
|
|
"""Связь между Django пользователем и Mattermost пользователем"""
|
|
django_user = models.OneToOneField(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name='mattermost_profile'
|
|
)
|
|
mattermost_id = models.CharField(max_length=100, unique=True)
|
|
username = models.CharField(max_length=150)
|
|
email = models.EmailField()
|
|
is_active = models.BooleanField(default=True)
|
|
access_token_encrypted = models.TextField(blank=True, default='')
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
last_sync = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
db_table = 'mattermost_users'
|
|
verbose_name = 'Mattermost User'
|
|
verbose_name_plural = 'Mattermost Users'
|
|
|
|
def __str__(self):
|
|
return f"{self.django_user.username} -> {self.username}"
|
|
|
|
def set_access_token(self, plain: str) -> None:
|
|
from core.utils.crypto import encrypt_secret
|
|
self.access_token_encrypted = encrypt_secret(plain)
|
|
|
|
def get_access_token(self) -> str | None:
|
|
"""Расшифровать PAT. None, если пусто или ключ шифрования не подходит (ротация)."""
|
|
from core.utils.crypto import decrypt_secret
|
|
|
|
if not self.access_token_encrypted:
|
|
return None
|
|
try:
|
|
return decrypt_secret(self.access_token_encrypted)
|
|
except Exception:
|
|
logger.warning(
|
|
'MattermostUser id=%s: не удалось расшифровать access_token '
|
|
'(FIELD_ENCRYPTION_KEY?). Токен будет пересоздан.',
|
|
self.pk,
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
class MattermostTeam(models.Model):
|
|
"""Команды Mattermost"""
|
|
team_id = models.CharField(max_length=100, unique=True)
|
|
name = models.CharField(max_length=150)
|
|
display_name = models.CharField(max_length=150)
|
|
description = models.TextField(blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = 'mattermost_teams'
|
|
|
|
def __str__(self):
|
|
return self.display_name
|
|
|
|
class MattermostChannel(models.Model):
|
|
"""Каналы Mattermost"""
|
|
CHANNEL_TYPES = [
|
|
('O', 'Open'),
|
|
('P', 'Private'),
|
|
('D', 'Direct'),
|
|
('G', 'Group'),
|
|
]
|
|
|
|
channel_id = models.CharField(max_length=100, unique=True)
|
|
team = models.ForeignKey(MattermostTeam, on_delete=models.CASCADE)
|
|
name = models.CharField(max_length=150)
|
|
display_name = models.CharField(max_length=150)
|
|
channel_type = models.CharField(max_length=1, choices=CHANNEL_TYPES)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = 'mattermost_channels'
|
|
|
|
def __str__(self):
|
|
return f"{self.team.name}:{self.name}"
|
|
|
|
class MattermostTeamMembership(models.Model):
|
|
"""Членство пользователей в командах"""
|
|
user = models.ForeignKey(MattermostUser, on_delete=models.CASCADE)
|
|
team = models.ForeignKey(MattermostTeam, on_delete=models.CASCADE)
|
|
role = models.CharField(max_length=50, default='team_user')
|
|
joined_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = 'mattermost_team_memberships'
|
|
unique_together = ['user', 'team']
|
|
|
|
class MattermostChannelMembership(models.Model):
|
|
"""Членство пользователей в каналах"""
|
|
user = models.ForeignKey(MattermostUser, on_delete=models.CASCADE)
|
|
channel = models.ForeignKey(MattermostChannel, on_delete=models.CASCADE)
|
|
role = models.CharField(max_length=50, default='channel_user')
|
|
joined_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
db_table = 'mattermost_channel_memberships'
|
|
unique_together = ['user', 'channel']
|
|
|
|
|
|
class TeamSyncSettings(models.Model):
|
|
"""Настройки синхронизации команд Mattermost"""
|
|
team = models.OneToOneField(
|
|
MattermostTeam,
|
|
on_delete=models.CASCADE,
|
|
related_name='sync_settings',
|
|
verbose_name='Команда Mattermost'
|
|
)
|
|
|
|
auto_add_users = models.BooleanField(
|
|
default=True,
|
|
verbose_name='Автоматически добавлять пользователей',
|
|
help_text='Автоматически добавлять новых пользователей в эту команду при синхронизации'
|
|
)
|
|
|
|
is_default = models.BooleanField(
|
|
default=False,
|
|
verbose_name='Команда по умолчанию',
|
|
help_text='Использовать эту команду как основную для синхронизации'
|
|
)
|
|
|
|
priority = models.IntegerField(
|
|
default=0,
|
|
verbose_name='Приоритет',
|
|
help_text='Порядок добавления пользователей (выше = раньше)'
|
|
)
|
|
|
|
filter_by_department = models.BooleanField(
|
|
default=False,
|
|
verbose_name='Фильтровать по отделам',
|
|
help_text='Добавлять только пользователей из указанных отделов'
|
|
)
|
|
|
|
departments = models.ManyToManyField(
|
|
'core.Directory',
|
|
blank=True,
|
|
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
|
|
verbose_name='Отделы',
|
|
help_text='Отделы, пользователи которых будут добавлены в команду'
|
|
)
|
|
|
|
filter_by_location_type = models.BooleanField(
|
|
default=False,
|
|
verbose_name='Фильтровать по типу локации',
|
|
help_text='Добавлять только пользователей с выбранным типом локации (Аптека/Склад/Офис)'
|
|
)
|
|
|
|
location_types = ArrayField(
|
|
models.CharField(max_length=20, choices=LOCATION_TYPE_CHOICES),
|
|
blank=True, default=list,
|
|
verbose_name='Типы локаций',
|
|
help_text='Пользователи с этими типами локации будут добавлены в команду'
|
|
)
|
|
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
verbose_name='Активна',
|
|
help_text='Включить/выключить синхронизацию для этой команды'
|
|
)
|
|
|
|
notes = models.TextField(
|
|
blank=True,
|
|
verbose_name='Примечания',
|
|
help_text='Дополнительная информация о команде'
|
|
)
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'mattermost_team_sync_settings'
|
|
verbose_name = 'Настройки синхронизации команды'
|
|
verbose_name_plural = 'Настройки синхронизации команд'
|
|
ordering = ['-priority', 'team__display_name']
|
|
|
|
def __str__(self):
|
|
status = "✓" if self.is_active else "✗"
|
|
default = " (по умолчанию)" if self.is_default else ""
|
|
return f"{status} {self.team.display_name}{default}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
# Если эта команда устанавливается как default, снимаем флаг с других
|
|
if self.is_default:
|
|
TeamSyncSettings.objects.filter(is_default=True).exclude(pk=self.pk).update(is_default=False)
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class ChannelSyncSettings(models.Model):
|
|
"""Настройки синхронизации каналов Mattermost"""
|
|
channel = models.OneToOneField(
|
|
MattermostChannel,
|
|
on_delete=models.CASCADE,
|
|
related_name='sync_settings',
|
|
verbose_name='Канал Mattermost'
|
|
)
|
|
|
|
auto_add_users = models.BooleanField(
|
|
default=True,
|
|
verbose_name='Автоматически добавлять пользователей',
|
|
help_text='Автоматически добавлять новых пользователей в этот канал при синхронизации'
|
|
)
|
|
|
|
priority = models.IntegerField(
|
|
default=0,
|
|
verbose_name='Приоритет',
|
|
help_text='Порядок добавления пользователей (выше = раньше)'
|
|
)
|
|
|
|
filter_by_department = models.BooleanField(
|
|
default=False,
|
|
verbose_name='Фильтровать по отделам',
|
|
help_text='Добавлять только пользователей из указанных отделов'
|
|
)
|
|
|
|
departments = models.ManyToManyField(
|
|
'core.Directory',
|
|
blank=True,
|
|
limit_choices_to={'directory_type__code': 'department', 'is_active': True},
|
|
verbose_name='Отделы',
|
|
help_text='Отделы, пользователи которых будут добавлены в канал'
|
|
)
|
|
|
|
filter_by_location_type = models.BooleanField(
|
|
default=False,
|
|
verbose_name='Фильтровать по типу локации',
|
|
help_text='Добавлять только пользователей с выбранным типом локации (Аптека/Склад/Офис)'
|
|
)
|
|
|
|
location_types = ArrayField(
|
|
models.CharField(max_length=20, choices=LOCATION_TYPE_CHOICES),
|
|
blank=True, default=list,
|
|
verbose_name='Типы локаций',
|
|
help_text='Пользователи с этими типами локации будут добавлены в канал'
|
|
)
|
|
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
verbose_name='Активна',
|
|
help_text='Включить/выключить синхронизацию для этого канала'
|
|
)
|
|
|
|
notes = models.TextField(
|
|
blank=True,
|
|
verbose_name='Примечания',
|
|
help_text='Дополнительная информация о канале'
|
|
)
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'mattermost_channel_sync_settings'
|
|
verbose_name = 'Настройки синхронизации канала'
|
|
verbose_name_plural = 'Настройки синхронизации каналов'
|
|
ordering = ['-priority', 'channel__display_name']
|
|
|
|
def __str__(self):
|
|
status = "✓" if self.is_active else "✗"
|
|
return f"{status} {self.channel.display_name} ({self.channel.team.display_name})"
|
|
|
|
class MattermostSyncLog(BaseModel):
|
|
"""Журнал синхронизации сотрудников с Mattermost (по образцу AD/Carbonio/Nextcloud)."""
|
|
employee = models.ForeignKey(
|
|
'employees.Employee', on_delete=models.CASCADE,
|
|
related_name='mattermost_sync_logs', verbose_name='Сотрудник',
|
|
)
|
|
action = models.CharField(
|
|
'Действие', max_length=20,
|
|
choices=[
|
|
('create', 'Создание пользователя'),
|
|
('add_team', 'Добавление в команду'),
|
|
('add_channel', 'Добавление в канал'),
|
|
('update', 'Обновление'),
|
|
('deactivate', 'Отключение'),
|
|
],
|
|
)
|
|
status = models.CharField(
|
|
'Статус', max_length=10,
|
|
choices=[('success', 'Успех'), ('error', 'Ошибка')],
|
|
)
|
|
message = models.TextField('Сообщение', blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = 'Запись синхронизации Mattermost'
|
|
verbose_name_plural = 'Журнал синхронизации Mattermost'
|
|
ordering = ['-created_at']
|
|
|
|
def __str__(self):
|
|
return f'{self.employee} — {self.get_action_display()} — {self.get_status_display()}'
|