AD integration, Mattermost client rewrite, Equipment brand to Directory
- ad_connector: full Django→AD provisioning via ldap3 — LDAP client, sync service (create/update/rename CN/enable-disable), Celery tasks, signals (auto-create with panel password, attribute updates, password sync, block/unblock), sync_users_to_ad management command - mattermost_connector: rewrite service on mattermostautodriver/httpx, fix bare except in username generation (catch ResourceNotFound), format_html args instead of interpolation - it_manager: Equipment.brand CharField → FK to core.Directory (equipment_brand type) with data migration - add missing employees is_staff migration, add requirements.txt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
# services/ad_service.py
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ldap3 import Connection, MODIFY_REPLACE, NTLM, SIMPLE, Server
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from ldap3.utils.dn import escape_rdn, parse_dn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Биты userAccountControl
|
||||
UF_ACCOUNTDISABLE = 0x0002
|
||||
UF_NORMAL_ACCOUNT = 0x0200
|
||||
|
||||
|
||||
class ADConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ADService:
|
||||
"""LDAP-клиент для Active Directory на базе ldap3"""
|
||||
|
||||
USER_ATTRIBUTES = [
|
||||
'distinguishedName', 'cn', 'sAMAccountName', 'userPrincipalName',
|
||||
'givenName', 'sn', 'middleName', 'displayName', 'mail',
|
||||
'title', 'department', 'company', 'telephoneNumber',
|
||||
'userAccountControl',
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
from core.models import ConnectorConfig
|
||||
cfg = ConnectorConfig.objects.filter(connector_type='ad').first()
|
||||
|
||||
if not cfg:
|
||||
raise ADConfigError(
|
||||
'Настройки коннектора Active Directory не найдены. '
|
||||
'Добавьте запись в Core → Настройки коннекторов.'
|
||||
)
|
||||
if not cfg.is_enabled:
|
||||
raise ADConfigError(
|
||||
'Коннектор Active Directory отключён. '
|
||||
'Включите его в Core → Настройки коннекторов.'
|
||||
)
|
||||
|
||||
extra = cfg.extra_config or {}
|
||||
self.base_dn = extra.get('base_dn', '')
|
||||
self.users_ou = extra.get('users_ou') or self.base_dn
|
||||
self.domain = extra.get('domain', '')
|
||||
if not self.base_dn:
|
||||
raise ADConfigError(
|
||||
'В extra_config коннектора AD не задан base_dn. '
|
||||
'Пример: {"base_dn": "DC=company,DC=local", '
|
||||
'"users_ou": "OU=Users,DC=company,DC=local", "domain": "company.local"}'
|
||||
)
|
||||
|
||||
self.use_ssl = cfg.use_ssl
|
||||
port = cfg.port or (636 if cfg.use_ssl else 389)
|
||||
server = Server(cfg.host, port=port, use_ssl=cfg.use_ssl, connect_timeout=15)
|
||||
|
||||
# NTLM для DOMAIN\user, иначе simple bind (DN или UPN)
|
||||
auth = NTLM if '\\' in cfg.username else SIMPLE
|
||||
try:
|
||||
self.conn = Connection(
|
||||
server,
|
||||
user=cfg.username,
|
||||
password=cfg.password,
|
||||
authentication=auth,
|
||||
auto_bind=True,
|
||||
raise_exceptions=True,
|
||||
receive_timeout=30,
|
||||
)
|
||||
except LDAPException as e:
|
||||
raise ADConfigError(f'Не удалось подключиться к AD ({cfg.host}:{port}): {e}')
|
||||
|
||||
# --- Поиск ---
|
||||
|
||||
def _search_one(self, ldap_filter: str) -> Optional[Dict]:
|
||||
"""Поиск одного пользователя по LDAP-фильтру"""
|
||||
try:
|
||||
self.conn.search(self.base_dn, ldap_filter, attributes=self.USER_ATTRIBUTES)
|
||||
except LDAPException as e:
|
||||
logger.error(f"AD search failed: {ldap_filter} — {type(e).__name__}: {e}")
|
||||
raise
|
||||
if not self.conn.entries:
|
||||
return None
|
||||
return self._entry_to_dict(self.conn.entries[0])
|
||||
|
||||
@staticmethod
|
||||
def _entry_to_dict(entry) -> Dict:
|
||||
data = {attr: entry[attr].value for attr in entry.entry_attributes}
|
||||
data['dn'] = entry.entry_dn
|
||||
return data
|
||||
|
||||
def get_user_by_dn(self, dn: str) -> Optional[Dict]:
|
||||
"""Получение пользователя по DN"""
|
||||
try:
|
||||
self.conn.search(dn, '(objectClass=user)', search_scope='BASE',
|
||||
attributes=self.USER_ATTRIBUTES)
|
||||
except LDAPException:
|
||||
return None
|
||||
if not self.conn.entries:
|
||||
return None
|
||||
return self._entry_to_dict(self.conn.entries[0])
|
||||
|
||||
def get_user_by_sam_account_name(self, sam: str) -> Optional[Dict]:
|
||||
"""Поиск пользователя по sAMAccountName"""
|
||||
return self._search_one(
|
||||
f'(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))'
|
||||
)
|
||||
|
||||
def get_user_by_email(self, email: str) -> Optional[Dict]:
|
||||
"""Поиск пользователя по email (mail)"""
|
||||
return self._search_one(
|
||||
f'(&(objectClass=user)(mail={escape_filter_chars(email)}))'
|
||||
)
|
||||
|
||||
# --- Создание и обновление ---
|
||||
|
||||
def create_user(self, dn: str, attributes: Dict) -> None:
|
||||
"""
|
||||
Создание пользователя. Учётная запись создаётся отключённой —
|
||||
включение возможно после установки пароля (см. set_password / enable_user).
|
||||
"""
|
||||
attrs = {k: v for k, v in attributes.items() if v}
|
||||
attrs['userAccountControl'] = UF_NORMAL_ACCOUNT | UF_ACCOUNTDISABLE
|
||||
logger.info(f"AD: создание пользователя {dn}")
|
||||
try:
|
||||
self.conn.add(dn, ['top', 'person', 'organizationalPerson', 'user'], attrs)
|
||||
except LDAPException as e:
|
||||
logger.error(f"AD: ошибка создания {dn} — {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
def update_user(self, dn: str, attributes: Dict) -> None:
|
||||
"""Обновление атрибутов пользователя. Пустые значения удаляют атрибут."""
|
||||
changes = {
|
||||
attr: [(MODIFY_REPLACE, [value] if value else [])]
|
||||
for attr, value in attributes.items()
|
||||
}
|
||||
logger.info(f"AD: обновление пользователя {dn}")
|
||||
try:
|
||||
self.conn.modify(dn, changes)
|
||||
except LDAPException as e:
|
||||
logger.error(f"AD: ошибка обновления {dn} — {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
def rename_user(self, dn: str, new_cn: str) -> str:
|
||||
"""Переименование учётной записи (смена CN). Возвращает новый DN."""
|
||||
relative_dn = f'CN={escape_rdn(new_cn)}'
|
||||
parent = ','.join(
|
||||
f'{attr}={escape_rdn(value)}'
|
||||
for attr, value, _ in parse_dn(dn, escape=False)[1:]
|
||||
)
|
||||
logger.info(f"AD: переименование {dn} -> {relative_dn}")
|
||||
try:
|
||||
self.conn.modify_dn(dn, relative_dn)
|
||||
except LDAPException as e:
|
||||
logger.error(f"AD: ошибка переименования {dn} — {type(e).__name__}: {e}")
|
||||
raise
|
||||
return f'{relative_dn},{parent}'
|
||||
|
||||
def set_password(self, dn: str, new_password: str) -> None:
|
||||
"""Установка пароля. Требует подключения по LDAPS."""
|
||||
if not self.use_ssl:
|
||||
raise ADConfigError(
|
||||
'Установка пароля в AD возможна только по LDAPS (use_ssl=True).'
|
||||
)
|
||||
logger.info(f"AD: установка пароля для {dn}")
|
||||
try:
|
||||
self.conn.extend.microsoft.modify_password(dn, new_password)
|
||||
except LDAPException as e:
|
||||
logger.error(f"AD: ошибка установки пароля {dn} — {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
# --- Активация / деактивация ---
|
||||
|
||||
def _set_uac_flag(self, dn: str, disable: bool) -> None:
|
||||
user = self.get_user_by_dn(dn)
|
||||
if not user:
|
||||
raise ADConfigError(f'Пользователь {dn} не найден в AD')
|
||||
uac = int(user.get('userAccountControl') or UF_NORMAL_ACCOUNT)
|
||||
uac = (uac | UF_ACCOUNTDISABLE) if disable else (uac & ~UF_ACCOUNTDISABLE)
|
||||
self.update_user(dn, {'userAccountControl': uac})
|
||||
|
||||
def enable_user(self, dn: str) -> None:
|
||||
"""Включение учётной записи"""
|
||||
self._set_uac_flag(dn, disable=False)
|
||||
|
||||
def disable_user(self, dn: str) -> None:
|
||||
"""Отключение учётной записи"""
|
||||
self._set_uac_flag(dn, disable=True)
|
||||
|
||||
@staticmethod
|
||||
def is_disabled(user_data: Dict) -> bool:
|
||||
"""Проверка, отключена ли учётная запись"""
|
||||
return bool(int(user_data.get('userAccountControl') or 0) & UF_ACCOUNTDISABLE)
|
||||
|
||||
def unbind(self) -> None:
|
||||
"""Закрытие соединения"""
|
||||
try:
|
||||
self.conn.unbind()
|
||||
except LDAPException:
|
||||
pass
|
||||
@@ -2,5 +2,10 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class AdConnectorConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.ad_connector'
|
||||
verbose_name = 'Active Directory Connector'
|
||||
|
||||
def ready(self):
|
||||
"""Подключаем сигналы при инициализации приложения"""
|
||||
import apps.ad_connector.signals # noqa
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from apps.ad_connector.services.sync_service import sync_employee_to_ad
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Синхронизация сотрудников с Active Directory'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--user-id', type=int, help='ID конкретного сотрудника')
|
||||
parser.add_argument('--all', action='store_true',
|
||||
help='Все активные сотрудники с ad_sync_enabled=True')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options['user_id']:
|
||||
employees = Employee.objects.filter(pk=options['user_id'])
|
||||
if not employees.exists():
|
||||
raise CommandError(f"Сотрудник с ID {options['user_id']} не найден")
|
||||
elif options['all']:
|
||||
employees = Employee.objects.filter(is_active=True, ad_sync_enabled=True)
|
||||
else:
|
||||
raise CommandError('Укажите --user-id или --all')
|
||||
|
||||
total = employees.count()
|
||||
synced = 0
|
||||
self.stdout.write(f'Синхронизация {total} сотрудников с AD...')
|
||||
|
||||
for employee in employees:
|
||||
if sync_employee_to_ad(employee):
|
||||
synced += 1
|
||||
self.stdout.write(self.style.SUCCESS(f' ✓ {employee.email}'))
|
||||
else:
|
||||
employee.refresh_from_db(fields=['ad_sync_error'])
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f' ✗ {employee.email}: {employee.ad_sync_error}'
|
||||
))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Готово: {synced}/{total}'))
|
||||
@@ -0,0 +1,227 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from typing import Dict, Optional
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from ldap3.utils.dn import escape_rdn, parse_dn
|
||||
|
||||
from apps.ad_connector.ad_service import ADConfigError, ADService
|
||||
|
||||
Employee = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# sAMAccountName ограничен 20 символами
|
||||
SAM_MAX_LENGTH = 20
|
||||
|
||||
|
||||
def generate_random_password(length: int = 16) -> str:
|
||||
"""Генерация пароля, удовлетворяющего дефолтной политике сложности AD"""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
while True:
|
||||
password = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
if (any(c.islower() for c in password)
|
||||
and any(c.isupper() for c in password)
|
||||
and any(c.isdigit() for c in password)):
|
||||
return password
|
||||
|
||||
|
||||
def generate_sam_account_name(employee: Employee) -> str:
|
||||
"""Генерация sAMAccountName из username сотрудника"""
|
||||
base = (employee.username or employee.email.split('@')[0]).lower()
|
||||
base = ''.join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
||||
return base[:SAM_MAX_LENGTH]
|
||||
|
||||
|
||||
def _build_attributes(employee: Employee, service: ADService, sam: str) -> Dict:
|
||||
"""Атрибуты AD из данных сотрудника"""
|
||||
attributes = {
|
||||
'sAMAccountName': sam,
|
||||
'givenName': employee.first_name or '',
|
||||
'sn': employee.last_name or '',
|
||||
'middleName': employee.middle_name or '',
|
||||
'displayName': employee.get_full_name() or sam,
|
||||
'mail': employee.email or '',
|
||||
'title': employee.post.name if employee.post else '',
|
||||
'department': employee.department.name if employee.department else '',
|
||||
'company': employee.user_organization.name if employee.user_organization else '',
|
||||
'telephoneNumber': employee.phone or '',
|
||||
}
|
||||
if service.domain:
|
||||
attributes['userPrincipalName'] = f'{sam}@{service.domain}'
|
||||
return attributes
|
||||
|
||||
|
||||
def _mark_synced(employee: Employee, dn: str, note: str = '') -> None:
|
||||
Employee.objects.filter(pk=employee.pk).update(
|
||||
ad_user_dn=dn,
|
||||
ad_sync_status='synced',
|
||||
ad_sync_error=note,
|
||||
ad_last_sync=timezone.now(),
|
||||
)
|
||||
employee.ad_user_dn = dn
|
||||
employee.ad_sync_status = 'synced'
|
||||
|
||||
|
||||
def _mark_error(employee: Employee, error: str) -> None:
|
||||
Employee.objects.filter(pk=employee.pk).update(
|
||||
ad_sync_status='error',
|
||||
ad_sync_error=error,
|
||||
ad_last_sync=timezone.now(),
|
||||
)
|
||||
employee.ad_sync_status = 'error'
|
||||
|
||||
|
||||
def sync_employee_to_ad(employee: Employee, password: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Синхронизация сотрудника с Active Directory:
|
||||
ищет учётную запись по DN, sAMAccountName и email;
|
||||
найденную обновляет, отсутствующую создаёт.
|
||||
|
||||
Args:
|
||||
employee: Объект сотрудника
|
||||
password: Пароль (если известен, используется при создании; иначе генерируется)
|
||||
"""
|
||||
try:
|
||||
service = ADService()
|
||||
except ADConfigError as e:
|
||||
logger.error(f"AD: конфигурация недоступна — {e}")
|
||||
_mark_error(employee, str(e))
|
||||
return False
|
||||
|
||||
try:
|
||||
sam = generate_sam_account_name(employee)
|
||||
ad_user = None
|
||||
|
||||
# 1. Ищем по сохранённому DN
|
||||
if employee.ad_user_dn:
|
||||
ad_user = service.get_user_by_dn(employee.ad_user_dn)
|
||||
if not ad_user:
|
||||
logger.warning(f"AD: пользователь {employee.ad_user_dn} не найден, DN сброшен")
|
||||
Employee.objects.filter(pk=employee.pk).update(ad_user_dn='')
|
||||
employee.ad_user_dn = ''
|
||||
|
||||
# 2. Ищем по sAMAccountName, затем по email
|
||||
if not ad_user:
|
||||
ad_user = service.get_user_by_sam_account_name(sam)
|
||||
if not ad_user and employee.email:
|
||||
ad_user = service.get_user_by_email(employee.email)
|
||||
|
||||
if ad_user:
|
||||
# Обновляем атрибуты существующей учётной записи
|
||||
dn = ad_user['dn']
|
||||
full_name = employee.get_full_name() or sam
|
||||
|
||||
# Смена ФИО — переименовываем учётную запись (CN)
|
||||
current_cn = ad_user.get('cn') or parse_dn(dn, escape=False)[0][1]
|
||||
if current_cn != full_name:
|
||||
dn = service.rename_user(dn, full_name)
|
||||
logger.info(f"AD: учётная запись переименована в {dn}")
|
||||
|
||||
attributes = _build_attributes(employee, service, ad_user.get('sAMAccountName') or sam)
|
||||
attributes.pop('sAMAccountName', None) # логин не переименовываем
|
||||
service.update_user(dn, attributes)
|
||||
if password:
|
||||
service.set_password(dn, password)
|
||||
_mark_synced(employee, dn)
|
||||
logger.info(f"AD: обновлён пользователь {dn} для {employee.email}")
|
||||
return True
|
||||
|
||||
# 3. Создаём нового
|
||||
cn = escape_rdn(employee.get_full_name() or sam)
|
||||
dn = f'CN={cn},{service.users_ou}'
|
||||
attributes = _build_attributes(employee, service, sam)
|
||||
service.create_user(dn, attributes)
|
||||
|
||||
note = ''
|
||||
if service.use_ssl:
|
||||
user_password = password or generate_random_password()
|
||||
service.set_password(dn, user_password)
|
||||
service.enable_user(dn)
|
||||
logger.info(f"AD: создан и включён пользователь {dn} для {employee.email}")
|
||||
else:
|
||||
note = ('Учётная запись создана отключённой: установка пароля '
|
||||
'и включение требуют подключения по LDAPS (use_ssl=True).')
|
||||
logger.warning(f"AD: {dn} создан отключённым — нет LDAPS")
|
||||
|
||||
_mark_synced(employee, dn, note)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AD: ошибка синхронизации {employee.email} — {type(e).__name__}: {e}")
|
||||
_mark_error(employee, f'{type(e).__name__}: {e}')
|
||||
return False
|
||||
finally:
|
||||
service.unbind()
|
||||
|
||||
|
||||
def deactivate_ad_user(employee: Employee) -> bool:
|
||||
"""Отключение учётной записи сотрудника в AD"""
|
||||
if not employee.ad_user_dn:
|
||||
return False
|
||||
try:
|
||||
service = ADService()
|
||||
except ADConfigError as e:
|
||||
logger.error(f"AD: конфигурация недоступна — {e}")
|
||||
return False
|
||||
try:
|
||||
service.disable_user(employee.ad_user_dn)
|
||||
Employee.objects.filter(pk=employee.pk).update(
|
||||
ad_sync_status='disabled',
|
||||
ad_last_sync=timezone.now(),
|
||||
)
|
||||
logger.info(f"AD: отключена учётная запись {employee.ad_user_dn}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"AD: ошибка отключения {employee.ad_user_dn} — {e}")
|
||||
_mark_error(employee, f'{type(e).__name__}: {e}')
|
||||
return False
|
||||
finally:
|
||||
service.unbind()
|
||||
|
||||
|
||||
def activate_ad_user(employee: Employee) -> bool:
|
||||
"""Включение учётной записи сотрудника в AD"""
|
||||
if not employee.ad_user_dn:
|
||||
return False
|
||||
try:
|
||||
service = ADService()
|
||||
except ADConfigError as e:
|
||||
logger.error(f"AD: конфигурация недоступна — {e}")
|
||||
return False
|
||||
try:
|
||||
service.enable_user(employee.ad_user_dn)
|
||||
Employee.objects.filter(pk=employee.pk).update(
|
||||
ad_sync_status='synced',
|
||||
ad_sync_error='',
|
||||
ad_last_sync=timezone.now(),
|
||||
)
|
||||
logger.info(f"AD: включена учётная запись {employee.ad_user_dn}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"AD: ошибка включения {employee.ad_user_dn} — {e}")
|
||||
_mark_error(employee, f'{type(e).__name__}: {e}')
|
||||
return False
|
||||
finally:
|
||||
service.unbind()
|
||||
|
||||
|
||||
def sync_ad_password(employee: Employee, new_password: str) -> bool:
|
||||
"""Синхронизация пароля сотрудника в AD (требует LDAPS)"""
|
||||
if not employee.ad_user_dn:
|
||||
return False
|
||||
try:
|
||||
service = ADService()
|
||||
except ADConfigError as e:
|
||||
logger.error(f"AD: конфигурация недоступна — {e}")
|
||||
return False
|
||||
try:
|
||||
service.set_password(employee.ad_user_dn, new_password)
|
||||
logger.info(f"AD: пароль обновлён для {employee.ad_user_dn}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"AD: ошибка смены пароля {employee.ad_user_dn} — {type(e).__name__}")
|
||||
return False
|
||||
finally:
|
||||
service.unbind()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Сигналы для синхронизации с Active Directory
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
Employee = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Поля, изменение которых требует обновления учётной записи в AD
|
||||
AD_SYNCED_FIELDS = (
|
||||
'first_name', 'last_name', 'middle_name', 'email', 'phone',
|
||||
'username', 'post_id', 'department_id', 'user_organization_id',
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Employee)
|
||||
def cache_employee_ad_state(sender, instance, **kwargs):
|
||||
"""Кешируем состояние до сохранения чтобы в post_save можно было сравнить."""
|
||||
if not instance.pk:
|
||||
instance._ad_pre_save_is_active = None
|
||||
instance._ad_pre_save_fields = None
|
||||
return
|
||||
try:
|
||||
old = Employee.objects.values('is_active', *AD_SYNCED_FIELDS).get(pk=instance.pk)
|
||||
instance._ad_pre_save_is_active = old['is_active']
|
||||
instance._ad_pre_save_fields = {f: old[f] for f in AD_SYNCED_FIELDS}
|
||||
except Employee.DoesNotExist:
|
||||
instance._ad_pre_save_is_active = None
|
||||
instance._ad_pre_save_fields = None
|
||||
|
||||
|
||||
@receiver(post_save, sender=Employee)
|
||||
def sync_new_employee_to_ad(sender, instance, created, **kwargs):
|
||||
"""Создаём учётную запись AD при создании нового сотрудника."""
|
||||
if not created or not instance.ad_sync_enabled:
|
||||
return
|
||||
|
||||
# Если пароль задавался в этой же транзакции (set_password), используем его,
|
||||
# чтобы пароль в AD совпадал с паролем в панели. Синхронно — чтобы
|
||||
# не передавать пароль открытым текстом через брокер Celery.
|
||||
raw_password = getattr(instance, '_password', None)
|
||||
if raw_password:
|
||||
from apps.ad_connector.services.sync_service import sync_employee_to_ad
|
||||
try:
|
||||
sync_employee_to_ad(instance, password=raw_password)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания AD-аккаунта для {instance.email}: {e}")
|
||||
return
|
||||
|
||||
from apps.ad_connector.tasks import sync_employee_to_ad_task
|
||||
sync_employee_to_ad_task.delay(instance.pk)
|
||||
logger.info(f"Поставлена задача создания AD-аккаунта для {instance.email}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=Employee)
|
||||
def update_employee_in_ad(sender, instance, created, **kwargs):
|
||||
"""Обновляем атрибуты в AD при изменении данных сотрудника."""
|
||||
if created or not instance.ad_sync_enabled or not instance.ad_user_dn:
|
||||
return
|
||||
|
||||
old_fields = getattr(instance, '_ad_pre_save_fields', None)
|
||||
if old_fields is None:
|
||||
return
|
||||
|
||||
changed = [f for f in AD_SYNCED_FIELDS if old_fields[f] != getattr(instance, f)]
|
||||
if not changed:
|
||||
return
|
||||
|
||||
from apps.ad_connector.tasks import sync_employee_to_ad_task
|
||||
sync_employee_to_ad_task.delay(instance.pk)
|
||||
logger.info(f"Поставлена задача обновления AD-аккаунта для {instance.email} (изменены: {', '.join(changed)})")
|
||||
|
||||
|
||||
@receiver(post_save, sender=Employee)
|
||||
def sync_employee_password_to_ad(sender, instance, created, **kwargs):
|
||||
"""Синхронизируем пароль в AD при его смене в панели."""
|
||||
if created or not instance.ad_sync_enabled or not instance.ad_user_dn:
|
||||
return
|
||||
|
||||
raw_password = getattr(instance, '_password', None)
|
||||
if not raw_password:
|
||||
return
|
||||
|
||||
# Синхронно — пароль не должен попадать в брокер Celery открытым текстом
|
||||
from apps.ad_connector.services.sync_service import sync_ad_password
|
||||
if sync_ad_password(instance, raw_password):
|
||||
logger.info(f"Пароль синхронизирован в AD для {instance.email}")
|
||||
else:
|
||||
logger.error(f"Не удалось синхронизировать пароль в AD для {instance.email}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=Employee)
|
||||
def toggle_employee_in_ad(sender, instance, created, **kwargs):
|
||||
"""Отключаем/включаем учётную запись AD при блокировке/разблокировке сотрудника."""
|
||||
if created or not instance.ad_user_dn:
|
||||
return
|
||||
|
||||
old_active = getattr(instance, '_ad_pre_save_is_active', None)
|
||||
if old_active is True and not instance.is_active:
|
||||
from apps.ad_connector.tasks import deactivate_employee_in_ad_task
|
||||
deactivate_employee_in_ad_task.delay(instance.pk)
|
||||
logger.info(f"Поставлена задача отключения AD-аккаунта для {instance.email}")
|
||||
elif old_active is False and instance.is_active and instance.ad_sync_enabled:
|
||||
from apps.ad_connector.tasks import activate_employee_in_ad_task
|
||||
activate_employee_in_ad_task.delay(instance.pk)
|
||||
logger.info(f"Поставлена задача включения AD-аккаунта для {instance.email}")
|
||||
@@ -0,0 +1,47 @@
|
||||
from celery import shared_task
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
Employee = get_user_model()
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def sync_employee_to_ad_task(employee_id, password=None):
|
||||
"""Асинхронная задача синхронизации сотрудника с Active Directory"""
|
||||
from apps.ad_connector.services.sync_service import sync_employee_to_ad
|
||||
try:
|
||||
employee = Employee.objects.get(id=employee_id)
|
||||
sync_employee_to_ad(employee, password=password)
|
||||
logger.info(f"Задача синхронизации AD для {employee.email} выполнена")
|
||||
except Employee.DoesNotExist:
|
||||
logger.error(f"Сотрудник с ID {employee_id} не найден")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в задаче синхронизации AD: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def deactivate_employee_in_ad_task(employee_id):
|
||||
"""Асинхронная задача отключения учётной записи в AD"""
|
||||
from apps.ad_connector.services.sync_service import deactivate_ad_user
|
||||
try:
|
||||
employee = Employee.objects.get(id=employee_id)
|
||||
deactivate_ad_user(employee)
|
||||
except Employee.DoesNotExist:
|
||||
logger.error(f"Сотрудник с ID {employee_id} не найден")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в задаче отключения AD: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def activate_employee_in_ad_task(employee_id):
|
||||
"""Асинхронная задача включения учётной записи в AD"""
|
||||
from apps.ad_connector.services.sync_service import activate_ad_user
|
||||
try:
|
||||
employee = Employee.objects.get(id=employee_id)
|
||||
activate_ad_user(employee)
|
||||
except Employee.DoesNotExist:
|
||||
logger.error(f"Сотрудник с ID {employee_id} не найден")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в задаче включения AD: {e}")
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-07-02 12:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0006_remove_employee_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='employee',
|
||||
name='is_staff',
|
||||
field=models.BooleanField(default=False, help_text='Разрешает вход в Django admin. Только для IT-администраторов системы.', verbose_name='Доступ к панели администратора'),
|
||||
),
|
||||
]
|
||||
@@ -85,10 +85,10 @@ class EquipmentAdmin(ModelAdmin):
|
||||
'status_display', 'assigned_to', 'location',
|
||||
'warranty_display', 'updated_at',
|
||||
]
|
||||
list_filter = ['equipment_type', 'status', 'location__location_type']
|
||||
search_fields = ['brand', 'model', 'serial_number', 'inventory_number', 'notes']
|
||||
list_select_related = ['assigned_to', 'location']
|
||||
ordering = ['equipment_type', 'brand', 'model']
|
||||
list_filter = ['equipment_type', 'status', 'location__location_type', 'brand']
|
||||
search_fields = ['brand__name', 'model', 'serial_number', 'inventory_number', 'notes']
|
||||
list_select_related = ['assigned_to', 'location', 'brand']
|
||||
ordering = ['equipment_type', 'brand__name', 'model']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'warranty_display']
|
||||
|
||||
@@ -174,7 +174,7 @@ class ConsumableAdmin(ModelAdmin):
|
||||
]
|
||||
list_filter = ['consumable_type', 'status', 'color']
|
||||
search_fields = ['brand', 'model_number', 'notes',
|
||||
'equipment__brand', 'equipment__model']
|
||||
'equipment__brand__name', 'equipment__model']
|
||||
list_select_related = ['equipment', 'location']
|
||||
ordering = ['consumable_type', 'color', 'brand']
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
DirectoryType = apps.get_model('core', 'DirectoryType')
|
||||
Directory = apps.get_model('core', 'Directory')
|
||||
Equipment = apps.get_model('it_manager', 'Equipment')
|
||||
|
||||
directory_type, _ = DirectoryType.objects.get_or_create(
|
||||
code='equipment_brand',
|
||||
defaults={'name': 'Бренды оборудования', 'hierarchy_type': 'strict'},
|
||||
)
|
||||
|
||||
brands = {}
|
||||
for equipment in Equipment.objects.exclude(brand='').exclude(brand__isnull=True):
|
||||
name = equipment.brand.strip()
|
||||
if not name:
|
||||
continue
|
||||
directory = brands.get(name)
|
||||
if directory is None:
|
||||
directory, _ = Directory.objects.get_or_create(
|
||||
directory_type=directory_type,
|
||||
name=name,
|
||||
defaults={'is_active': True},
|
||||
)
|
||||
brands[name] = directory
|
||||
equipment.brand_directory = directory
|
||||
equipment.save(update_fields=['brand_directory'])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
Equipment = apps.get_model('it_manager', 'Equipment')
|
||||
for equipment in Equipment.objects.select_related('brand_directory').exclude(brand_directory__isnull=True):
|
||||
equipment.brand = equipment.brand_directory.name
|
||||
equipment.save(update_fields=['brand'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0005_consumable'),
|
||||
('core', '0002_connector_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='equipment',
|
||||
name='brand_directory',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
limit_choices_to={'directory_type__code': 'equipment_brand', 'is_active': True},
|
||||
related_name='equipment_brands',
|
||||
to='core.directory',
|
||||
verbose_name='Бренд',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
migrations.RemoveField(
|
||||
model_name='equipment',
|
||||
name='brand',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='equipment',
|
||||
old_name='brand_directory',
|
||||
new_name='brand',
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='equipment',
|
||||
options={
|
||||
'ordering': ['equipment_type', 'brand__name', 'model'],
|
||||
'verbose_name': 'Оборудование',
|
||||
'verbose_name_plural': 'Оборудование',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -25,7 +25,12 @@ class Equipment(models.Model):
|
||||
]
|
||||
|
||||
equipment_type = models.CharField('Тип', max_length=20, choices=EQUIPMENT_TYPES)
|
||||
brand = models.CharField('Бренд', max_length=100)
|
||||
brand = models.ForeignKey(
|
||||
'core.Directory', on_delete=models.SET_NULL,
|
||||
limit_choices_to={'directory_type__code': 'equipment_brand', 'is_active': True},
|
||||
related_name='equipment_brands', verbose_name='Бренд',
|
||||
null=True, blank=True,
|
||||
)
|
||||
model = models.CharField('Модель', max_length=100)
|
||||
serial_number = models.CharField('Серийный номер', max_length=100, unique=True)
|
||||
inventory_number = models.CharField('Инвентарный номер', max_length=50,
|
||||
@@ -54,7 +59,7 @@ class Equipment(models.Model):
|
||||
class Meta:
|
||||
verbose_name = 'Оборудование'
|
||||
verbose_name_plural = 'Оборудование'
|
||||
ordering = ['equipment_type', 'brand', 'model']
|
||||
ordering = ['equipment_type', 'brand__name', 'model']
|
||||
indexes = [
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['equipment_type']),
|
||||
|
||||
@@ -76,7 +76,7 @@ class TeamSyncSettingsAdmin(ModelAdmin):
|
||||
@display(description='Отделов')
|
||||
def departments_count(self, obj):
|
||||
if not obj.filter_by_department:
|
||||
return format_html('<span style="color: gray;">Все</span>')
|
||||
return format_html('<span style="color: gray;">{}</span>', 'Все')
|
||||
count = obj.departments.count()
|
||||
return format_html(
|
||||
'<span style="color: {};">{}</span>',
|
||||
@@ -144,7 +144,7 @@ class MattermostUserAdmin(ModelAdmin):
|
||||
'<span style="color: green; font-weight: bold;">{}</span>',
|
||||
count
|
||||
)
|
||||
return format_html('<span style="color: gray;">0</span>')
|
||||
return format_html('<span style="color: gray;">{}</span>', 0)
|
||||
|
||||
|
||||
@admin.register(MattermostChannel)
|
||||
@@ -210,7 +210,7 @@ class ChannelSyncSettingsAdmin(ModelAdmin):
|
||||
@display(description='Отделов')
|
||||
def departments_count(self, obj):
|
||||
if not obj.filter_by_department:
|
||||
return format_html('<span style="color: gray;">Все</span>')
|
||||
return format_html('<span style="color: gray;">{}</span>', 'Все')
|
||||
count = obj.departments.count()
|
||||
return format_html(
|
||||
'<span style="color: {};">{}</span>',
|
||||
|
||||
@@ -1,256 +1,212 @@
|
||||
# services/mattermost_service.py
|
||||
import requests
|
||||
from typing import Dict, Optional, List
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MattermostConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MattermostService:
|
||||
def __init__(self):
|
||||
from core.models import ConnectorConfig
|
||||
cfg = ConnectorConfig.objects.filter(connector_type='mattermost').first()
|
||||
|
||||
if not cfg:
|
||||
raise MattermostConfigError(
|
||||
'Настройки коннектора Mattermost не найдены. '
|
||||
'Добавьте запись в Core → Настройки коннекторов.'
|
||||
)
|
||||
if not cfg.is_enabled:
|
||||
raise MattermostConfigError(
|
||||
'Коннектор Mattermost отключён. '
|
||||
'Включите его в Core → Настройки коннекторов.'
|
||||
)
|
||||
|
||||
scheme = 'https' if cfg.use_ssl else 'http'
|
||||
port = f':{cfg.port}' if cfg.port else ''
|
||||
self.base_url = f'{scheme}://{cfg.host}{port}'
|
||||
self.token = cfg.token
|
||||
self.headers = {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
_SENSITIVE_FIELDS = {'password', 'new_password', 'token', 'auth_data'}
|
||||
|
||||
def _sanitize_log_data(self, data: Dict) -> Dict:
|
||||
return {k: '***' if k in self._SENSITIVE_FIELDS else v for k, v in data.items()}
|
||||
|
||||
def _make_request(self, method: str, endpoint: str, data: Dict = None) -> Dict:
|
||||
"""Базовый метод для выполнения HTTP запросов"""
|
||||
url = f"{self.base_url}/api/v4/{endpoint}"
|
||||
|
||||
try:
|
||||
logger.info(f"Mattermost API: {method} {endpoint}")
|
||||
if data:
|
||||
logger.debug(f"Request data: {self._sanitize_log_data(data)}")
|
||||
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=self.headers,
|
||||
json=data,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.error(f"Mattermost API error: {response.status_code} {endpoint}")
|
||||
try:
|
||||
logger.error(f"Error: {response.json().get('message', 'unknown')}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {}
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Mattermost API request failed: {endpoint} — {type(e).__name__}")
|
||||
raise
|
||||
|
||||
def create_user(self, user_data: Dict) -> Dict:
|
||||
"""Создание нового пользователя"""
|
||||
endpoint = "users"
|
||||
return self._make_request('POST', endpoint, user_data)
|
||||
|
||||
def get_user(self, user_id: str) -> Dict:
|
||||
"""Получение информации о пользователе"""
|
||||
endpoint = f"users/{user_id}"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def get_user_by_email(self, email: str) -> Dict:
|
||||
"""Получение пользователя по email"""
|
||||
endpoint = f"users/email/{email}"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def get_user_by_username(self, username: str) -> Dict:
|
||||
"""Получение пользователя по username"""
|
||||
endpoint = f"users/username/{username}"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def update_user(self, user_id: str, user_data: Dict) -> Dict:
|
||||
"""Обновление данных пользователя через PATCH"""
|
||||
endpoint = f"users/{user_id}/patch"
|
||||
return self._make_request('PATCH', endpoint, user_data)
|
||||
|
||||
def deactivate_user(self, user_id: str) -> Dict:
|
||||
"""Деактивация пользователя"""
|
||||
endpoint = f"users/{user_id}"
|
||||
return self._make_request('DELETE', endpoint)
|
||||
|
||||
def activate_user(self, user_id: str) -> Dict:
|
||||
"""Активация пользователя"""
|
||||
endpoint = f"users/{user_id}/active"
|
||||
return self._make_request('PUT', endpoint, {"active": True})
|
||||
|
||||
def add_user_to_team(self, team_id: str, user_id: str) -> Dict:
|
||||
"""Добавление пользователя в команду"""
|
||||
endpoint = f"teams/{team_id}/members"
|
||||
data = {"team_id": team_id, "user_id": user_id}
|
||||
return self._make_request('POST', endpoint, data)
|
||||
|
||||
def remove_user_from_team(self, team_id: str, user_id: str) -> Dict:
|
||||
"""Удаление пользователя из команды"""
|
||||
endpoint = f"teams/{team_id}/members/{user_id}"
|
||||
return self._make_request('DELETE', endpoint)
|
||||
|
||||
def add_user_to_channel(self, channel_id: str, user_id: str) -> Dict:
|
||||
"""Добавление пользователя в канал"""
|
||||
endpoint = f"channels/{channel_id}/members"
|
||||
data = {"user_id": user_id}
|
||||
return self._make_request('POST', endpoint, data)
|
||||
|
||||
def get_teams(self) -> List[Dict]:
|
||||
"""Получение списка команд"""
|
||||
endpoint = "teams"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def get_channels_for_team(self, team_id: str) -> List[Dict]:
|
||||
"""Получение каналов команды"""
|
||||
endpoint = f"teams/{team_id}/channels"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def reset_password(self, user_id: str, new_password: str) -> Dict:
|
||||
"""Сброс пароля пользователя (от имени администратора)"""
|
||||
endpoint = f"users/{user_id}/password"
|
||||
data = {"new_password": new_password}
|
||||
return self._make_request('PUT', endpoint, data)
|
||||
|
||||
def update_user_preferences(self, user_id: str, timezone_data: Dict) -> Dict:
|
||||
"""Обновление настроек пользователя (например, часовой пояс)"""
|
||||
endpoint = f"users/{user_id}/preferences"
|
||||
preferences = [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"category": "timezone",
|
||||
"name": "useAutomaticTimezone",
|
||||
"value": timezone_data.get('useAutomaticTimezone', 'false')
|
||||
},
|
||||
{
|
||||
"user_id": user_id,
|
||||
"category": "timezone",
|
||||
"name": "manualTimezone",
|
||||
"value": timezone_data.get('manualTimezone', 'Europe/Kiev')
|
||||
},
|
||||
{
|
||||
"user_id": user_id,
|
||||
"category": "timezone",
|
||||
"name": "automaticTimezone",
|
||||
"value": timezone_data.get('automaticTimezone', '')
|
||||
}
|
||||
]
|
||||
return self._make_request('PUT', endpoint, preferences)
|
||||
|
||||
def get_user_teams(self, user_id: str) -> List[Dict]:
|
||||
"""Получение списка команд пользователя"""
|
||||
endpoint = f"users/{user_id}/teams"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def get_team_member(self, team_id: str, user_id: str) -> Dict:
|
||||
"""Получение информации о членстве пользователя в команде"""
|
||||
endpoint = f"teams/{team_id}/members/{user_id}"
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def create_channel(self, channel_data: Dict) -> Dict:
|
||||
"""Создание нового канала"""
|
||||
endpoint = "channels"
|
||||
return self._make_request('POST', endpoint, channel_data)
|
||||
|
||||
def set_profile_image(self, user_id: str, image_file) -> Dict:
|
||||
"""
|
||||
Установка фото профиля пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Mattermost
|
||||
image_file: Файл изображения (file object или path)
|
||||
"""
|
||||
url = f"{self.base_url}/api/v4/users/{user_id}/image"
|
||||
|
||||
try:
|
||||
logger.info(f"Установка фото профиля для пользователя {user_id}")
|
||||
|
||||
# Если передан путь к файлу, открываем его
|
||||
if isinstance(image_file, str):
|
||||
with open(image_file, 'rb') as f:
|
||||
files = {'image': f}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={'Authorization': f'Bearer {self.token}'},
|
||||
files=files,
|
||||
timeout=30
|
||||
)
|
||||
else:
|
||||
# Если передан file object
|
||||
files = {'image': image_file}
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={'Authorization': f'Bearer {self.token}'},
|
||||
files=files,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.error(f"Mattermost API error response: {response.status_code}")
|
||||
logger.error(f"Response body: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
logger.info(f"Фото профиля успешно установлено для {user_id}")
|
||||
return response.json() if response.content else {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка установки фото профиля: {e}")
|
||||
raise
|
||||
|
||||
def get_profile_image(self, user_id: str) -> bytes:
|
||||
"""
|
||||
Получение фото профиля пользователя из Mattermost
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Mattermost
|
||||
|
||||
Returns:
|
||||
bytes: Содержимое изображения
|
||||
"""
|
||||
url = f"{self.base_url}/api/v4/users/{user_id}/image"
|
||||
|
||||
try:
|
||||
logger.info(f"Получение фото профиля для пользователя {user_id}")
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={'Authorization': f'Bearer {self.token}'},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.error(f"Mattermost API error response: {response.status_code}")
|
||||
logger.error(f"Response body: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
logger.info(f"Фото профиля успешно получено для {user_id}")
|
||||
return response.content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения фото профиля: {e}")
|
||||
raise
|
||||
# services/mattermost_service.py
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
|
||||
import httpx
|
||||
from mattermostautodriver import TypedDriver
|
||||
from mattermostautodriver.exceptions import InvalidMattermostError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MattermostConfigError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MattermostService:
|
||||
_SENSITIVE_FIELDS = {'password', 'new_password', 'token', 'auth_data'}
|
||||
|
||||
def __init__(self):
|
||||
from core.models import ConnectorConfig
|
||||
cfg = ConnectorConfig.objects.filter(connector_type='mattermost').first()
|
||||
|
||||
if not cfg:
|
||||
raise MattermostConfigError(
|
||||
'Настройки коннектора Mattermost не найдены. '
|
||||
'Добавьте запись в Core → Настройки коннекторов.'
|
||||
)
|
||||
if not cfg.is_enabled:
|
||||
raise MattermostConfigError(
|
||||
'Коннектор Mattermost отключён. '
|
||||
'Включите его в Core → Настройки коннекторов.'
|
||||
)
|
||||
|
||||
scheme = 'https' if cfg.use_ssl else 'http'
|
||||
port = cfg.port or (443 if cfg.use_ssl else 80)
|
||||
|
||||
self.driver = TypedDriver({
|
||||
'url': cfg.host,
|
||||
'port': port,
|
||||
'scheme': scheme,
|
||||
'timeout': 30,
|
||||
})
|
||||
# Токен передаём напрямую — пользователь/пароль не используются
|
||||
self.driver.client.token = cfg.token
|
||||
|
||||
def _sanitize_log_data(self, data: Dict) -> Dict:
|
||||
return {k: '***' if k in self._SENSITIVE_FIELDS else v for k, v in data.items()}
|
||||
|
||||
def _call(self, action: str, func, *args, log_data: Dict = None, **kwargs):
|
||||
"""Выполняет вызов методов driver с единым логированием и обработкой ошибок"""
|
||||
try:
|
||||
logger.info(f"Mattermost API: {action}")
|
||||
if log_data:
|
||||
logger.debug(f"Request data: {self._sanitize_log_data(log_data)}")
|
||||
return func(*args, **kwargs)
|
||||
except (InvalidMattermostError, httpx.HTTPError) as e:
|
||||
logger.error(f"Mattermost API request failed: {action} — {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
def create_user(self, user_data: Dict) -> Dict:
|
||||
"""Создание нового пользователя"""
|
||||
return self._call('POST users', self.driver.users.create_user, log_data=user_data, **user_data)
|
||||
|
||||
def get_user(self, user_id: str) -> Dict:
|
||||
"""Получение информации о пользователе"""
|
||||
return self._call(f'GET users/{user_id}', self.driver.users.get_user, user_id)
|
||||
|
||||
def get_user_by_email(self, email: str) -> Dict:
|
||||
"""Получение пользователя по email"""
|
||||
return self._call(f'GET users/email/{email}', self.driver.users.get_user_by_email, email)
|
||||
|
||||
def get_user_by_username(self, username: str) -> Dict:
|
||||
"""Получение пользователя по username"""
|
||||
return self._call(f'GET users/username/{username}', self.driver.users.get_user_by_username, username)
|
||||
|
||||
def update_user(self, user_id: str, user_data: Dict) -> Dict:
|
||||
"""Обновление данных пользователя через PATCH"""
|
||||
return self._call(f'PATCH users/{user_id}', self.driver.users.patch_user, user_id, log_data=user_data, **user_data)
|
||||
|
||||
def deactivate_user(self, user_id: str) -> Dict:
|
||||
"""Деактивация пользователя"""
|
||||
return self._call(f'DELETE users/{user_id}', self.driver.users.delete_user, user_id)
|
||||
|
||||
def activate_user(self, user_id: str) -> Dict:
|
||||
"""Активация пользователя"""
|
||||
return self._call(f'PUT users/{user_id}/active', self.driver.users.update_user_active, user_id, True)
|
||||
|
||||
def add_user_to_team(self, team_id: str, user_id: str) -> Dict:
|
||||
"""Добавление пользователя в команду"""
|
||||
return self._call(
|
||||
f'POST teams/{team_id}/members',
|
||||
self.driver.teams.add_team_member, team_id, user_id=user_id,
|
||||
)
|
||||
|
||||
def remove_user_from_team(self, team_id: str, user_id: str) -> Dict:
|
||||
"""Удаление пользователя из команды"""
|
||||
return self._call(
|
||||
f'DELETE teams/{team_id}/members/{user_id}',
|
||||
self.driver.teams.remove_team_member, team_id, user_id,
|
||||
)
|
||||
|
||||
def add_user_to_channel(self, channel_id: str, user_id: str) -> Dict:
|
||||
"""Добавление пользователя в канал"""
|
||||
return self._call(
|
||||
f'POST channels/{channel_id}/members',
|
||||
self.driver.channels.add_channel_member, channel_id, user_id=user_id,
|
||||
)
|
||||
|
||||
def get_teams(self) -> List[Dict]:
|
||||
"""Получение списка команд"""
|
||||
return self._call('GET teams', self.driver.teams.get_all_teams)
|
||||
|
||||
def get_channels_for_team(self, team_id: str) -> List[Dict]:
|
||||
"""Получение каналов команды"""
|
||||
return self._call(
|
||||
f'GET teams/{team_id}/channels',
|
||||
self.driver.channels.get_public_channels_for_team, team_id,
|
||||
)
|
||||
|
||||
def reset_password(self, user_id: str, new_password: str) -> Dict:
|
||||
"""Сброс пароля пользователя (от имени администратора)"""
|
||||
return self._call(
|
||||
f'PUT users/{user_id}/password',
|
||||
self.driver.users.update_user_password, user_id,
|
||||
new_password=new_password,
|
||||
log_data={'new_password': new_password},
|
||||
)
|
||||
|
||||
def update_user_preferences(self, user_id: str, timezone_data: Dict) -> Dict:
|
||||
"""Обновление настроек пользователя (например, часовой пояс)"""
|
||||
preferences = [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"category": "timezone",
|
||||
"name": "useAutomaticTimezone",
|
||||
"value": timezone_data.get('useAutomaticTimezone', 'false')
|
||||
},
|
||||
{
|
||||
"user_id": user_id,
|
||||
"category": "timezone",
|
||||
"name": "manualTimezone",
|
||||
"value": timezone_data.get('manualTimezone', 'Europe/Kiev')
|
||||
},
|
||||
{
|
||||
"user_id": user_id,
|
||||
"category": "timezone",
|
||||
"name": "automaticTimezone",
|
||||
"value": timezone_data.get('automaticTimezone', '')
|
||||
}
|
||||
]
|
||||
return self._call(
|
||||
f'PUT users/{user_id}/preferences',
|
||||
self.driver.preferences.update_preferences, user_id, preferences,
|
||||
)
|
||||
|
||||
def get_user_teams(self, user_id: str) -> List[Dict]:
|
||||
"""Получение списка команд пользователя"""
|
||||
return self._call(f'GET users/{user_id}/teams', self.driver.teams.get_teams_for_user, user_id)
|
||||
|
||||
def get_team_member(self, team_id: str, user_id: str) -> Dict:
|
||||
"""Получение информации о членстве пользователя в команде"""
|
||||
return self._call(
|
||||
f'GET teams/{team_id}/members/{user_id}',
|
||||
self.driver.teams.get_team_member, team_id, user_id,
|
||||
)
|
||||
|
||||
def create_channel(self, channel_data: Dict) -> Dict:
|
||||
"""Создание нового канала"""
|
||||
return self._call('POST channels', self.driver.channels.create_channel, log_data=channel_data, **channel_data)
|
||||
|
||||
def set_profile_image(self, user_id: str, image_file) -> Dict:
|
||||
"""
|
||||
Установка фото профиля пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Mattermost
|
||||
image_file: Файл изображения (file object или path)
|
||||
"""
|
||||
logger.info(f"Установка фото профиля для пользователя {user_id}")
|
||||
try:
|
||||
if isinstance(image_file, str):
|
||||
with open(image_file, 'rb') as f:
|
||||
result = self.driver.users.set_profile_image(user_id, image=f)
|
||||
else:
|
||||
result = self.driver.users.set_profile_image(user_id, image=image_file)
|
||||
|
||||
logger.info(f"Фото профиля успешно установлено для {user_id}")
|
||||
return result
|
||||
except (InvalidMattermostError, httpx.HTTPError) as e:
|
||||
logger.error(f"Ошибка установки фото профиля: {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
def get_profile_image(self, user_id: str) -> bytes:
|
||||
"""
|
||||
Получение фото профиля пользователя из Mattermost
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Mattermost
|
||||
|
||||
Returns:
|
||||
bytes: Содержимое изображения
|
||||
"""
|
||||
logger.info(f"Получение фото профиля для пользователя {user_id}")
|
||||
try:
|
||||
response = self.driver.users.get_profile_image(user_id)
|
||||
content = response.content if hasattr(response, 'content') else response
|
||||
|
||||
logger.info(f"Фото профиля успешно получено для {user_id}")
|
||||
return content
|
||||
except (InvalidMattermostError, httpx.HTTPError) as e:
|
||||
logger.error(f"Ошибка получения фото профиля: {type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
@@ -5,6 +5,7 @@ from apps.mattermost_connector.models import MattermostUser
|
||||
|
||||
Employee = get_user_model()
|
||||
from apps.mattermost_connector.mattermost_service import MattermostService
|
||||
from mattermostautodriver.exceptions import ResourceNotFound
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -93,7 +94,7 @@ def sync_employee(employee: Employee, password: Optional[str] = None) -> bool:
|
||||
service.get_user_by_username(username)
|
||||
username = f"{original_username}{counter}"
|
||||
counter += 1
|
||||
except:
|
||||
except ResourceNotFound:
|
||||
break
|
||||
|
||||
# Используем переданный пароль или генерируем новый
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
aiohappyeyeballs==2.6.2
|
||||
aiohttp==3.14.0
|
||||
aiosignal==1.4.0
|
||||
amqp==5.3.1
|
||||
anyio==4.13.0
|
||||
asgiref==3.11.1
|
||||
attrs==26.1.0
|
||||
billiard==4.2.4
|
||||
celery==5.5.1
|
||||
certifi==2026.2.25
|
||||
charset-normalizer==3.4.7
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1.2
|
||||
click-repl==0.3.0
|
||||
click==8.3.2
|
||||
cron-descriptor==1.4.5
|
||||
diff-match-patch==20241021
|
||||
django-celery-beat==2.9.0
|
||||
django-celery-results==2.5.1
|
||||
django-environ==0.13.0
|
||||
django-fast-treenode==3.2.10
|
||||
django-import-export==4.4.0
|
||||
django-location-field==2.7.3
|
||||
django-timezone-field==7.2.1
|
||||
django-unfold==0.87.0
|
||||
Django==6.0.3
|
||||
djangorestframework==3.15.2
|
||||
et_xmlfile==2.0.0
|
||||
frozenlist==1.8.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
import-export==0.3.1
|
||||
kombu==5.5.4
|
||||
ldap3==2.9.1
|
||||
mattermostautodriver==11.7.2
|
||||
msgpack==1.1.2
|
||||
multidict==6.7.1
|
||||
openpyxl==3.1.5
|
||||
packaging==26.0
|
||||
pillow==12.2.0
|
||||
prompt_toolkit==3.0.52
|
||||
propcache==0.5.2
|
||||
psycopg2-binary==2.9.11
|
||||
PyJWT==2.12.1
|
||||
python-crontab==3.3.0
|
||||
python-dateutil==2.9.0.post0
|
||||
pytils==0.4.4
|
||||
PyYAML==6.0.3
|
||||
redis==5.2.1
|
||||
requests==2.33.1
|
||||
six==1.17.0
|
||||
sqlparse==0.5.5
|
||||
tablib==3.9.0
|
||||
tzdata==2026.1
|
||||
urllib3==2.6.3
|
||||
vine==5.1.0
|
||||
wcwidth==0.6.0
|
||||
yarl==1.24.2
|
||||
Reference in New Issue
Block a user