mattermost: возможности бота галочками вместо одной роли

Бот больше не привязан к единственной role (unique). Вместо неё три флага-
галочки: can_provisioning / can_channels / can_tasks — можно совмещать.
get_active(role) ищет активного бота с соответствующей галочкой.

- модель: убран role, добавлены 3 boolean, get_active/caps по флагам;
- миграция 0006: перенос старой role в галочки, затем удаление role;
- админка: чекбоксы «Возможности бота»;
- команда create_mattermost_bot: --role включает нужную галочку (username-based upsert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 20:27:09 +03:00
parent e885e2924b
commit 982c7e6203
4 changed files with 131 additions and 51 deletions
+15 -4
View File
@@ -61,11 +61,22 @@ class ChannelSyncSettingsForm(forms.ModelForm):
@admin.register(MattermostBot)
class MattermostBotAdmin(ModelAdmin):
"""Боты Mattermost по ролям. Токен хранится в БД зашифрованным."""
list_display = ('display_name', 'role', 'username', 'user_id', 'is_active', 'updated_at')
list_filter = ('role', 'is_active')
"""Боты Mattermost. Возможности выдаются галочками (провижинг/каналы/задачи).
Токен хранится в БД зашифрованным."""
list_display = ('display_name', 'caps_display', 'username', 'user_id', 'is_active', 'updated_at')
list_filter = ('is_active', 'can_provisioning', 'can_channels', 'can_tasks')
search_fields = ('display_name', 'username', 'user_id')
fields = ('role', 'display_name', 'username', 'user_id', 'token', 'is_active')
fieldsets = (
(None, {'fields': ('display_name', 'username', 'user_id', 'token', 'is_active')}),
('Возможности бота', {
'fields': ('can_provisioning', 'can_channels', 'can_tasks'),
'description': 'Отметьте, что этот бот умеет делать. Один бот может совмещать несколько ролей.',
}),
)
@display(description='Возможности')
def caps_display(self, obj):
return ', '.join(obj.caps()) or '—'
@admin.register(MattermostTeam)
@@ -10,7 +10,7 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('--role', default='tasks',
choices=['provisioning', 'channels', 'tasks'],
help='Роль бота (MattermostBot.role)')
help='Возможность бота (галочка), которую активировать')
parser.add_argument('--username', default='acore-portal-bot')
parser.add_argument('--display-name', default='ACORE Портал')
parser.add_argument('--force', action='store_true', help='Пересоздать токен, даже если бот уже настроен')
@@ -28,18 +28,22 @@ class Command(BaseCommand):
raise CommandError('Коннектор Mattermost не настроен или отключён')
role = options['role']
cap_field = MattermostBot.CAP_FIELD[role]
def _upsert(user_id, token):
MattermostBot.objects.update_or_create(
role=role,
bot, _ = MattermostBot.objects.update_or_create(
username=options['username'],
defaults={
'username': options['username'],
'display_name': options['display_name'],
'user_id': user_id,
'token': token,
'is_active': True,
},
)
# Включаем нужную возможность-галочку, не сбрасывая уже выданные.
if not getattr(bot, cap_field):
setattr(bot, cap_field, True)
bot.save(update_fields=[cap_field])
if options['set_token']:
# Провижининг-бот уже должен существовать, чтобы найти нового по username.
@@ -50,7 +54,7 @@ class Command(BaseCommand):
bot_user = service.driver.users.get_user_by_username(options['username'])
_upsert(bot_user['id'], options['set_token'])
self.stdout.write(self.style.SUCCESS(
f"Токен бота {options['username']} (id={bot_user['id']}) сохранён как MattermostBot(role={role})"
f"Токен бота {options['username']} (id={bot_user['id']}) сохранён; включена возможность «{role}»"
))
return
@@ -84,4 +88,4 @@ class Command(BaseCommand):
token = driver.users.create_user_access_token(bot['user_id'], description='ACORE portal notifications')
_upsert(bot['user_id'], token['token'])
self.stdout.write(self.style.SUCCESS(f'Токен бота сохранён как MattermostBot(role={role})'))
self.stdout.write(self.style.SUCCESS(f'Токен бота сохранён; включена возможность «{role}»'))
@@ -0,0 +1,51 @@
from django.db import migrations, models
def role_to_caps(apps, schema_editor):
Bot = apps.get_model('mattermost_connector', 'MattermostBot')
for bot in Bot.objects.all():
if bot.role == 'provisioning':
bot.can_provisioning = True
elif bot.role == 'channels':
bot.can_channels = True
elif bot.role == 'tasks':
bot.can_tasks = True
bot.save(update_fields=['can_provisioning', 'can_channels', 'can_tasks'])
class Migration(migrations.Migration):
dependencies = [
('mattermost_connector', '0005_mattermostsynclog'),
]
operations = [
migrations.AddField(
model_name='mattermostbot',
name='can_provisioning',
field=models.BooleanField(default=False, verbose_name='Провижининг (сотрудники)'),
),
migrations.AddField(
model_name='mattermostbot',
name='can_channels',
field=models.BooleanField(default=False, verbose_name='Каналы'),
),
migrations.AddField(
model_name='mattermostbot',
name='can_tasks',
field=models.BooleanField(default=False, verbose_name='Задачи / уведомления портала'),
),
migrations.RunPython(role_to_caps, migrations.RunPython.noop),
migrations.AlterModelOptions(
name='mattermostbot',
options={
'ordering': ['display_name', 'username'],
'verbose_name': 'Mattermost-бот',
'verbose_name_plural': 'Mattermost-боты',
},
),
migrations.RemoveField(
model_name='mattermostbot',
name='role',
),
]
+55 -41
View File
@@ -18,10 +18,9 @@ User = get_user_model()
class MattermostBot(models.Model):
"""Отдельный Mattermost-бот на роль. Каждая интеграционная операция берёт
бота по своей роли, а не общий сервисный токен. Токен хранится в БД
зашифрованным (EncryptedCharField). Имя бота (display_name/username) —
только метка: сменить тему/переименовать можно, не трогая код."""
"""Mattermost-бот с набором возможностей (галочки): провижининг сотрудников,
каналы, задачи/уведомления. Один бот может совмещать несколько ролей.
Токен хранится зашифрованным (EncryptedCharField)."""
ROLE_PROVISIONING = 'provisioning'
ROLE_CHANNELS = 'channels'
@@ -31,13 +30,21 @@ class MattermostBot(models.Model):
(ROLE_CHANNELS, 'Каналы'),
(ROLE_TASKS, 'Задачи / уведомления портала'),
]
# Возможность (ключ get_active) → поле-галочка.
CAP_FIELD = {
ROLE_PROVISIONING: 'can_provisioning',
ROLE_CHANNELS: 'can_channels',
ROLE_TASKS: 'can_tasks',
}
role = models.CharField('Роль', max_length=20, choices=ROLE_CHOICES, unique=True)
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)
@@ -46,16 +53,23 @@ class MattermostBot(models.Model):
db_table = 'mattermost_bots'
verbose_name = 'Mattermost-бот'
verbose_name_plural = 'Mattermost-боты'
ordering = ['role']
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 self.get_role_display()
return f'{label} ({self.get_role_display()})'
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':
"""Активный бот для роли или None."""
return cls.objects.filter(role=role, is_active=True).first()
"""Активный бот с включённой возможностью 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 пользователем"""
@@ -306,34 +320,34 @@ class ChannelSyncSettings(models.Model):
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()}'
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()}'