From 5008ba737fe9e7838fcfffcf39374deb204caab1 Mon Sep 17 00:00:00 2001 From: lokses Date: Thu, 2 Jul 2026 16:01:44 +0300 Subject: [PATCH] AD integration, Mattermost client rewrite, Equipment brand to Directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/ad_connector/ad_service.py | 203 ++++++++ apps/ad_connector/apps.py | 5 + apps/ad_connector/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/sync_users_to_ad.py | 41 ++ apps/ad_connector/services/__init__.py | 0 apps/ad_connector/services/sync_service.py | 227 +++++++++ apps/ad_connector/signals.py | 110 ++++ apps/ad_connector/tasks.py | 47 ++ .../0007_alter_employee_is_staff.py | 18 + apps/it_manager/admin.py | 10 +- .../0006_equipment_brand_to_directory.py | 77 +++ apps/it_manager/models/equipment.py | 9 +- apps/mattermost_connector/admin.py | 6 +- .../mattermost_service.py | 468 ++++++++---------- .../services/sync_service.py | 3 +- requirements.txt | 60 +++ 17 files changed, 1017 insertions(+), 267 deletions(-) create mode 100644 apps/ad_connector/ad_service.py create mode 100644 apps/ad_connector/management/__init__.py create mode 100644 apps/ad_connector/management/commands/__init__.py create mode 100644 apps/ad_connector/management/commands/sync_users_to_ad.py create mode 100644 apps/ad_connector/services/__init__.py create mode 100644 apps/ad_connector/services/sync_service.py create mode 100644 apps/ad_connector/signals.py create mode 100644 apps/ad_connector/tasks.py create mode 100644 apps/employees/migrations/0007_alter_employee_is_staff.py create mode 100644 apps/it_manager/migrations/0006_equipment_brand_to_directory.py create mode 100644 requirements.txt 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