diff --git a/apps/mattermost_connector/admin.py b/apps/mattermost_connector/admin.py index 5ecd865..be2d19d 100755 --- a/apps/mattermost_connector/admin.py +++ b/apps/mattermost_connector/admin.py @@ -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) diff --git a/apps/mattermost_connector/management/commands/create_mattermost_bot.py b/apps/mattermost_connector/management/commands/create_mattermost_bot.py index f1c5951..bdac5e3 100644 --- a/apps/mattermost_connector/management/commands/create_mattermost_bot.py +++ b/apps/mattermost_connector/management/commands/create_mattermost_bot.py @@ -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}»')) diff --git a/apps/mattermost_connector/migrations/0006_mattermostbot_capabilities.py b/apps/mattermost_connector/migrations/0006_mattermostbot_capabilities.py new file mode 100644 index 0000000..44f1fa6 --- /dev/null +++ b/apps/mattermost_connector/migrations/0006_mattermostbot_capabilities.py @@ -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', + ), + ] diff --git a/apps/mattermost_connector/models.py b/apps/mattermost_connector/models.py index 37c86da..ceb659b 100755 --- a/apps/mattermost_connector/models.py +++ b/apps/mattermost_connector/models.py @@ -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()}'