Імпорт співробітників з Active Directory
+Облікові записи AD, яким ще не відповідає жоден співробітник у системі
+diff --git a/apps/ad_connector/ad_service.py b/apps/ad_connector/ad_service.py index aa82f01..e82917e 100644 --- a/apps/ad_connector/ad_service.py +++ b/apps/ad_connector/ad_service.py @@ -5,7 +5,7 @@ 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 +from ldap3.utils.dn import escape_rdn, parse_dn, safe_rdn logger = logging.getLogger(__name__) @@ -121,6 +121,20 @@ class ADService: f'(&(objectClass=user)(mail={escape_filter_chars(email)}))' ) + def search_users(self, term: str, limit: int = 50) -> List[Dict]: + """Поиск пользователей по логину/ФИО/email — для ручного сопоставления""" + escaped = escape_filter_chars(term) + ldap_filter = ( + f'(&(objectClass=user)(!(objectClass=computer))(|' + f'(sAMAccountName=*{escaped}*)(cn=*{escaped}*)(mail=*{escaped}*)))' + ) + try: + self.conn.search(self.base_dn, ldap_filter, attributes=self.USER_ATTRIBUTES) + except LDAPException as e: + logger.error(f"AD: ошибка поиска '{term}' — {type(e).__name__}: {e}") + raise + return [self._entry_to_dict(entry) for entry in self.conn.entries[:limit]] + def get_all_users(self) -> List[Dict]: """Список всех учётных записей пользователей в каталоге""" try: @@ -134,6 +148,59 @@ class ADService: raise return [self._entry_to_dict(entry) for entry in self.conn.entries] + # --- Организационные юниты (OU) --- + + def list_ous(self) -> List[Dict]: + """OU в поддереве base_dn: [{'dn': ..., 'name': ...}, ...]""" + try: + self.conn.search( + self.base_dn, '(objectClass=organizationalUnit)', + attributes=['distinguishedName', 'ou'], + ) + except LDAPException as e: + logger.error(f"AD: ошибка получения списка OU — {type(e).__name__}: {e}") + raise + ous = [ + {'dn': entry.entry_dn, 'name': str(entry.ou) if 'ou' in entry else entry.entry_dn} + for entry in self.conn.entries + ] + return sorted(ous, key=lambda o: o['dn']) + + def create_ou(self, name: str, parent_dn: Optional[str] = None) -> str: + """Создание OU. Возвращает DN созданного юнита.""" + dn = f'OU={escape_rdn(name)},{parent_dn or self.users_ou}' + logger.info(f"AD: создание OU {dn}") + try: + self.conn.add(dn, ['top', 'organizationalUnit'], {'ou': name}) + except LDAPException as e: + logger.error(f"AD: ошибка создания OU {dn} — {type(e).__name__}: {e}") + raise + return dn + + def rename_ou(self, dn: str, new_name: str) -> str: + """Переименование OU (смена RDN). Возвращает новый DN.""" + relative_dn = f'OU={escape_rdn(new_name)}' + parent = ','.join( + f'{attr}={escape_rdn(value)}' + for attr, value, _ in parse_dn(dn, escape=False)[1:] + ) + logger.info(f"AD: переименование OU {dn} -> {relative_dn}") + try: + self.conn.modify_dn(dn, relative_dn) + except LDAPException as e: + logger.error(f"AD: ошибка переименования OU {dn} — {type(e).__name__}: {e}") + raise + return f'{relative_dn},{parent}' + + def delete_ou(self, dn: str) -> None: + """Удаление OU. AD не позволит удалить непустую OU.""" + logger.info(f"AD: удаление OU {dn}") + try: + self.conn.delete(dn) + except LDAPException as e: + logger.error(f"AD: ошибка удаления OU {dn} — {type(e).__name__}: {e}") + raise + # --- Создание и обновление --- def create_user(self, dn: str, attributes: Dict) -> None: @@ -178,6 +245,17 @@ class ADService: raise return f'{relative_dn},{parent}' + def move_user(self, dn: str, new_parent_dn: str) -> str: + """Перенос учётной записи в другую OU без изменения CN. Возвращает новый DN.""" + relative_dn = safe_rdn(dn)[0] + logger.info(f"AD: перенос {dn} -> {new_parent_dn}") + try: + self.conn.modify_dn(dn, relative_dn, new_superior=new_parent_dn) + except LDAPException as e: + logger.error(f"AD: ошибка переноса {dn} — {type(e).__name__}: {e}") + raise + return f'{relative_dn},{new_parent_dn}' + def set_password(self, dn: str, new_password: str) -> None: """Установка пароля. Требует подключения по LDAPS.""" if not self.use_ssl: diff --git a/apps/ad_connector/admin.py b/apps/ad_connector/admin.py index 8c38f3f..4f56804 100644 --- a/apps/ad_connector/admin.py +++ b/apps/ad_connector/admin.py @@ -1,3 +1,21 @@ from django.contrib import admin +from unfold.admin import ModelAdmin -# Register your models here. +from .models import ADSyncLog + + +@admin.register(ADSyncLog) +class ADSyncLogAdmin(ModelAdmin): + list_display = ('employee', 'action', 'status', 'created_at') + list_filter = ('action', 'status') + search_fields = ('employee__username', 'employee__last_name', 'employee__first_name', 'message') + date_hierarchy = 'created_at' + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/apps/ad_connector/migrations/0001_initial.py b/apps/ad_connector/migrations/0001_initial.py new file mode 100644 index 0000000..0ad88cd --- /dev/null +++ b/apps/ad_connector/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.3 on 2026-07-12 20:00 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ADSyncLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('action', models.CharField(choices=[('create', 'Создание'), ('update', 'Обновление'), ('activate', 'Включение'), ('deactivate', 'Отключение')], max_length=20, verbose_name='Действие')), + ('status', models.CharField(choices=[('success', 'Успех'), ('error', 'Ошибка')], max_length=10, verbose_name='Статус')), + ('message', models.TextField(blank=True, verbose_name='Сообщение')), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ad_sync_logs', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ], + options={ + 'verbose_name': 'Запись синхронизации AD', + 'verbose_name_plural': 'Журнал синхронизации AD', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/ad_connector/models.py b/apps/ad_connector/models.py index 71a8362..5f2c689 100644 --- a/apps/ad_connector/models.py +++ b/apps/ad_connector/models.py @@ -1,3 +1,33 @@ from django.db import models +from django.utils.translation import gettext_lazy as _ -# Create your models here. +from core.models import BaseModel + + +class ADSyncLog(BaseModel): + employee = models.ForeignKey( + 'employees.Employee', on_delete=models.CASCADE, + related_name='ad_sync_logs', verbose_name=_('Сотрудник'), + ) + action = models.CharField( + _('Действие'), max_length=20, + choices=[ + ('create', _('Создание')), + ('update', _('Обновление')), + ('activate', _('Включение')), + ('deactivate', _('Отключение')), + ], + ) + status = models.CharField( + _('Статус'), max_length=10, + choices=[('success', _('Успех')), ('error', _('Ошибка'))], + ) + message = models.TextField(_('Сообщение'), blank=True) + + class Meta: + verbose_name = _('Запись синхронизации AD') + verbose_name_plural = _('Журнал синхронизации AD') + ordering = ['-created_at'] + + def __str__(self): + return f'{self.employee} — {self.get_action_display()} — {self.get_status_display()}' diff --git a/apps/ad_connector/services/sync_service.py b/apps/ad_connector/services/sync_service.py index 3e264c4..b253024 100644 --- a/apps/ad_connector/services/sync_service.py +++ b/apps/ad_connector/services/sync_service.py @@ -21,6 +21,37 @@ def is_office_employee(employee: Employee) -> bool: return employee.user_location_type == 'office' +def _department_ou(employee: Employee) -> str: + """OU отдела сотрудника, если она явно задана в Directory.ad_ou_dn. Иначе '' — + тогда используется корневая OU коннектора (service.users_ou).""" + department = employee.department + if department is not None and department.ad_ou_dn: + return department.ad_ou_dn + return '' + + +def _log(employee: Employee, action: str, status: str, message: str = '') -> None: + from apps.ad_connector.models import ADSyncLog + ADSyncLog.objects.create(employee=employee, action=action, status=status, message=message) + + +def relocate_ou_references(old_dn: str, new_dn: str) -> None: + """ + После переноса/переименования OU в AD обновляет Directory.ad_ou_dn у самой записи + и у всех вложенных (чьи сохранённые DN физически лежали под old_dn), чтобы отделы + не остались привязаны к устаревшему пути. + """ + from core.models import Directory + + for directory in Directory.objects.exclude(ad_ou_dn=''): + if directory.ad_ou_dn == old_dn: + directory.ad_ou_dn = new_dn + directory.save(update_fields=['ad_ou_dn']) + elif directory.ad_ou_dn.endswith(',' + old_dn): + directory.ad_ou_dn = directory.ad_ou_dn[: -len(old_dn)] + new_dn + directory.save(update_fields=['ad_ou_dn']) + + def generate_random_password(length: int = 16) -> str: """Генерация пароля, удовлетворяющего дефолтной политике сложности AD""" alphabet = string.ascii_letters + string.digits @@ -98,11 +129,14 @@ def sync_employee_to_ad(employee: Employee, password: Optional[str] = None) -> b employee.ad_sync_status = 'disabled' return False + action = 'update' if employee.ad_user_dn else 'create' + try: service = ADService() except ADConfigError as e: logger.error(f"AD: конфигурация недоступна — {e}") _mark_error(employee, str(e)) + _log(employee, action, 'error', str(e)) return False try: @@ -123,6 +157,8 @@ def sync_employee_to_ad(employee: Employee, password: Optional[str] = None) -> b if not ad_user and employee.email: ad_user = service.get_user_by_email(employee.email) + department_ou = _department_ou(employee) + if ad_user: # Обновляем атрибуты существующей учётной записи dn = ad_user['dn'] @@ -134,6 +170,14 @@ def sync_employee_to_ad(employee: Employee, password: Optional[str] = None) -> b dn = service.rename_user(dn, full_name) logger.info(f"AD: учётная запись переименована в {dn}") + # Если у отдела сотрудника задана своя OU, отличная от текущей — + # переносим учётную запись в неё + if department_ou: + current_parent = dn.split(',', 1)[1] + if current_parent != department_ou: + dn = service.move_user(dn, department_ou) + logger.info(f"AD: учётная запись перенесена в OU отдела {dn}") + attributes = _build_attributes(employee, service, ad_user.get('sAMAccountName') or sam) attributes.pop('sAMAccountName', None) # логин не переименовываем service.update_user(dn, attributes) @@ -146,11 +190,12 @@ def sync_employee_to_ad(employee: Employee, password: Optional[str] = None) -> b logger.info(f"AD: учётная запись {dn} включена повторно") _mark_synced(employee, dn) logger.info(f"AD: обновлён пользователь {dn} для {employee.email}") + _log(employee, action, 'success') return True - # 3. Создаём нового + # 3. Создаём нового — в OU отдела, если она задана, иначе в корневой OU коннектора cn = escape_rdn(employee.get_full_name() or sam) - dn = f'CN={cn},{service.users_ou}' + dn = f'CN={cn},{department_ou or service.users_ou}' attributes = _build_attributes(employee, service, sam) service.create_user(dn, attributes) @@ -160,17 +205,21 @@ def sync_employee_to_ad(employee: Employee, password: Optional[str] = None) -> b service.set_password(dn, user_password) service.enable_user(dn) logger.info(f"AD: создан и включён пользователь {dn} для {employee.email}") + if not password: + note = f'Временный пароль: {user_password} (сообщите сотруднику для первого входа)' else: note = ('Учётная запись создана отключённой: установка пароля ' 'и включение требуют подключения по LDAPS (use_ssl=True).') logger.warning(f"AD: {dn} создан отключённым — нет LDAPS") _mark_synced(employee, dn, note) + _log(employee, action, 'success', 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}') + _log(employee, action, 'error', f'{type(e).__name__}: {e}') return False finally: service.unbind() @@ -192,10 +241,12 @@ def deactivate_ad_user(employee: Employee) -> bool: ad_last_sync=timezone.now(), ) logger.info(f"AD: отключена учётная запись {employee.ad_user_dn}") + _log(employee, 'deactivate', 'success') return True except Exception as e: logger.error(f"AD: ошибка отключения {employee.ad_user_dn} — {e}") _mark_error(employee, f'{type(e).__name__}: {e}') + _log(employee, 'deactivate', 'error', f'{type(e).__name__}: {e}') return False finally: service.unbind() @@ -218,10 +269,12 @@ def activate_ad_user(employee: Employee) -> bool: ad_last_sync=timezone.now(), ) logger.info(f"AD: включена учётная запись {employee.ad_user_dn}") + _log(employee, 'activate', 'success') return True except Exception as e: logger.error(f"AD: ошибка включения {employee.ad_user_dn} — {e}") _mark_error(employee, f'{type(e).__name__}: {e}') + _log(employee, 'activate', 'error', f'{type(e).__name__}: {e}') return False finally: service.unbind() diff --git a/apps/ad_connector/urls.py b/apps/ad_connector/urls.py index 05eb9c1..2420c84 100644 --- a/apps/ad_connector/urls.py +++ b/apps/ad_connector/urls.py @@ -4,4 +4,6 @@ from . import views urlpatterns = [ path('ad-mapping/', views.ad_mapping_view, name='ad_mapping'), + path('ad-ou-manage/', views.ad_ou_manage_view, name='ad_ou_manage'), + path('ad-import/', views.ad_import_view, name='ad_import'), ] diff --git a/apps/ad_connector/views.py b/apps/ad_connector/views.py index a21656b..d8c5c60 100644 --- a/apps/ad_connector/views.py +++ b/apps/ad_connector/views.py @@ -2,18 +2,20 @@ import logging from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth import get_user_model -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404 +from django.utils import timezone from apps.ad_connector.ad_service import ADConfigError, ADService +from apps.ad_connector.services.sync_service import relocate_ou_references +from apps.locations.models import Location +from core.models import Directory +from core.utils.htmx import render_page +from core.utils.pagination import paginate Employee = get_user_model() logger = logging.getLogger(__name__) -def is_htmx_request(request): - return request.headers.get('HX-Request') == 'true' - - def _handle_action(request): """Обработка действий сопоставления: связать / отвязать / создать""" action = request.POST.get('action') @@ -72,6 +74,8 @@ def ad_mapping_view(request): if request.method == 'POST': _handle_action(request) + query = request.GET.get('q', '').strip() + employees = list( Employee.objects.filter(user_location_type='office', is_active=True) .select_related('post', 'department') @@ -82,7 +86,7 @@ def ad_mapping_view(request): try: service = ADService() try: - ad_users = service.get_all_users() + ad_users = service.search_users(query) if query else service.get_all_users() for ad_user in ad_users: ad_user['is_disabled'] = service.is_disabled(ad_user) finally: @@ -111,13 +115,184 @@ def ad_mapping_view(request): unmatched_ad_users = [u for u in ad_users if u['dn'] not in linked_dns] context = { - 'employees': employees, + 'employees': paginate(request, employees, page_param='emp_page', size_param='emp_per_page'), + 'employees_total': len(employees), 'ad_error': ad_error, 'ad_total': len(ad_users), 'linked_count': linked_count, 'unlinked_count': len(employees) - linked_count, + # Повний список — для випадаючого списку "зв'язати" у кожному рядку співробітника 'unmatched_ad_users': unmatched_ad_users, + # Пагінована версія — для окремої таблиці "Облікові записи AD без співробітника" + 'unmatched_ad_users_page': paginate(request, unmatched_ad_users, page_param='ad_page', size_param='ad_per_page'), + 'query': query, } - if is_htmx_request(request): - return render(request, 'pages/ad_mapping.html', context) - return render(request, 'index.html', {'page_template': 'pages/ad_mapping.html', **context}) + return render_page(request, 'ad_mapping.html', context) + + +def _handle_ou_action(request): + """Обработка действий управления OU: создание / переименование / удаление. + Возвращает (error, notice).""" + action = request.POST.get('action') + try: + service = ADService() + except ADConfigError as e: + return str(e), None + + try: + if action == 'create': + name = request.POST.get('name', '').strip() + parent_dn = request.POST.get('parent_dn', '').strip() + if not name: + return 'Вкажіть назву OU', None + dn = service.create_ou(name, parent_dn or service.users_ou) + logger.info(f"AD OU: створено {dn}") + return None, f'OU створено: {dn}' + + elif action == 'rename': + dn = request.POST.get('dn', '').strip() + new_name = request.POST.get('new_name', '').strip() + if not dn or not new_name: + return 'Вкажіть OU і нову назву', None + new_dn = service.rename_ou(dn, new_name) + relocate_ou_references(dn, new_dn) + logger.info(f"AD OU: перейменовано {dn} -> {new_dn}") + return None, f'OU перейменовано: {new_dn}' + + elif action == 'delete': + dn = request.POST.get('dn', '').strip() + linked = Directory.objects.filter(ad_ou_dn=dn) + if linked.exists(): + names = ', '.join(linked.values_list('name', flat=True)) + return f'Не можна видалити — OU використовується у відділах: {names}', None + service.delete_ou(dn) + logger.info(f"AD OU: видалено {dn}") + return None, f'OU видалено: {dn}' + + return 'Невідома дія', None + except Exception as e: + logger.error(f"AD OU: помилка дії {action} — {e}") + return f'Помилка AD: {e}', None + finally: + service.unbind() + + +@staff_member_required +def ad_ou_manage_view(request): + """Управление организационными юнитами (OU) в Active Directory и их привязкой к отделам""" + error, notice = None, None + if request.method == 'POST': + error, notice = _handle_ou_action(request) + + ous, ad_error, root_ou = [], None, '' + try: + service = ADService() + try: + ous = service.list_ous() + root_ou = service.users_ou + finally: + service.unbind() + except ADConfigError as e: + ad_error = str(e) + except Exception as e: + ad_error = f'Помилка з’єднання з Active Directory: {e}' + logger.error(f"AD OU: {ad_error}") + + linked_by_dn = dict(Directory.objects.exclude(ad_ou_dn='').values_list('ad_ou_dn', 'name')) + for ou in ous: + ou['department'] = linked_by_dn.get(ou['dn']) + + context = { + # Повний список — для випадаючого списку "Батьківська OU" у формі створення + 'ous': ous, + # Пагінована версія — для таблиці OU + 'ous_page': paginate(request, ous, page_param='ou_page', size_param='ou_per_page'), + 'root_ou': root_ou, + 'ad_error': ad_error or error, + 'notice': notice, + } + return render_page(request, 'ad_ou_manage.html', context) + + +def _import_candidates(): + """AD-учётки без соответствующего Employee — кандидаты на импорт. Возвращает (candidates, ad_error).""" + existing_usernames = set(Employee.objects.values_list('username', flat=True)) + try: + service = ADService() + try: + ad_users = service.get_all_users() + finally: + service.unbind() + except ADConfigError as e: + return [], str(e) + except Exception as e: + return [], f'Помилка з’єднання з Active Directory: {e}' + + candidates = [] + for u in ad_users: + sam = u.get('sAMAccountName') or '' + if not sam or sam in existing_usernames: + continue + candidates.append({ + 'username': sam, + 'first_name': u.get('givenName') or sam, + 'last_name': u.get('sn') or sam, + 'email': u.get('mail') or f'{sam}@local.domain', + 'dn': u.get('dn', ''), + 'is_disabled': ADService.is_disabled(u), + }) + return sorted(candidates, key=lambda c: c['username']), None + + +def _import_context(request): + candidates, ad_error = _import_candidates() + return { + 'candidates': paginate(request, candidates, page_param='candidates_page', size_param='candidates_per_page'), + 'candidates_total': len(candidates), + 'ad_error': ad_error, + 'departments': Directory.objects.get_by_type('department'), + 'posts': Directory.objects.get_by_type('post'), + 'locations': Location.objects.by_type('office'), + 'today': timezone.now().date().isoformat(), + } + + +@staff_member_required +def ad_import_view(request): + """Импорт учётных записей AD, отсутствующих среди сотрудников, как новых офисных сотрудников. + Ничего не создаётся, пока администратор явно не отметит и не подтвердит конкретные записи.""" + imported, errors = [], [] + if request.method == 'POST': + candidates, _ = _import_candidates() + hire_date = request.POST.get('hire_date') or timezone.now().date().isoformat() + location_id = request.POST.get('user_location') or None + + for candidate in candidates: + username = candidate['username'] + if not request.POST.get(f'import_{username}'): + continue + + employee = Employee( + username=username, + first_name=candidate['first_name'], + last_name=candidate['last_name'], + email=candidate['email'], + hire_date=hire_date, + user_location_type='office', + user_location_id=location_id, + department_id=request.POST.get(f'department_{username}') or None, + post_id=request.POST.get(f'post_{username}') or None, + ad_sync_enabled=True, + ) + employee.set_unusable_password() + try: + employee.full_clean() + employee.save() + imported.append(username) + logger.info(f"AD-импорт: создан сотрудник {username}") + except Exception as exc: + errors.append(f'{username}: {exc}') + + context = _import_context(request) + context.update({'imported': imported, 'errors': errors}) + return render_page(request, 'ad_import.html', context) diff --git a/apps/mattermost_connector/management/commands/create_mattermost_bot.py b/apps/mattermost_connector/management/commands/create_mattermost_bot.py new file mode 100644 index 0000000..c206a8e --- /dev/null +++ b/apps/mattermost_connector/management/commands/create_mattermost_bot.py @@ -0,0 +1,76 @@ +"""Заводит Bot Account в Mattermost для уведомлений портала (задачи и т.п.) +и сохраняет его user_id/токен в ConnectorConfig(connector_type='mattermost').extra_config. +Идемпотентна: повторный запуск без --force ничего не меняет, если бот уже создан.""" +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = 'Создать Mattermost-бота для уведомлений портала' + + def add_arguments(self, parser): + parser.add_argument('--username', default='acore-portal-bot') + parser.add_argument('--display-name', default='ACORE Портал') + parser.add_argument('--force', action='store_true', help='Пересоздать токен, даже если бот уже настроен') + parser.add_argument('--set-token', + help='Токен бота, созданного вручную через System Console — ' + 'просто сохранить его (без попытки создать бота через API)') + + def handle(self, *args, **options): + from core.models import ConnectorConfig + from apps.mattermost_connector.mattermost_service import MattermostService, MattermostConfigError + + cfg = ConnectorConfig.objects.filter(connector_type='mattermost').first() + if not cfg or not cfg.is_enabled: + raise CommandError('Коннектор Mattermost не настроен или отключён') + + extra = cfg.extra_config or {} + + if options['set_token']: + try: + service = MattermostService() + except MattermostConfigError as e: + raise CommandError(str(e)) + bot_user = service.driver.users.get_user_by_username(options['username']) + extra['bot_user_id'] = bot_user['id'] + extra['bot_token'] = options['set_token'] + cfg.extra_config = extra + cfg.save(update_fields=['extra_config']) + self.stdout.write(self.style.SUCCESS( + f"Токен бота {options['username']} (id={bot_user['id']}) сохранён в ConnectorConfig.extra_config" + )) + return + + if extra.get('bot_token') and not options['force']: + self.stdout.write(self.style.WARNING( + f"Бот уже настроен (bot_user_id={extra.get('bot_user_id')}). Используйте --force для пересоздания токена." + )) + return + + try: + service = MattermostService() + except MattermostConfigError as e: + raise CommandError(str(e)) + + driver = service.driver + username = options['username'] + + bot = None + for existing in driver.bots.get_bots(per_page=200): + if existing['username'] == username: + bot = existing + break + if bot is None: + bot = driver.bots.create_bot(username=username, display_name=options['display_name'], + description='Уведомления из портала ACORE') + self.stdout.write(self.style.SUCCESS(f"Создан бот {username} (id={bot['user_id']})")) + else: + self.stdout.write(self.style.WARNING(f"Бот {username} уже существует (id={bot['user_id']})")) + + token = driver.users.create_user_access_token(bot['user_id'], description='ACORE portal notifications') + + extra['bot_user_id'] = bot['user_id'] + extra['bot_token'] = token['token'] + cfg.extra_config = extra + cfg.save(update_fields=['extra_config']) + + self.stdout.write(self.style.SUCCESS('Токен бота сохранён в ConnectorConfig.extra_config')) diff --git a/apps/mattermost_connector/management/commands/sync_employees_to_mattermost.py b/apps/mattermost_connector/management/commands/sync_employees_to_mattermost.py new file mode 100644 index 0000000..569d786 --- /dev/null +++ b/apps/mattermost_connector/management/commands/sync_employees_to_mattermost.py @@ -0,0 +1,62 @@ +"""Включает mattermost_sync_enabled и создаёт/связывает реальные аккаунты в +Mattermost для сотрудников, у которых их ещё нет (например, после массового +импорта employees.json, где синхронизация была намеренно отключена).""" +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +from apps.mattermost_connector.services.sync_service import sync_employee + +Employee = get_user_model() + + +class Command(BaseCommand): + help = 'Создать/связать аккаунты Mattermost для сотрудников без mattermost_user_id' + + def add_arguments(self, parser): + parser.add_argument('--user-id', type=int, help='ID конкретного сотрудника') + parser.add_argument('--missing', action='store_true', + help='Все сотрудники без mattermost_user_id и с mattermost_sync_enabled=False') + parser.add_argument('--apply', action='store_true', + help='Выполнить реальную синхронизацию (создаст аккаунты на живом Mattermost-сервере). ' + 'Без флага — только список кандидатов (dry-run)') + + def handle(self, *args, **options): + if options['user_id']: + employees = Employee.objects.filter(pk=options['user_id']) + if not employees.exists(): + raise CommandError(f"Сотрудник #{options['user_id']} не найден") + elif options['missing']: + employees = Employee.objects.filter( + mattermost_user_id__isnull=True, mattermost_sync_enabled=False, + ) + else: + raise CommandError('Укажите --user-id или --missing') + + apply = options['apply'] + total = employees.count() + self.stdout.write( + f"{'Синхронизация' if apply else '[dry-run] Кандидатов на синхронизацию'}: {total}" + ) + + if not apply: + for employee in employees: + self.stdout.write(f' - {employee.get_full_name()} ({employee.email})') + self.stdout.write(self.style.WARNING( + 'Это был dry-run. Повторите с --apply, чтобы реально создать аккаунты в Mattermost.' + )) + return + + ok, failed = 0, 0 + for employee in employees: + employee.mattermost_sync_enabled = True + employee.save(update_fields=['mattermost_sync_enabled']) + if sync_employee(employee): + ok += 1 + self.stdout.write(self.style.SUCCESS(f' ✓ {employee.get_full_name()}')) + else: + failed += 1 + self.stdout.write(self.style.ERROR( + f' ✗ {employee.get_full_name()}: ошибка синхронизации, см. mattermost_sync_status/логи' + )) + + self.stdout.write(self.style.SUCCESS(f'Готово: {ok}/{total}, ошибок: {failed}')) diff --git a/apps/mattermost_connector/mattermost_service.py b/apps/mattermost_connector/mattermost_service.py index d1f80b8..54c9ebc 100755 --- a/apps/mattermost_connector/mattermost_service.py +++ b/apps/mattermost_connector/mattermost_service.py @@ -106,6 +106,13 @@ class MattermostService: self.driver.channels.add_channel_member, channel_id, user_id=user_id, ) + def create_user_access_token(self, user_id: str, description: str) -> Dict: + """Создание Personal Access Token для пользователя""" + return self._call( + f'POST users/{user_id}/tokens', + self.driver.users.create_user_access_token, user_id, description, + ) + def get_teams(self) -> List[Dict]: """Получение списка команд""" return self._call('GET teams', self.driver.teams.get_all_teams) @@ -117,6 +124,16 @@ class MattermostService: self.driver.channels.get_public_channels_for_team, team_id, ) + def update_channel(self, channel_id: str, display_name: str = None, + purpose: str = None, header: str = None) -> Dict: + """Частичное обновление канала (PATCH). URL-slug (name) канала этим не меняется — + только то, что видит пользователь: отображаемое имя/описание/шапка.""" + return self._call( + f'PATCH channels/{channel_id}', + self.driver.channels.patch_channel, channel_id, + display_name=display_name, purpose=purpose, header=header, + ) + def reset_password(self, user_id: str, new_password: str) -> Dict: """Сброс пароля пользователя (от имени администратора)""" return self._call( diff --git a/apps/mattermost_connector/migrations/0002_mattermostuser_access_token_encrypted.py b/apps/mattermost_connector/migrations/0002_mattermostuser_access_token_encrypted.py new file mode 100644 index 0000000..6e320c0 --- /dev/null +++ b/apps/mattermost_connector/migrations/0002_mattermostuser_access_token_encrypted.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-07-10 08:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mattermost_connector', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='mattermostuser', + name='access_token_encrypted', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/apps/mattermost_connector/models.py b/apps/mattermost_connector/models.py index 09656fe..18cfd12 100755 --- a/apps/mattermost_connector/models.py +++ b/apps/mattermost_connector/models.py @@ -8,7 +8,7 @@ User = get_user_model() class MattermostUser(models.Model): """Связь между Django пользователем и Mattermost пользователем""" django_user = models.OneToOneField( - User, + User, on_delete=models.CASCADE, related_name='mattermost_profile' ) @@ -16,18 +16,27 @@ class MattermostUser(models.Model): username = models.CharField(max_length=150) email = models.EmailField() is_active = models.BooleanField(default=True) + access_token_encrypted = models.TextField(blank=True, default='') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) last_sync = models.DateTimeField(null=True, blank=True) - + class Meta: db_table = 'mattermost_users' verbose_name = 'Mattermost User' verbose_name_plural = 'Mattermost Users' - + def __str__(self): return f"{self.django_user.username} -> {self.username}" + def set_access_token(self, plain: str) -> None: + from core.utils.crypto import encrypt_secret + self.access_token_encrypted = encrypt_secret(plain) + + def get_access_token(self) -> str: + from core.utils.crypto import decrypt_secret + return decrypt_secret(self.access_token_encrypted) + class MattermostTeam(models.Model): """Команды Mattermost""" team_id = models.CharField(max_length=100, unique=True) diff --git a/apps/mattermost_connector/services/bot_service.py b/apps/mattermost_connector/services/bot_service.py new file mode 100644 index 0000000..cc088fb --- /dev/null +++ b/apps/mattermost_connector/services/bot_service.py @@ -0,0 +1,170 @@ +"""Уведомления от Mattermost-бота портала (личные сообщения сотрудникам). +Бот заводится вручную в System Console (создание бота через API недоступно +на этом сервере — 403 даже для system_admin), токен хранится в +ConnectorConfig(connector_type='mattermost').extra_config['bot_token'].""" +import logging + +from django.conf import settings +from django.core import signing + +logger = logging.getLogger(__name__) + +TASK_ACTION_SALT = 'mattermost-task-action' +TASK_ACTION_MAX_AGE = 60 * 60 * 24 * 7 # тиждень — довше кнопка "Виконано" вважається недійсною + + +def _get_bot_driver(): + from core.models import ConnectorConfig + from mattermostautodriver import TypedDriver + + cfg = ConnectorConfig.objects.filter(connector_type='mattermost', is_enabled=True).first() + if not cfg: + return None, None + + extra = cfg.extra_config or {} + bot_token = extra.get('bot_token') + bot_user_id = extra.get('bot_user_id') + if not bot_token or not bot_user_id: + return None, None + + scheme = 'https' if cfg.use_ssl else 'http' + driver = TypedDriver({ + 'url': cfg.host, + 'port': cfg.port or (443 if cfg.use_ssl else 80), + 'scheme': scheme, + 'timeout': 15, + }) + driver.client.token = bot_token + return driver, bot_user_id + + +def send_direct_message(employee, message: str, props: dict | None = None) -> bool: + """Отправить ЛС сотруднику от имени бота портала. False — бот не + настроен, у сотрудника нет mattermost_user_id, или запрос не удался.""" + if not employee.mattermost_user_id: + return False + + driver, bot_user_id = _get_bot_driver() + if not driver: + logger.warning('Mattermost-бот не настроен (нет bot_token в ConnectorConfig) — уведомление не отправлено') + return False + + try: + channel = driver.channels.create_direct_channel([bot_user_id, employee.mattermost_user_id]) + driver.posts.create_post(channel['id'], message, props=props) + return True + except Exception as e: + logger.error(f'Не удалось отправить сообщение от бота сотруднику {employee.email}: {e}') + return False + + +def _task_done_attachment(task) -> dict: + """Интерактивна кнопка «Виконано» під повідомленням. task_id підписаний + (django.core.signing) — сервер перевіряє підпис у task_done_action, + окремого секрету заводити не треба, використовується SECRET_KEY.""" + action_url = f'{settings.PORTAL_BASE_URL.rstrip("/")}/mattermost/actions/task-done/' + signed_task_id = signing.dumps(task.pk, salt=TASK_ACTION_SALT) + return { + 'attachments': [{ + 'actions': [{ + 'id': 'task_done', + 'name': 'Виконано', + 'integration': {'url': action_url, 'context': {'t': signed_task_id}}, + }], + }], + } + + +def notify_task_assigned(employee, task, role_label: str, can_complete: bool = False) -> bool: + url = f'{settings.PORTAL_BASE_URL.rstrip("/")}/tasks/{task.pk}/' + message = f'Вам призначено задачу «{task.title}» ({role_label}).\n{url}' + props = _task_done_attachment(task) if can_complete else None + return send_direct_message(employee, message, props=props) + + +def create_channel_post(channel_id: str, message: str, props: dict | None = None, + file_ids: list[str] | None = None, root_id: str | None = None) -> str | None: + """Опублікувати НОВЕ повідомлення від бота у публічний канал (не ЛС). + root_id — щоб відповісти в тред під конкретним повідомленням, а не постити окремо. + Бот має бути учасником команди/каналу — інакше Mattermost поверне 403. + Повертає id повідомлення (щоб потім можна було відредагувати) або None при помилці.""" + driver, _bot_user_id = _get_bot_driver() + if not driver: + logger.warning('Mattermost-бот не настроен — сообщение в канал не отправлено') + return None + + try: + result = driver.posts.create_post(channel_id, message, props=props, file_ids=file_ids, root_id=root_id) + return result['id'] + except Exception as e: + logger.error(f'Не удалось отправить сообщение от бота в канал {channel_id}: {e}') + return None + + +def delete_channel_post(post_id: str) -> bool: + """Видалити повідомлення в каналі (разом з ним Mattermost сам видаляє й усі + відповіді в треді — окремо видаляти репліки коментарів не треба).""" + driver, _bot_user_id = _get_bot_driver() + if not driver: + return False + + try: + driver.posts.delete_post(post_id) + return True + except Exception as e: + logger.error(f'Не удалось удалить сообщение {post_id}: {e}') + return False + + +def get_thread_replies(root_post_id: str) -> list[dict]: + """Відповіді в треді під root_post_id (сам кореневий пост виключено), + відсортовані за часом створення — найстаріші перші.""" + driver, _bot_user_id = _get_bot_driver() + if not driver: + return [] + + try: + thread = driver.posts.get_post_thread(root_post_id) + except Exception as e: + logger.error(f'Не удалось получить тред {root_post_id}: {e}') + return [] + + posts = thread.get('posts', {}) + replies = [p for pid, p in posts.items() if pid != root_post_id and not p.get('delete_at')] + replies.sort(key=lambda p: p.get('create_at', 0)) + return replies + + +def update_channel_post(post_id: str, message: str, props: dict | None = None, + file_ids: list[str] | None = None) -> bool: + """Відредагувати вже надіслане ботом повідомлення. False — бот не налаштований, + повідомлення видалено в Mattermost, або запит не вдався (тоді викликач має + відкотитись на create_channel_post і опублікувати нове).""" + driver, _bot_user_id = _get_bot_driver() + if not driver: + return False + + try: + driver.posts.patch_post(post_id, message=message, props=props, file_ids=file_ids) + return True + except Exception as e: + logger.warning(f'Не удалось отредактировать сообщение {post_id} — опубликую заново: {e}') + return False + + +def upload_channel_file(channel_id: str, filename: str, content: bytes, + content_type: str = 'application/octet-stream') -> str | None: + """Завантажити файл (картинку) в канал перед публікацією поста — + повертає file_id для file_ids у create_channel_post/update_channel_post. + Файли прив'язані до каналу при завантаженні, тому для кожного каналу, + куди йде пост, картинку треба заливати окремо.""" + driver, _bot_user_id = _get_bot_driver() + if not driver: + return None + + try: + result = driver.files.upload_file(files=(filename, content, content_type), channel_id=channel_id) + return result['file_infos'][0]['id'] + except Exception as e: + logger.error(f'Не удалось загрузить файл {filename} в канал {channel_id}: {e}') + return None diff --git a/apps/mattermost_connector/services/sync_service.py b/apps/mattermost_connector/services/sync_service.py index d7e1ccc..c890029 100755 --- a/apps/mattermost_connector/services/sync_service.py +++ b/apps/mattermost_connector/services/sync_service.py @@ -40,9 +40,18 @@ def sync_employee(employee: Employee, password: Optional[str] = None) -> bool: if employee.mattermost_user_id: try: mm_data = service.get_user(employee.mattermost_user_id) - mattermost_user = MattermostUser.objects.filter( - mattermost_id=employee.mattermost_user_id - ).first() + mattermost_user, _ = MattermostUser.objects.get_or_create( + mattermost_id=employee.mattermost_user_id, + defaults={ + 'django_user': employee, + 'username': mm_data.get('username', ''), + 'email': mm_data.get('email', employee.email), + 'is_active': not mm_data.get('delete_at', 0), + } + ) + if mattermost_user.django_user_id != employee.pk: + mattermost_user.django_user = employee + mattermost_user.save(update_fields=['django_user']) logger.info(f"Найден существующий пользователь Mattermost по ID: {employee.mattermost_user_id}") except Exception as e: logger.warning(f"Пользователь {employee.mattermost_user_id} не найден в Mattermost: {e}") diff --git a/apps/mattermost_connector/services/token_service.py b/apps/mattermost_connector/services/token_service.py new file mode 100644 index 0000000..2671484 --- /dev/null +++ b/apps/mattermost_connector/services/token_service.py @@ -0,0 +1,30 @@ +import logging + +from apps.mattermost_connector.models import MattermostUser +from apps.mattermost_connector.mattermost_service import MattermostService + +logger = logging.getLogger(__name__) + + +def ensure_personal_access_token(employee) -> str | None: + """Возвращает персональный access token сотрудника в Mattermost, + создавая его при первом обращении.""" + if not employee.mattermost_user_id: + return None + + mm_user = MattermostUser.objects.filter(django_user=employee).first() + if not mm_user: + return None + + if mm_user.access_token_encrypted: + return mm_user.get_access_token() + + try: + service = MattermostService() + data = service.create_user_access_token(employee.mattermost_user_id, 'ACORE Portal Chat') + mm_user.set_access_token(data['token']) + mm_user.save(update_fields=['access_token_encrypted']) + return data['token'] + except Exception as e: + logger.error(f"Не удалось создать Mattermost access token для {employee.email}: {e}") + return None diff --git a/apps/mattermost_connector/tests.py b/apps/mattermost_connector/tests.py index e8602c4..a9174fd 100755 --- a/apps/mattermost_connector/tests.py +++ b/apps/mattermost_connector/tests.py @@ -10,7 +10,7 @@ from django.test import TestCase from mattermostautodriver.exceptions import ResourceNotFound from apps.mattermost_connector.models import MattermostUser -from apps.mattermost_connector.services import sync_service +from apps.mattermost_connector.services import sync_service, token_service Employee = get_user_model() @@ -213,3 +213,46 @@ class MattermostSignalTests(TestCase): employee.is_active = False employee.save() deactivate.assert_called_once() + + +class EnsurePersonalAccessTokenTests(TestCase): + def test_no_mattermost_user_id_returns_none(self): + employee = make_employee() + self.assertIsNone(token_service.ensure_personal_access_token(employee)) + + def test_no_mattermost_user_row_returns_none(self): + employee = make_employee(mattermost_user_id='mm-abc') + self.assertIsNone(token_service.ensure_personal_access_token(employee)) + + def test_creates_and_encrypts_token_on_first_call(self): + employee = make_employee(mattermost_user_id='mm-abc') + MattermostUser.objects.create( + django_user=employee, mattermost_id='mm-abc', username='ivan', email=employee.email, + ) + service = mock.Mock() + service.create_user_access_token.return_value = {'token': 'plain-secret-token'} + with mock.patch.object(token_service, 'MattermostService', return_value=service): + token = token_service.ensure_personal_access_token(employee) + + self.assertEqual(token, 'plain-secret-token') + service.create_user_access_token.assert_called_once_with('mm-abc', 'ACORE Portal Chat') + + mm_user = MattermostUser.objects.get(mattermost_id='mm-abc') + self.assertTrue(mm_user.access_token_encrypted) + self.assertNotEqual(mm_user.access_token_encrypted, 'plain-secret-token') + self.assertEqual(mm_user.get_access_token(), 'plain-secret-token') + + def test_reuses_stored_token_without_recreating(self): + employee = make_employee(mattermost_user_id='mm-abc') + mm_user = MattermostUser.objects.create( + django_user=employee, mattermost_id='mm-abc', username='ivan', email=employee.email, + ) + mm_user.set_access_token('already-stored-token') + mm_user.save(update_fields=['access_token_encrypted']) + + service = mock.Mock() + with mock.patch.object(token_service, 'MattermostService', return_value=service): + token = token_service.ensure_personal_access_token(employee) + + self.assertEqual(token, 'already-stored-token') + service.create_user_access_token.assert_not_called() diff --git a/apps/mattermost_connector/urls.py b/apps/mattermost_connector/urls.py new file mode 100644 index 0000000..227d4c6 --- /dev/null +++ b/apps/mattermost_connector/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from apps.mattermost_connector import views + +urlpatterns = [ + path('mattermost/commands/tasks/', views.slash_command_tasks, name='mattermost_slash_tasks'), + path('mattermost/actions/task-done/', views.task_done_action, name='mattermost_task_done_action'), +] diff --git a/apps/mattermost_connector/views.py b/apps/mattermost_connector/views.py index c60c790..9cdd104 100755 --- a/apps/mattermost_connector/views.py +++ b/apps/mattermost_connector/views.py @@ -1,3 +1,88 @@ -from django.shortcuts import render - -# Create your views here. +import json +import logging + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core import signing +from django.http import JsonResponse, HttpResponseForbidden +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +logger = logging.getLogger(__name__) + +Employee = get_user_model() + + +def _find_employee(mattermost_user_id): + if not mattermost_user_id: + return None + return Employee.objects.filter(mattermost_user_id=mattermost_user_id).first() + + +@csrf_exempt +@require_POST +def slash_command_tasks(request): + """Обработчик слэш-команды /tasks — Mattermost шлёт application/x-www-form-urlencoded. + https://developers.mattermost.com/integrate/slash-commands/custom/""" + token = request.POST.get('token', '') + if not settings.MATTERMOST_SLASH_COMMAND_TOKEN or token != settings.MATTERMOST_SLASH_COMMAND_TOKEN: + return HttpResponseForbidden('invalid token') + + employee = _find_employee(request.POST.get('user_id')) + if employee is None: + return JsonResponse({ + 'response_type': 'ephemeral', + 'text': 'Не знайшов вашого облікового запису в порталі ACORE. Зверніться до HR.', + }) + + from apps.tasks.models import Task + tasks = ( + Task.objects.for_user(employee) + .exclude(status__in=['done', 'cancelled']) + .order_by('due_date', '-priority')[:10] + ) + if not tasks: + text = 'У вас немає активних задач. 🎉' + else: + base_url = settings.PORTAL_BASE_URL.rstrip('/') + lines = ['**Ваші активні задачі:**'] + for task in tasks: + due = f' (до {task.due_date:%d.%m.%Y})' if task.due_date else '' + lines.append(f'- [{task.title}]({base_url}/tasks/{task.pk}/){due}') + text = '\n'.join(lines) + + return JsonResponse({'response_type': 'ephemeral', 'text': text}) + + +@csrf_exempt +@require_POST +def task_done_action(request): + """Обработчик кнопки «Виконано» в интерактивному повідомленні бота. + https://developers.mattermost.com/integrate/interactive-messages/""" + try: + payload = json.loads(request.body) + except (json.JSONDecodeError, UnicodeDecodeError): + return HttpResponseForbidden('bad payload') + + from apps.mattermost_connector.services.bot_service import TASK_ACTION_SALT, TASK_ACTION_MAX_AGE + + context = payload.get('context') or {} + try: + task_id = signing.loads(context.get('t', ''), salt=TASK_ACTION_SALT, max_age=TASK_ACTION_MAX_AGE) + except signing.BadSignature: + return JsonResponse({'ephemeral_text': 'Посилання застаріло або недійсне.'}) + + employee = _find_employee(payload.get('user_id')) + if employee is None: + return JsonResponse({'ephemeral_text': 'Не знайшов вашого облікового запису в порталі ACORE.'}) + + from apps.tasks.models import Task + task = Task.objects.filter(pk=task_id).first() + if task is None: + return JsonResponse({'ephemeral_text': 'Задачу не знайдено — можливо, її видалили.'}) + + if not task.is_participant(employee): + return JsonResponse({'ephemeral_text': 'У вас немає прав змінювати статус цієї задачі.'}) + + task.set_status('done') + return JsonResponse({'ephemeral_text': f'✅ Задача «{task.title}» позначена виконаною.'}) diff --git a/core/admin.py b/core/admin.py index d3d51f5..2783d0c 100644 --- a/core/admin.py +++ b/core/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin, messages from django.contrib.admin import SimpleListFilter from django.shortcuts import redirect from django.utils.html import format_html +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django.urls import reverse from unfold.admin import ModelAdmin @@ -141,8 +142,9 @@ class DirectoryAdmin(ModelAdmin, ImportExportModelAdmin): 'fields': ('directory_type', 'parent', 'code', 'name', 'description', 'is_active') }), (_('Должности и локации'), { - 'fields': ('location_types', 'ad_group_dn'), - 'description': 'location_types — для должностей: в каких локациях доступна. ad_group_dn — для отделов.', + 'fields': ('location_types', 'ad_group_dn', 'ad_ou_dn'), + 'description': 'location_types — для должностей: в каких локациях доступна. ' + 'ad_group_dn, ad_ou_dn — для отделов.', 'classes': ('collapse',) }), (_('Дополнительные данные'), { @@ -197,13 +199,13 @@ class DirectoryAdmin(ModelAdmin, ImportExportModelAdmin): location_types = obj.location_types.all() if not location_types.exists(): - return format_html('Все типы') + return mark_safe('Все типы') types_html = ', '.join([ f'{lt.name}' for lt in location_types ]) - return format_html(types_html) + return mark_safe(types_html) location_types_display.short_description = _('Типы локаций') def children_count_display(self, obj): @@ -236,7 +238,6 @@ class ConnectorConfigAdmin(ModelAdmin): }), (_('Дополнительные параметры'), { 'fields': ('extra_config',), - 'classes': ('collapse',), }), ) diff --git a/core/models/directories.py b/core/models/directories.py index 1b785da..24436a9 100644 --- a/core/models/directories.py +++ b/core/models/directories.py @@ -1,3 +1,5 @@ +import functools + from django.db import models from treenode.models import TreeNodeModel from core.models import BaseModel @@ -6,6 +8,20 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ +def _resolve_directory_default(type_code, code): + return Directory.objects.filter( + directory_type__code=type_code, code=code, + ).values_list('id', flat=True).first() + + +def directory_default(type_code, code): + """Ледачий default= для ForeignKey('core.Directory') на місці колишнього + choices=CharField: резолвиться при збереженні, а не при імпорті моделі + (щоб не залежати від порядку застосування seed-міграцій). functools.partial, + а не замикання, — щоб Django міг серіалізувати default у міграціях.""" + return functools.partial(_resolve_directory_default, type_code, code) + + class DirectoryType(BaseModel): code = models.CharField(max_length=50, unique=True, verbose_name=_('Код типа')) name = models.CharField(max_length=100, verbose_name=_('Название типа')) @@ -123,6 +139,14 @@ class Directory(TreeNodeModel, BaseModel): blank=True, help_text='Distinguished Name группы в Active Directory (например: CN=IT_Department,OU=Groups,DC=company,DC=local). Только для отделов.' ) + ad_ou_dn = models.CharField( + 'AD OU DN', + max_length=500, + blank=True, + help_text='Distinguished Name организационного юнита (OU) в Active Directory, куда переносятся ' + 'учётные записи сотрудников этого отдела (например: OU=IT,OU=Users,DC=company,DC=local). ' + 'Только для отделов. Если не задано — используется корневая OU коннектора AD.' + ) objects = DirectoryManager() diff --git a/templates/pages/ad_import.html b/templates/pages/ad_import.html new file mode 100644 index 0000000..0667a5e --- /dev/null +++ b/templates/pages/ad_import.html @@ -0,0 +1,125 @@ +
Облікові записи AD, яким ще не відповідає жоден співробітник у системі
+Зв'язок офісних співробітників з обліковими записами домену
@@ -17,6 +17,19 @@{{ unmatched_ad_users|length }} записів у домені не зв'язані з жодним співробітником
+{{ unmatched_ad_users_page.paginator.count }} записів у домені не зв'язані з жодним співробітником
| {{ ad_user.sAMAccountName|default:"—" }} | {{ ad_user.displayName|default:ad_user.cn|default:"—" }} | @@ -182,6 +196,7 @@
Управління OU та їх прив'язкою до відділів (Directory.ad_ou_dn)
+{{ ous_page.paginator.count }} організаційних юнітів
+| OU | +Відділ | +Перейменувати | +Дії | +
|---|---|---|---|
|
+ {{ ou.name }}
+
+ |
+ + {% if ou.department %} + {{ ou.department }} + {% else %} + — + {% endif %} + | ++ + | ++ + | +
| OU не знайдено | +|||
+ Щоб прив'язати OU до відділу, вкажіть її DN у полі «OU в Active Directory» картки відділу (Довідники → Відділи). + При синхронізації співробітник переноситься в OU свого відділу автоматично. +
+ +