Files
ACORE/apps/mattermost_connector/models.py
T
lokses a2fe4a49e3 mail + portal: самоподключение почты, веб-клиент и глобальный поиск
Сотрудник сам подключает ящик (autoconfig, вложения, пагинация списка, надёжный IMAP-поиск); в хедере работает /api/search по разделам и сущностям с ACL. Также устойчивость Mattermost-токена для чата.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 12:22:53 +03:00

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