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:
2026-07-02 16:01:44 +03:00
parent faa06b9f82
commit 5008ba737f
17 changed files with 1017 additions and 267 deletions
+203
View File
@@ -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
+5
View File
@@ -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}'))
+227
View File
@@ -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()
+110
View File
@@ -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}")
+47
View File
@@ -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='Доступ к панели администратора'),
),
]
+5 -5
View File
@@ -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': 'Оборудование',
},
),
]
+7 -2
View File
@@ -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']),
+3 -3
View File
@@ -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>',
+212 -256
View File
@@ -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
# Используем переданный пароль или генерируем новый
+60
View File
@@ -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