diff --git a/apps/ad_connector/ad_service.py b/apps/ad_connector/ad_service.py
new file mode 100644
index 0000000..86201c5
--- /dev/null
+++ b/apps/ad_connector/ad_service.py
@@ -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
diff --git a/apps/ad_connector/apps.py b/apps/ad_connector/apps.py
index 09c854d..1b1b911 100644
--- a/apps/ad_connector/apps.py
+++ b/apps/ad_connector/apps.py
@@ -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
diff --git a/apps/ad_connector/management/__init__.py b/apps/ad_connector/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/ad_connector/management/commands/__init__.py b/apps/ad_connector/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/ad_connector/management/commands/sync_users_to_ad.py b/apps/ad_connector/management/commands/sync_users_to_ad.py
new file mode 100644
index 0000000..ce05291
--- /dev/null
+++ b/apps/ad_connector/management/commands/sync_users_to_ad.py
@@ -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}'))
diff --git a/apps/ad_connector/services/__init__.py b/apps/ad_connector/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/apps/ad_connector/services/sync_service.py b/apps/ad_connector/services/sync_service.py
new file mode 100644
index 0000000..5cbffab
--- /dev/null
+++ b/apps/ad_connector/services/sync_service.py
@@ -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()
diff --git a/apps/ad_connector/signals.py b/apps/ad_connector/signals.py
new file mode 100644
index 0000000..e147ffd
--- /dev/null
+++ b/apps/ad_connector/signals.py
@@ -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}")
diff --git a/apps/ad_connector/tasks.py b/apps/ad_connector/tasks.py
new file mode 100644
index 0000000..2a569f8
--- /dev/null
+++ b/apps/ad_connector/tasks.py
@@ -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}")
diff --git a/apps/employees/migrations/0007_alter_employee_is_staff.py b/apps/employees/migrations/0007_alter_employee_is_staff.py
new file mode 100644
index 0000000..8f4b3ee
--- /dev/null
+++ b/apps/employees/migrations/0007_alter_employee_is_staff.py
@@ -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='Доступ к панели администратора'),
+ ),
+ ]
diff --git a/apps/it_manager/admin.py b/apps/it_manager/admin.py
index b99d3c7..2890790 100644
--- a/apps/it_manager/admin.py
+++ b/apps/it_manager/admin.py
@@ -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']
diff --git a/apps/it_manager/migrations/0006_equipment_brand_to_directory.py b/apps/it_manager/migrations/0006_equipment_brand_to_directory.py
new file mode 100644
index 0000000..e5439d3
--- /dev/null
+++ b/apps/it_manager/migrations/0006_equipment_brand_to_directory.py
@@ -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': 'Оборудование',
+ },
+ ),
+ ]
diff --git a/apps/it_manager/models/equipment.py b/apps/it_manager/models/equipment.py
index 27a0761..5586764 100644
--- a/apps/it_manager/models/equipment.py
+++ b/apps/it_manager/models/equipment.py
@@ -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']),
diff --git a/apps/mattermost_connector/admin.py b/apps/mattermost_connector/admin.py
index f9f051e..6e228b0 100755
--- a/apps/mattermost_connector/admin.py
+++ b/apps/mattermost_connector/admin.py
@@ -76,7 +76,7 @@ class TeamSyncSettingsAdmin(ModelAdmin):
@display(description='Отделов')
def departments_count(self, obj):
if not obj.filter_by_department:
- return format_html('Все')
+ return format_html('{}', 'Все')
count = obj.departments.count()
return format_html(
'{}',
@@ -144,7 +144,7 @@ class MattermostUserAdmin(ModelAdmin):
'{}',
count
)
- return format_html('0')
+ return format_html('{}', 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('Все')
+ return format_html('{}', 'Все')
count = obj.departments.count()
return format_html(
'{}',
diff --git a/apps/mattermost_connector/mattermost_service.py b/apps/mattermost_connector/mattermost_service.py
index 497fa9b..d1f80b8 100755
--- a/apps/mattermost_connector/mattermost_service.py
+++ b/apps/mattermost_connector/mattermost_service.py
@@ -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
\ No newline at end of file
+# 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
diff --git a/apps/mattermost_connector/services/sync_service.py b/apps/mattermost_connector/services/sync_service.py
index 0dab483..d7e1ccc 100755
--- a/apps/mattermost_connector/services/sync_service.py
+++ b/apps/mattermost_connector/services/sync_service.py
@@ -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
# Используем переданный пароль или генерируем новый
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..b7fa6d2
--- /dev/null
+++ b/requirements.txt
@@ -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