From 456af4a836fd60723787405c1efacb2cc30c01f2 Mon Sep 17 00:00:00 2001 From: lokses Date: Mon, 12 Oct 2026 13:58:36 +0300 Subject: [PATCH] =?UTF-8?q?ad/mattermost:=20=D1=81=D0=BE=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=20OU?= =?UTF-8?q?=20=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B5,=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D1=8B=20=D0=BA=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0=D0=BC=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0=D0=BC=20=D0=BF=D0=BE=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B4=D0=B5=D0=BB=D0=B0=D0=BC=20=D0=B8=20=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D0=B0=D0=BC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- acore/settings/base.py | 27 ++ apps/ad_connector/ad_service.py | 23 +- apps/ad_connector/admin.py | 28 +- apps/ad_connector/forms.py | 52 +++ apps/ad_connector/tests.py | 52 ++- apps/ad_connector/views.py | 288 +++++++++------ apps/mattermost_connector/admin.py | 42 ++- .../mattermost_service.py | 7 + .../migrations/0007_sync_filter_by_group.py | 52 +++ apps/mattermost_connector/models.py | 26 ++ .../services/sync_service.py | 349 ++++++++++-------- apps/mattermost_connector/signals.py | 39 +- apps/mattermost_connector/tests.py | 122 ++++++ core/admin.py | 75 +++- core/portal_sections.py | 5 +- core/tests.py | 7 +- core/tests_portal_access.py | 2 - templates/ad_connector/_tabs.html | 12 + templates/ad_connector/import_employees.html | 77 ++++ templates/ad_connector/match_employees.html | 143 +++++++ templates/ad_connector/ou_manage.html | 92 +++++ templates/index.html | 20 +- 22 files changed, 1209 insertions(+), 331 deletions(-) create mode 100644 apps/ad_connector/forms.py create mode 100644 apps/mattermost_connector/migrations/0007_sync_filter_by_group.py create mode 100644 templates/ad_connector/_tabs.html create mode 100644 templates/ad_connector/import_employees.html create mode 100644 templates/ad_connector/match_employees.html create mode 100644 templates/ad_connector/ou_manage.html diff --git a/acore/settings/base.py b/acore/settings/base.py index 0b7b89c..9144c6c 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -403,6 +403,33 @@ UNFOLD = { }, ], }, + { + "title": "Active Directory", + "separator": True, + "collapsible": True, + "items": [ + { + "title": "Сопоставление пользователей", + "icon": "link", + "link": reverse_lazy("admin:ad_connector_match_employees"), + }, + { + "title": "Дерево OU / групп", + "icon": "account_tree", + "link": reverse_lazy("admin:ad_connector_ou_manage"), + }, + { + "title": "Импорт сотрудников из AD", + "icon": "download", + "link": reverse_lazy("admin:ad_connector_import_employees"), + }, + { + "title": "Журнал синхронизации AD", + "icon": "history", + "link": reverse_lazy("admin:ad_connector_adsynclog_changelist"), + }, + ], + }, { "title": "IT-отдел", "separator": True, diff --git a/apps/ad_connector/ad_service.py b/apps/ad_connector/ad_service.py index e82917e..69323fa 100644 --- a/apps/ad_connector/ad_service.py +++ b/apps/ad_connector/ad_service.py @@ -47,6 +47,7 @@ class ADService: self.base_dn = extra.get('base_dn', '') self.users_ou = extra.get('users_ou') or self.base_dn self.domain = extra.get('domain', '') + # Как в OSC: пользователи ищутся в users_ou; каталог целиком — base_dn. if not self.base_dn: raise ADConfigError( 'В extra_config коннектора AD не задан base_dn. ' @@ -126,20 +127,23 @@ class ADService: escaped = escape_filter_chars(term) ldap_filter = ( f'(&(objectClass=user)(!(objectClass=computer))(|' - f'(sAMAccountName=*{escaped}*)(cn=*{escaped}*)(mail=*{escaped}*)))' + f'(sAMAccountName=*{escaped}*)(cn=*{escaped}*)' + f'(givenName=*{escaped}*)(sn=*{escaped}*)(mail=*{escaped}*)))' ) try: - self.conn.search(self.base_dn, ldap_filter, attributes=self.USER_ATTRIBUTES) + self.conn.search( + self.users_ou or 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]: - """Список всех учётных записей пользователей в каталоге""" + """Список учётных записей в поддереве users_ou (как AD_USERS_OU в OSC).""" try: self.conn.search( - self.base_dn, + self.users_ou or self.base_dn, '(&(objectClass=user)(!(objectClass=computer)))', attributes=self.USER_ATTRIBUTES, ) @@ -201,6 +205,17 @@ class ADService: logger.error(f"AD: ошибка удаления OU {dn} — {type(e).__name__}: {e}") raise + def move_ou(self, dn: str, new_parent_dn: str) -> str: + """Перенос OU (и поддерева) под новую родительскую OU. Возвращает новый DN.""" + relative_dn = safe_rdn(dn)[0] + logger.info(f"AD: перенос OU {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: ошибка переноса OU {dn} — {type(e).__name__}: {e}") + raise + return f'{relative_dn},{new_parent_dn}' + # --- Создание и обновление --- def create_user(self, dn: str, attributes: Dict) -> None: diff --git a/apps/ad_connector/admin.py b/apps/ad_connector/admin.py index 580c0ba..05990a3 100644 --- a/apps/ad_connector/admin.py +++ b/apps/ad_connector/admin.py @@ -1,7 +1,9 @@ from django.contrib import admin +from django.urls import path from unfold.admin import ModelAdmin from .models import ADSyncLog +from .views import ImportEmployeesView, MatchEmployeesView, OUManageView @admin.register(ADSyncLog) @@ -10,7 +12,6 @@ class ADSyncLogAdmin(ModelAdmin): list_filter = ('action', 'status') search_fields = ('employee__username', 'employee__last_name', 'employee__first_name', 'message') date_hierarchy = 'created_at' - # Журнал только для просмотра — без массового удаления из changelist. actions = None def has_add_permission(self, request): @@ -20,9 +21,24 @@ class ADSyncLogAdmin(ModelAdmin): return False def has_delete_permission(self, request, obj=None): - # Нельзя False для всех: Django get_deleted_objects() зовёт этот метод - # при удалении Employee с CASCADE на ADSyncLog и блокирует удаление - # сообщением «нет прав на Запись синхронизации AD». - # Разрешаем CASCADE тем, кто может удалять сотрудников; UI журнала - # без delete_selected (actions = None). return request.user.has_perm('employees.delete_employee') + + def get_urls(self): + # Кастомные пути ПЕРЕД catch-all '/'. + return [ + path( + 'ous/', + self.admin_site.admin_view(OUManageView.as_view(model_admin=self)), + name='ad_connector_ou_manage', + ), + path( + 'import/', + self.admin_site.admin_view(ImportEmployeesView.as_view(model_admin=self)), + name='ad_connector_import_employees', + ), + path( + 'match/', + self.admin_site.admin_view(MatchEmployeesView.as_view(model_admin=self)), + name='ad_connector_match_employees', + ), + ] + super().get_urls() diff --git a/apps/ad_connector/forms.py b/apps/ad_connector/forms.py new file mode 100644 index 0000000..52e1ce2 --- /dev/null +++ b/apps/ad_connector/forms.py @@ -0,0 +1,52 @@ +from django import forms + +INPUT_CLASS = ( + 'border border-base-200 bg-white font-medium placeholder-base-400 rounded-default ' + 'shadow-xs text-font-default-light text-sm focus:outline-2 focus:-outline-offset-2 ' + 'focus:outline-primary-600 dark:bg-base-900 dark:border-base-700 dark:text-font-default-dark ' + 'dark:scheme-dark px-3 py-2 w-full max-w-2xl' +) +SELECT_CLASS = INPUT_CLASS + ' appearance-none' + + +def _forbidden_ou_chars(name: str) -> None: + if any(ch in name for ch in ',+"\\<>;='): + raise forms.ValidationError('Название не должно содержать символы , + " \\ < > ; =') + + +class CreateOUForm(forms.Form): + name = forms.CharField( + label='Название OU', max_length=64, + widget=forms.TextInput(attrs={'class': INPUT_CLASS}), + ) + parent_dn = forms.ChoiceField( + label='Родительская OU', + widget=forms.Select(attrs={'class': SELECT_CLASS}), + ) + + def __init__(self, *args, ou_choices=(), root_ou='', **kwargs): + super().__init__(*args, **kwargs) + root = root_ou or '' + choices = [] + if root: + choices.append((root, f'{root} (корень)')) + choices += [(ou['dn'], ou['dn']) for ou in ou_choices if ou['dn'] != root] + self.fields['parent_dn'].choices = choices or [('', '—')] + + def clean_name(self): + name = self.cleaned_data['name'].strip() + _forbidden_ou_chars(name) + return name + + +class RenameOUForm(forms.Form): + dn = forms.CharField(widget=forms.HiddenInput) + new_name = forms.CharField( + label='Новое название', max_length=64, + widget=forms.TextInput(attrs={'class': INPUT_CLASS + ' max-w-xs'}), + ) + + def clean_new_name(self): + name = self.cleaned_data['new_name'].strip() + _forbidden_ou_chars(name) + return name diff --git a/apps/ad_connector/tests.py b/apps/ad_connector/tests.py index b210418..7707618 100644 --- a/apps/ad_connector/tests.py +++ b/apps/ad_connector/tests.py @@ -7,6 +7,7 @@ from unittest import mock from django.contrib.auth import get_user_model from django.test import TestCase +from django.urls import reverse from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server from apps.ad_connector import views @@ -132,6 +133,17 @@ class ADServiceMockTests(TestCase): self.assertEqual(len(users), 1) self.assertEqual(users[0]['sAMAccountName'], 'ivanov.i') + def test_move_ou(self): + self.svc.create_ou('IT', USERS_OU) + self.svc.create_ou('HR', USERS_OU) + src = f'OU=IT,{USERS_OU}' + dst_parent = f'OU=HR,{USERS_OU}' + new_dn = self.svc.move_ou(src, dst_parent) + self.assertEqual(new_dn, f'OU=IT,{dst_parent}') + ous = {ou['dn'] for ou in self.svc.list_ous()} + self.assertIn(new_dn, ous) + self.assertNotIn(src, ous) + class SyncEmployeeToADTests(TestCase): def setUp(self): @@ -313,6 +325,11 @@ class ADSignalTests(TestCase): self.assertEqual(sync_pw.call_args.args[1], 'NewSecret1') +MATCH_URL = reverse('admin:ad_connector_match_employees') +OU_URL = reverse('admin:ad_connector_ou_manage') +IMPORT_URL = reverse('admin:ad_connector_import_employees') + + class ADMappingViewTests(TestCase): def setUp(self): from django.contrib.auth.models import Group @@ -323,20 +340,29 @@ class ADMappingViewTests(TestCase): patcher.start() self.addCleanup(patcher.stop) - def test_requires_ad_mapping_perm(self): + def test_portal_urls_redirect_to_admin(self): + login_2fa(self.client, self.staff) + for src, dst in ( + ('/ad-mapping/', MATCH_URL), + ('/ad-ou-manage/', OU_URL), + ('/ad-import/', IMPORT_URL), + ): + response = self.client.get(src) + self.assertEqual(response.status_code, 302, src) + self.assertEqual(response['Location'], dst, src) + + def test_non_staff_cannot_open_admin_match(self): regular = make_employee() login_2fa(self.client, regular) - response = self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true') + response = self.client.get(MATCH_URL) self.assertEqual(response.status_code, 302) - self.assertEqual(response['Location'], '/dashboard/') - self.assertEqual(response['HX-Redirect'], '/dashboard/') def test_page_renders_with_ad_users(self): self.svc.create_user(f'CN=Вільний Аккаунт,{USERS_OU}', { 'sAMAccountName': 'free.account', 'displayName': 'Вільний Аккаунт', }) login_2fa(self.client, self.staff) - response = self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true') + response = self.client.get(MATCH_URL) self.assertEqual(response.status_code, 200) self.assertContains(response, 'free.account') @@ -346,10 +372,10 @@ class ADMappingViewTests(TestCase): self.svc.create_user(dn, {'sAMAccountName': 'free.account'}) login_2fa(self.client, self.staff) with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: - response = self.client.post('/ad-mapping/', { + response = self.client.post(MATCH_URL, { 'action': 'link', 'employee_id': employee.pk, 'dn': dn, - }, HTTP_HX_REQUEST='true') - self.assertEqual(response.status_code, 200) + }) + self.assertEqual(response.status_code, 302) employee.refresh_from_db() self.assertEqual(employee.ad_user_dn, dn) delay.assert_called_once_with(employee.pk) @@ -357,10 +383,10 @@ class ADMappingViewTests(TestCase): def test_unlink_action_clears_dn(self): employee = make_employee(ad_user_dn=f'CN=X,{USERS_OU}', ad_sync_status='synced') login_2fa(self.client, self.staff) - response = self.client.post('/ad-mapping/', { + response = self.client.post(MATCH_URL, { 'action': 'unlink', 'employee_id': employee.pk, - }, HTTP_HX_REQUEST='true') - self.assertEqual(response.status_code, 200) + }) + self.assertEqual(response.status_code, 302) employee.refresh_from_db() self.assertEqual(employee.ad_user_dn, '') self.assertEqual(employee.ad_sync_status, 'not_synced') @@ -368,7 +394,7 @@ class ADMappingViewTests(TestCase): def test_action_on_non_office_employee_returns_404(self): employee = make_employee(user_location_type='pharmacy') login_2fa(self.client, self.staff) - response = self.client.post('/ad-mapping/', { + response = self.client.post(MATCH_URL, { 'action': 'unlink', 'employee_id': employee.pk, - }, HTTP_HX_REQUEST='true') + }) self.assertEqual(response.status_code, 404) diff --git a/apps/ad_connector/views.py b/apps/ad_connector/views.py index 5c390f2..a1be91a 100644 --- a/apps/ad_connector/views.py +++ b/apps/ad_connector/views.py @@ -1,22 +1,41 @@ import logging +from django.contrib import messages from django.contrib.auth import get_user_model -from django.contrib.auth.decorators import login_required -from django.shortcuts import get_object_or_404 +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse from django.utils import timezone +from django.views.generic import TemplateView +from unfold.views import UnfoldModelAdminViewMixin from apps.ad_connector.ad_service import ADConfigError, ADService +from apps.ad_connector.forms import CreateOUForm, RenameOUForm from apps.ad_connector.services.sync_service import relocate_ou_references from apps.locations.models import Location from core.models import Directory -from core.portal_access import section_required -from core.utils.htmx import render_page -from core.utils.pagination import paginate Employee = get_user_model() logger = logging.getLogger(__name__) +def _redirect_staff(name): + return redirect(reverse(name)) + + +def ad_mapping_view(request): + """Старый URL портала → админка.""" + return _redirect_staff('admin:ad_connector_match_employees') + + +def ad_ou_manage_view(request): + return _redirect_staff('admin:ad_connector_ou_manage') + + +def ad_import_view(request): + return _redirect_staff('admin:ad_connector_import_employees') + + def _handle_action(request): """Обработка действий сопоставления: связать / отвязать / создать""" action = request.POST.get('action') @@ -26,6 +45,7 @@ def _handle_action(request): if action == 'link': dn = request.POST.get('dn', '').strip() + username = request.POST.get('username', '').strip() if dn: Employee.objects.filter(pk=employee.pk).update( ad_user_dn=dn, @@ -33,16 +53,17 @@ def _handle_action(request): ad_sync_error='', ad_sync_enabled=True, ) - # Выравниваем атрибуты привязанной учётной записи from apps.ad_connector.tasks import sync_employee_to_ad_task sync_employee_to_ad_task.delay(employee.pk) logger.info(f"AD-сопоставление: {employee.email} связан с {dn}") + messages.success(request, f'{employee} сопоставлен с {username or dn}') elif action == 'unlink': Employee.objects.filter(pk=employee.pk).update( ad_user_dn='', ad_sync_status='not_synced', ad_sync_error='', ) logger.info(f"AD-сопоставление: {employee.email} отвязан от AD") + messages.success(request, f'{employee} отвязан от учётной записи AD') elif action == 'create': Employee.objects.filter(pk=employee.pk).update(ad_sync_enabled=True) @@ -56,6 +77,7 @@ def _handle_action(request): details=f'email={employee.email}', ) logger.info(f"AD-сопоставление: поставлена задача создания учётки для {employee.email}") + messages.success(request, f'Поставлена задача создания учётки AD для {employee}') def _find_suggestion(employee, ad_users, linked_dns): @@ -70,25 +92,18 @@ def _find_suggestion(employee, ad_users, linked_dns): if ad_user['dn'] in linked_dns: continue if (ad_user.get('sAMAccountName') or '').lower() == (employee.username or '').lower(): - return ad_user, 'логін' + return ad_user, 'логин' for ad_user in ad_users: if ad_user['dn'] in linked_dns: continue display = ad_user.get('displayName') or ad_user.get('cn') or '' if full_name and display.lower() == full_name.lower(): - return ad_user, 'ПІБ' + return ad_user, 'ФИО' return None, None -@login_required -@section_required('ad_mapping') -def ad_mapping_view(request): - """Страница сопоставления офисных сотрудников с учётными записями AD""" - if request.method == 'POST': - _handle_action(request) - +def _mapping_context(request): query = request.GET.get('q', '').strip() - employees = list( Employee.objects.filter(user_location_type='office', is_active=True) .select_related('post', 'department') @@ -107,7 +122,7 @@ def ad_mapping_view(request): except ADConfigError as e: ad_error = str(e) except Exception as e: - ad_error = f'Помилка з’єднання з Active Directory: {e}' + ad_error = f'Ошибка соединения с Active Directory: {e}' logger.error(f"AD-сопоставление: {ad_error}") ad_by_dn = {u['dn']: u for u in ad_users} @@ -126,26 +141,19 @@ def ad_mapping_view(request): ) unmatched_ad_users = [u for u in ad_users if u['dn'] not in linked_dns] - - context = { - 'employees': paginate(request, employees, page_param='emp_page', size_param='emp_per_page'), + return { + 'employees': employees, '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, } - return render_page(request, 'ad_mapping.html', context) def _handle_ou_action(request): - """Обработка действий управления OU: создание / переименование / удаление. - Возвращает (error, notice).""" action = request.POST.get('action') try: service = ADService() @@ -154,50 +162,66 @@ def _handle_ou_action(request): 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}' + form = CreateOUForm( + request.POST, + ou_choices=_safe_list_ous()[0], + root_ou=service.users_ou, + ) + if not form.is_valid(): + return 'Не удалось создать OU: проверьте поля формы', None + dn = service.create_ou(form.cleaned_data['name'], form.cleaned_data['parent_dn']) + 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}' + form = RenameOUForm(request.POST) + if not form.is_valid(): + return 'Не удалось переименовать OU: проверьте поля формы', None + new_dn = service.rename_ou(form.cleaned_data['dn'], form.cleaned_data['new_name']) + relocate_ou_references(form.cleaned_data['dn'], new_dn) + logger.info(f"AD OU: переименовано {form.cleaned_data['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 + return f'Нельзя удалить — OU используется в отделах: {names}', None service.delete_ou(dn) - logger.info(f"AD OU: видалено {dn}") - return None, f'OU видалено: {dn}' + logger.info(f"AD OU: удалено {dn}") + return None, f'OU удалена: {dn}' - return 'Невідома дія', None + elif action == 'move': + dn = request.POST.get('dn', '').strip() + new_parent = request.POST.get('new_parent_dn', '').strip() + if not dn or not new_parent: + return 'Укажите OU и нового родителя', None + new_dn = service.move_ou(dn, new_parent) + relocate_ou_references(dn, new_dn) + logger.info(f"AD OU: перенесено {dn} -> {new_dn}") + return None, f'OU перенесена: {new_dn}' + + return 'Неизвестное действие', None except Exception as e: - logger.error(f"AD OU: помилка дії {action} — {e}") - return f'Помилка AD: {e}', None + logger.error(f"AD OU: ошибка действия {action} — {e}") + return f'Ошибка AD: {e}', None finally: service.unbind() -@login_required -@section_required('ad_mapping') -def ad_ou_manage_view(request): - """Управление организационными юнитами (OU) в Active Directory и их привязкой к отделам""" - error, notice = None, None - if request.method == 'POST': - error, notice = _handle_ou_action(request) +def _safe_list_ous(): + try: + service = ADService() + try: + return service.list_ous(), service.users_ou + finally: + service.unbind() + except Exception: + return [], '' + +def _ou_context(): ous, ad_error, root_ou = [], None, '' try: service = ADService() @@ -209,27 +233,23 @@ def ad_ou_manage_view(request): except ADConfigError as e: ad_error = str(e) except Exception as e: - ad_error = f'Помилка з’єднання з Active Directory: {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']) + ou['rename_form'] = RenameOUForm(initial={'dn': ou['dn'], 'new_name': ou['name']}) - context = { - # Повний список — для випадаючого списку "Батьківська OU" у формі створення + return { '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, + 'ad_error': ad_error, + 'create_form': CreateOUForm(ou_choices=ous, root_ou=root_ou), } - 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() @@ -240,7 +260,7 @@ def _import_candidates(): except ADConfigError as e: return [], str(e) except Exception as e: - return [], f'Помилка з’єднання з Active Directory: {e}' + return [], f'Ошибка соединения с Active Directory: {e}' candidates = [] for u in ad_users: @@ -261,7 +281,7 @@ def _import_candidates(): def _import_context(request): candidates, ad_error = _import_candidates() return { - 'candidates': paginate(request, candidates, page_param='candidates_page', size_param='candidates_per_page'), + 'candidates': candidates, 'candidates_total': len(candidates), 'ad_error': ad_error, 'departments': Directory.objects.get_by_type('department'), @@ -271,52 +291,102 @@ def _import_context(request): } -@login_required -@section_required('ad_mapping') -def ad_import_view(request): - """Импорт учётных записей AD, отсутствующих среди сотрудников, как новых офисных сотрудников. - Ничего не создаётся, пока администратор явно не отметит и не подтвердит конкретные записи.""" +def _run_import(request): + candidates, _ = _import_candidates() + hire_date = request.POST.get('hire_date') or timezone.now().date().isoformat() + location_id = request.POST.get('user_location') or None 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 + 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_user_dn=candidate.get('dn') or '', - ad_sync_status='synced' if candidate.get('dn') else 'not_synced', - ad_sync_enabled=False, + 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_user_dn=candidate.get('dn') or '', + ad_sync_status='synced' if candidate.get('dn') else 'not_synced', + ad_sync_enabled=False, + ) + employee.set_unusable_password() + try: + employee.full_clean() + employee.save() + imported.append(username) + from core.security import log_security_event + log_security_event( + 'employee.created_via_ad_import', + actor=request.user, + target=str(employee.pk), + details=f'username={username} dn={candidate.get("dn", "")}', ) - employee.set_unusable_password() - try: - employee.full_clean() - employee.save() - imported.append(username) - from core.security import log_security_event - log_security_event( - 'employee.created_via_ad_import', - actor=request.user, - target=str(employee.pk), - details=f'username={username} dn={candidate.get("dn", "")}', - ) - logger.info(f"AD-импорт: создан сотрудник {username}") - except Exception as exc: - errors.append(f'{username}: {exc}') + logger.info(f"AD-импорт: создан сотрудник {username}") + except Exception as exc: + errors.append(f'{username}: {exc}') + return imported, errors - context = _import_context(request) - context.update({'imported': imported, 'errors': errors}) - return render_page(request, 'ad_import.html', context) + +class MatchEmployeesView(UnfoldModelAdminViewMixin, TemplateView): + title = 'Сопоставление сотрудников с AD' + permission_required = () + template_name = 'ad_connector/match_employees.html' + + def post(self, request, *args, **kwargs): + try: + _handle_action(request) + except Http404: + raise + except Exception as exc: + messages.error(request, str(exc)) + return redirect(reverse('admin:ad_connector_match_employees')) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update(_mapping_context(self.request)) + return context + + +class OUManageView(UnfoldModelAdminViewMixin, TemplateView): + title = 'Управление деревом OU в Active Directory' + permission_required = () + template_name = 'ad_connector/ou_manage.html' + + def post(self, request, *args, **kwargs): + error, notice = _handle_ou_action(request) + if notice: + messages.success(request, notice) + if error: + messages.error(request, error) + return redirect(reverse('admin:ad_connector_ou_manage')) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update(_ou_context()) + return context + + +class ImportEmployeesView(UnfoldModelAdminViewMixin, TemplateView): + title = 'Импорт сотрудников из AD' + permission_required = () + template_name = 'ad_connector/import_employees.html' + + def post(self, request, *args, **kwargs): + imported, errors = _run_import(request) + if imported: + messages.success(request, f'Импортированы: {", ".join(imported)}') + for err in errors: + messages.error(request, err) + return redirect(reverse('admin:ad_connector_import_employees')) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update(_import_context(self.request)) + return context diff --git a/apps/mattermost_connector/admin.py b/apps/mattermost_connector/admin.py index 40879fa..1ca1a81 100755 --- a/apps/mattermost_connector/admin.py +++ b/apps/mattermost_connector/admin.py @@ -134,9 +134,9 @@ class TeamSyncSettingsAdmin(ModelAdmin): 'filter_by_department', 'departments_count' ) - list_filter = ('is_active', 'is_default', 'auto_add_users', 'filter_by_department') + list_filter = ('is_active', 'is_default', 'auto_add_users', 'filter_by_department', 'filter_by_group') search_fields = ('team__display_name', 'team__name', 'notes') - filter_horizontal = ('departments',) + filter_horizontal = ('departments', 'groups') fieldsets = ( ('Команда', { @@ -145,13 +145,16 @@ class TeamSyncSettingsAdmin(ModelAdmin): ('Основные настройки', { 'fields': ('is_active', 'is_default', 'priority', 'auto_add_users') }), - ('Фильтрация по отделам', { - 'fields': ('filter_by_department', 'departments'), - 'description': 'Если включено, в команду будут добавлены только пользователи из выбранных отделов' - }), - ('Фильтрация по типу локации', { - 'fields': ('filter_by_location_type', 'location_types'), - 'description': 'Если включено, в команду будут добавлены только сотрудники с выбранным типом локации (Аптека/Склад/Офис). При совместном включении с фильтром по отделам условия объединяются по И.' + ('Кому выдавать доступ', { + 'fields': ( + 'filter_by_department', 'departments', + 'filter_by_group', 'groups', + 'filter_by_location_type', 'location_types', + ), + 'description': ( + 'Включённый фильтр с пустым списком никого не пускает. ' + 'Несколько фильтров работают по И. Без фильтров — все сотрудники с синхронизацией MM.' + ), }), ('Дополнительно', { 'fields': ('notes',), @@ -273,9 +276,9 @@ class ChannelSyncSettingsAdmin(ModelAdmin): 'filter_by_department', 'departments_count' ) - list_filter = ('is_active', 'auto_add_users', 'filter_by_department', 'channel__team') + list_filter = ('is_active', 'auto_add_users', 'filter_by_department', 'filter_by_group', 'channel__team') search_fields = ('channel__display_name', 'channel__name', 'notes') - filter_horizontal = ('departments',) + filter_horizontal = ('departments', 'groups') fieldsets = ( ('Канал', { @@ -284,13 +287,16 @@ class ChannelSyncSettingsAdmin(ModelAdmin): ('Основные настройки', { 'fields': ('is_active', 'priority', 'auto_add_users') }), - ('Фильтрация по отделам', { - 'fields': ('filter_by_department', 'departments'), - 'description': 'Если включено, в канал будут добавлены только пользователи из выбранных отделов' - }), - ('Фильтрация по типу локации', { - 'fields': ('filter_by_location_type', 'location_types'), - 'description': 'Если включено, в канал будут добавлены только сотрудники с выбранным типом локации (Аптека/Склад/Офис). При совместном включении с фильтром по отделам условия объединяются по И.' + ('Кому выдавать доступ', { + 'fields': ( + 'filter_by_department', 'departments', + 'filter_by_group', 'groups', + 'filter_by_location_type', 'location_types', + ), + 'description': ( + 'Включённый фильтр с пустым списком никого не пускает. ' + 'Несколько фильтров работают по И. Сотрудника сначала добавляем в команду канала.' + ), }), ('Дополнительно', { 'fields': ('notes',), diff --git a/apps/mattermost_connector/mattermost_service.py b/apps/mattermost_connector/mattermost_service.py index 2aba159..3a6561c 100755 --- a/apps/mattermost_connector/mattermost_service.py +++ b/apps/mattermost_connector/mattermost_service.py @@ -145,6 +145,13 @@ class MattermostService: self.driver.channels.add_channel_member, channel_id, user_id=user_id, ) + def remove_user_from_channel(self, channel_id: str, user_id: str) -> Dict: + """Удаление пользователя из канала""" + return self._call( + f'DELETE channels/{channel_id}/members/{user_id}', + self.driver.channels.remove_channel_member, channel_id, user_id, + ) + def create_user_access_token(self, user_id: str, description: str) -> Dict: """Создание Personal Access Token для пользователя""" return self._call( diff --git a/apps/mattermost_connector/migrations/0007_sync_filter_by_group.py b/apps/mattermost_connector/migrations/0007_sync_filter_by_group.py new file mode 100644 index 0000000..aaa3048 --- /dev/null +++ b/apps/mattermost_connector/migrations/0007_sync_filter_by_group.py @@ -0,0 +1,52 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('mattermost_connector', '0006_mattermostbot_capabilities'), + ] + + operations = [ + migrations.AddField( + model_name='teamsyncsettings', + name='filter_by_group', + field=models.BooleanField( + default=False, + help_text='Добавлять только пользователей из выбранных групп портала (IT, HR, …)', + verbose_name='Фильтровать по группам доступа', + ), + ), + migrations.AddField( + model_name='teamsyncsettings', + name='groups', + field=models.ManyToManyField( + blank=True, + help_text='Группы Django, участники которых получат доступ к команде', + related_name='mm_team_sync_settings', + to='auth.group', + verbose_name='Группы доступа', + ), + ), + migrations.AddField( + model_name='channelsyncsettings', + name='filter_by_group', + field=models.BooleanField( + default=False, + help_text='Добавлять только пользователей из выбранных групп портала (IT, HR, …)', + verbose_name='Фильтровать по группам доступа', + ), + ), + migrations.AddField( + model_name='channelsyncsettings', + name='groups', + field=models.ManyToManyField( + blank=True, + help_text='Группы Django, участники которых получат доступ к каналу', + related_name='mm_channel_sync_settings', + to='auth.group', + verbose_name='Группы доступа', + ), + ), + ] diff --git a/apps/mattermost_connector/models.py b/apps/mattermost_connector/models.py index 632188c..ad06dd7 100755 --- a/apps/mattermost_connector/models.py +++ b/apps/mattermost_connector/models.py @@ -233,6 +233,19 @@ class TeamSyncSettings(models.Model): help_text='Пользователи с этими типами локации будут добавлены в команду' ) + filter_by_group = models.BooleanField( + default=False, + verbose_name='Фильтровать по группам доступа', + help_text='Добавлять только пользователей из выбранных групп портала (IT, HR, …)', + ) + groups = models.ManyToManyField( + 'auth.Group', + blank=True, + related_name='mm_team_sync_settings', + verbose_name='Группы доступа', + help_text='Группы Django, участники которых получат доступ к команде', + ) + is_active = models.BooleanField( default=True, verbose_name='Активна', @@ -314,6 +327,19 @@ class ChannelSyncSettings(models.Model): help_text='Пользователи с этими типами локации будут добавлены в канал' ) + filter_by_group = models.BooleanField( + default=False, + verbose_name='Фильтровать по группам доступа', + help_text='Добавлять только пользователей из выбранных групп портала (IT, HR, …)', + ) + groups = models.ManyToManyField( + 'auth.Group', + blank=True, + related_name='mm_channel_sync_settings', + verbose_name='Группы доступа', + help_text='Группы Django, участники которых получат доступ к каналу', + ) + is_active = models.BooleanField( default=True, verbose_name='Активна', diff --git a/apps/mattermost_connector/services/sync_service.py b/apps/mattermost_connector/services/sync_service.py index 1760d44..00ab1c7 100755 --- a/apps/mattermost_connector/services/sync_service.py +++ b/apps/mattermost_connector/services/sync_service.py @@ -404,188 +404,225 @@ def generate_random_password(length: int = 16) -> str: return password -def add_user_to_teams(employee: Employee) -> bool: +def employee_matches_sync_filters(employee: Employee, setting) -> bool: + """Сотрудник подходит под правила выдачи команды/канала. + + Включённый фильтр с пустым списком никого не пускает (не «всех»). + Несколько фильтров комбинируются по И. Без фильтров и с auto_add — + правило «для всех» (команда/канал по умолчанию). """ - Добавление пользователя в команды Mattermost согласно настройкам синхронизации + if not getattr(setting, 'is_active', True) or not setting.auto_add_users: + return False - Args: - employee: Объект сотрудника + if setting.filter_by_department: + dept_ids = set(setting.departments.values_list('id', flat=True)) + if not dept_ids or employee.department_id not in dept_ids: + return False - Returns: - bool: True если успешно добавлен хотя бы в одну команду - """ - from apps.mattermost_connector.models import TeamSyncSettings, MattermostTeamMembership, MattermostUser, MattermostSyncLog + if setting.filter_by_location_type: + types = list(setting.location_types or []) + if not types or employee.user_location_type not in types: + return False + if getattr(setting, 'filter_by_group', False): + group_ids = set(setting.groups.values_list('id', flat=True)) + if not group_ids: + return False + if not employee.groups.filter(id__in=group_ids).exists(): + return False + + return True + + +def setting_is_targeted(setting) -> bool: + """Правило с фильтрами: участников, которые больше не подходят, нужно снимать.""" + return bool( + setting.filter_by_department + or setting.filter_by_location_type + or getattr(setting, 'filter_by_group', False) + ) + + +def _is_already_member(exc: Exception) -> bool: + msg = str(exc).lower() + return any( + token in msg + for token in ( + 'already a member', + 'already exists', + 'already_exists', + 'user is already', + 'already in team', + 'already in channel', + ) + ) + + +def _ensure_mm_user(employee: Employee): + from apps.mattermost_connector.models import MattermostUser if not employee.mattermost_user_id: - logger.warning(f"Пользователь {employee.email} не имеет mattermost_user_id") + return None + return MattermostUser.objects.filter(mattermost_id=employee.mattermost_user_id).first() + + +def _grant_team(service, employee, mm_user, team) -> bool: + from apps.mattermost_connector.models import MattermostSyncLog, MattermostTeamMembership + try: + service.add_user_to_team(team.team_id, employee.mattermost_user_id) + except Exception as e: + if not _is_already_member(e): + logger.error(f"Ошибка добавления {employee.email} в команду {team.display_name}: {e}") + MattermostSyncLog.objects.create( + employee=employee, action='add_team', status='error', + message=f'{team.display_name}: {e}', + ) + return False + _, created = MattermostTeamMembership.objects.get_or_create(user=mm_user, team=team) + if created: + MattermostSyncLog.objects.create( + employee=employee, action='add_team', status='success', + message=f'Команда: {team.display_name}', + ) + return True + + +def _revoke_team(service, employee, mm_user, team) -> None: + from apps.mattermost_connector.models import ( + MattermostChannelMembership, MattermostSyncLog, MattermostTeamMembership, + ) + try: + service.remove_user_from_team(team.team_id, employee.mattermost_user_id) + except Exception as e: + logger.error(f"Ошибка удаления {employee.email} из команды {team.display_name}: {e}") + MattermostSyncLog.objects.create( + employee=employee, action='add_team', status='error', + message=f'Снять {team.display_name}: {e}', + ) + return + MattermostTeamMembership.objects.filter(user=mm_user, team=team).delete() + MattermostChannelMembership.objects.filter(user=mm_user, channel__team=team).delete() + MattermostSyncLog.objects.create( + employee=employee, action='add_team', status='success', + message=f'Снят с команды: {team.display_name}', + ) + + +def _grant_channel(service, employee, mm_user, channel) -> bool: + from apps.mattermost_connector.models import MattermostChannelMembership, MattermostSyncLog + if not _grant_team(service, employee, mm_user, channel.team): + return False + try: + service.add_user_to_channel(channel.channel_id, employee.mattermost_user_id) + except Exception as e: + if not _is_already_member(e): + logger.error(f"Ошибка добавления {employee.email} в канал {channel.display_name}: {e}") + MattermostSyncLog.objects.create( + employee=employee, action='add_channel', status='error', + message=f'{channel.display_name}: {e}', + ) + return False + _, created = MattermostChannelMembership.objects.get_or_create(user=mm_user, channel=channel) + if created: + MattermostSyncLog.objects.create( + employee=employee, action='add_channel', status='success', + message=f'Канал: {channel.display_name}', + ) + return True + + +def _revoke_channel(service, employee, mm_user, channel) -> None: + from apps.mattermost_connector.models import MattermostChannelMembership, MattermostSyncLog + try: + service.remove_user_from_channel(channel.channel_id, employee.mattermost_user_id) + except Exception as e: + logger.error(f"Ошибка удаления {employee.email} из канала {channel.display_name}: {e}") + MattermostSyncLog.objects.create( + employee=employee, action='add_channel', status='error', + message=f'Снять {channel.display_name}: {e}', + ) + return + MattermostChannelMembership.objects.filter(user=mm_user, channel=channel).delete() + MattermostSyncLog.objects.create( + employee=employee, action='add_channel', status='success', + message=f'Снят с канала: {channel.display_name}', + ) + + +def add_user_to_teams(employee: Employee) -> bool: + """Выдать/снять команды Mattermost по правилам синхронизации.""" + from apps.mattermost_connector.models import ChannelSyncSettings, TeamSyncSettings + + mm_user = _ensure_mm_user(employee) + if not mm_user: + logger.warning(f"Пользователь {employee.email} не имеет Mattermost-профиля") return False service = MattermostService() - added_to_teams = 0 - try: - # Получаем MattermostUser - mattermost_user = MattermostUser.objects.filter(mattermost_id=employee.mattermost_user_id).first() - if not mattermost_user: - logger.warning(f"MattermostUser не найден для {employee.email}") - return False + desired = {} + for setting in TeamSyncSettings.objects.filter(is_active=True).select_related('team').prefetch_related( + 'departments', 'groups', + ): + if employee_matches_sync_filters(employee, setting): + desired[setting.team_id] = setting.team - # Получаем все активные настройки синхронизации, отсортированные по приоритету - team_settings = TeamSyncSettings.objects.filter(is_active=True).select_related('team').prefetch_related('departments') + for setting in ChannelSyncSettings.objects.filter(is_active=True).select_related( + 'channel__team', + ).prefetch_related('departments', 'groups'): + if employee_matches_sync_filters(employee, setting): + desired.setdefault(setting.channel.team_id, setting.channel.team) - for setting in team_settings: - # Проверяем, нужно ли добавлять пользователя в эту команду - should_add = False + ok = True + for team in desired.values(): + if not _grant_team(service, employee, mm_user, team): + ok = False - if setting.auto_add_users: - # Фильтры комбинируются по AND: если включены оба (отдел + тип - # локации), пользователь должен подходить под оба условия. - should_add = True - if setting.filter_by_department: - should_add = should_add and bool( - employee.department_id - and setting.departments.filter(id=employee.department_id).exists() - ) - if setting.filter_by_location_type: - should_add = should_add and ( - employee.user_location_type in (setting.location_types or []) - ) - - if should_add: - # Проверяем, не добавлен ли уже пользователь в эту команду - membership_exists = MattermostTeamMembership.objects.filter( - user=mattermost_user, - team=setting.team - ).exists() - - if not membership_exists: - try: - # Добавляем пользователя в команду - logger.info(f"Добавление {employee.email} в команду {setting.team.display_name}") - service.add_user_to_team(setting.team.team_id, employee.mattermost_user_id) - - # Создаем запись о членстве - MattermostTeamMembership.objects.create( - user=mattermost_user, - team=setting.team - ) - - added_to_teams += 1 - logger.info(f"Пользователь {employee.email} добавлен в команду {setting.team.display_name}") - MattermostSyncLog.objects.create( - employee=employee, action='add_team', status='success', - message=f'Команда: {setting.team.display_name}', - ) - - except Exception as e: - logger.error(f"Ошибка добавления {employee.email} в команду {setting.team.display_name}: {e}") - MattermostSyncLog.objects.create( - employee=employee, action='add_team', status='error', - message=f'{setting.team.display_name}: {e}', - ) - else: - logger.debug(f"Пользователь {employee.email} уже в команде {setting.team.display_name}") - - if added_to_teams > 0: - logger.info(f"Пользователь {employee.email} добавлен в {added_to_teams} команд(у/ы)") - return True - else: - logger.info(f"Пользователь {employee.email} уже добавлен во все необходимые команды") - return True + for setting in TeamSyncSettings.objects.filter(is_active=True).select_related('team'): + if setting.team_id in desired: + continue + if not setting_is_targeted(setting): + continue + _revoke_team(service, employee, mm_user, setting.team) + return ok except Exception as e: - logger.error(f"Ошибка добавления пользователя {employee.email} в команды: {e}") + logger.error(f"Ошибка синхронизации команд для {employee.email}: {e}") return False def add_user_to_channels(employee: Employee) -> bool: - """ - Добавление пользователя в каналы Mattermost согласно настройкам синхронизации + """Выдать/снять каналы Mattermost по правилам. В команду канала добавляем заранее.""" + from apps.mattermost_connector.models import ChannelSyncSettings - Args: - employee: Объект сотрудника - - Returns: - bool: True если успешно добавлен хотя бы в один канал - """ - from apps.mattermost_connector.models import ChannelSyncSettings, MattermostChannelMembership, MattermostUser, MattermostSyncLog - - if not employee.mattermost_user_id: - logger.warning(f"Пользователь {employee.email} не имеет mattermost_user_id") + mm_user = _ensure_mm_user(employee) + if not mm_user: + logger.warning(f"Пользователь {employee.email} не имеет Mattermost-профиля") return False service = MattermostService() - added_to_channels = 0 - try: - # Получаем MattermostUser - mattermost_user = MattermostUser.objects.filter(mattermost_id=employee.mattermost_user_id).first() - if not mattermost_user: - logger.warning(f"MattermostUser не найден для {employee.email}") - return False + desired = {} + settings = ChannelSyncSettings.objects.filter(is_active=True).select_related( + 'channel', 'channel__team', + ).prefetch_related('departments', 'groups') - # Получаем все активные настройки синхронизации каналов, отсортированные по приоритету - channel_settings = ChannelSyncSettings.objects.filter(is_active=True).select_related('channel', 'channel__team').prefetch_related('departments') + for setting in settings: + if employee_matches_sync_filters(employee, setting): + desired[setting.channel_id] = setting.channel - for setting in channel_settings: - # Проверяем, нужно ли добавлять пользователя в этот канал - should_add = False + ok = True + for channel in desired.values(): + if not _grant_channel(service, employee, mm_user, channel): + ok = False - if setting.auto_add_users: - # Фильтры комбинируются по AND (отдел + тип локации). - should_add = True - if setting.filter_by_department: - should_add = should_add and bool( - employee.department_id - and setting.departments.filter(id=employee.department_id).exists() - ) - if setting.filter_by_location_type: - should_add = should_add and ( - employee.user_location_type in (setting.location_types or []) - ) - - if should_add: - # Проверяем, не добавлен ли уже пользователь в этот канал - membership_exists = MattermostChannelMembership.objects.filter( - user=mattermost_user, - channel=setting.channel - ).exists() - - if not membership_exists: - try: - # Добавляем пользователя в канал - logger.info(f"Добавление {employee.email} в канал {setting.channel.display_name}") - service.add_user_to_channel(setting.channel.channel_id, employee.mattermost_user_id) - - # Создаем запись о членстве - MattermostChannelMembership.objects.create( - user=mattermost_user, - channel=setting.channel - ) - - added_to_channels += 1 - logger.info(f"Пользователь {employee.email} добавлен в канал {setting.channel.display_name}") - MattermostSyncLog.objects.create( - employee=employee, action='add_channel', status='success', - message=f'Канал: {setting.channel.display_name}', - ) - - except Exception as e: - logger.error(f"Ошибка добавления {employee.email} в канал {setting.channel.display_name}: {e}") - MattermostSyncLog.objects.create( - employee=employee, action='add_channel', status='error', - message=f'{setting.channel.display_name}: {e}', - ) - else: - logger.debug(f"Пользователь {employee.email} уже в канале {setting.channel.display_name}") - - if added_to_channels > 0: - logger.info(f"Пользователь {employee.email} добавлен в {added_to_channels} канал(ов)") - return True - else: - logger.info(f"Пользователь {employee.email} уже добавлен во все необходимые каналы") - return True + for setting in settings: + if setting.channel_id in desired: + continue + if not setting_is_targeted(setting): + continue + _revoke_channel(service, employee, mm_user, setting.channel) + return ok except Exception as e: - logger.error(f"Ошибка добавления пользователя {employee.email} в каналы: {e}") + logger.error(f"Ошибка синхронизации каналов для {employee.email}: {e}") return False \ No newline at end of file diff --git a/apps/mattermost_connector/signals.py b/apps/mattermost_connector/signals.py index 8e358dc..6ffd9f2 100755 --- a/apps/mattermost_connector/signals.py +++ b/apps/mattermost_connector/signals.py @@ -4,7 +4,7 @@ import logging from django.contrib.auth import get_user_model -from django.db.models.signals import post_save, pre_save +from django.db.models.signals import m2m_changed, post_save, pre_save from django.dispatch import receiver from apps.mattermost_connector.mattermost_service import MattermostService @@ -21,12 +21,14 @@ def cache_employee_state(sender, instance, **kwargs): instance._pre_save_is_active = None return try: - old = Employee.objects.only('photo', 'is_active').get(pk=instance.pk) + old = Employee.objects.only('photo', 'is_active', 'department_id').get(pk=instance.pk) instance._pre_save_photo = old.photo.name if old.photo else None instance._pre_save_is_active = old.is_active + instance._pre_save_department_id = old.department_id except Employee.DoesNotExist: instance._pre_save_photo = None instance._pre_save_is_active = None + instance._pre_save_department_id = None @receiver(post_save, sender=Employee) @@ -78,3 +80,36 @@ def sync_employee_photo_to_mattermost(sender, instance, created, **kwargs): logger.info(f"Фото профиля синхронизировано для {instance.email}") except Exception as e: logger.error(f"Ошибка синхронизации фото для {instance.email}: {e}") + + +def _resync_mm_membership(employee): + if not employee.mattermost_user_id or not employee.mattermost_sync_enabled: + return + from apps.mattermost_connector.services.sync_service import add_user_to_channels, add_user_to_teams + add_user_to_teams(employee) + add_user_to_channels(employee) + + +@receiver(post_save, sender=Employee) +def resync_mattermost_access_on_department_change(sender, instance, created, **kwargs): + """Смена отдела — пересчитать команды и каналы.""" + if created: + return + old_dept = getattr(instance, '_pre_save_department_id', instance.department_id) + if old_dept == instance.department_id: + return + try: + _resync_mm_membership(instance) + except Exception as e: + logger.error(f"Не удалось обновить доступы Mattermost для {instance.email}: {e}") + + +@receiver(m2m_changed, sender=Employee.groups.through) +def resync_mattermost_access_on_groups_change(sender, instance, action, **kwargs): + """Выдача/снятие группы портала — доступы в MM.""" + if action not in ('post_add', 'post_remove', 'post_clear'): + return + try: + _resync_mm_membership(instance) + except Exception as e: + logger.error(f"Не удалось обновить доступы Mattermost для {instance}: {e}") diff --git a/apps/mattermost_connector/tests.py b/apps/mattermost_connector/tests.py index cd7905d..4e1052d 100755 --- a/apps/mattermost_connector/tests.py +++ b/apps/mattermost_connector/tests.py @@ -538,3 +538,125 @@ class ChatApiProxyTests(TestCase): body = built.read() self.assertIn(b'channel_id', body) self.assertIn(b'hello', body) + + +class MembershipFilterTests(TestCase): + """Правила выдачи команд/каналов: отдел, группа, пустой фильтр.""" + + def setUp(self): + from django.contrib.auth.models import Group + from core.models import Directory, DirectoryType + from apps.mattermost_connector.models import MattermostTeam, TeamSyncSettings + + dtype, _ = DirectoryType.objects.get_or_create( + code='department', defaults={'name': 'Відділ', 'hierarchy_type': 'strict'}, + ) + self.it_dept = Directory.objects.create(directory_type=dtype, name='IT') + self.hr_dept = Directory.objects.create(directory_type=dtype, name='HR') + self.it_group = Group.objects.get_or_create(name='IT')[0] + self.team = MattermostTeam.objects.create( + team_id='team-it', name='it', display_name='IT', + ) + self.setting = TeamSyncSettings.objects.create( + team=self.team, auto_add_users=True, is_active=True, + ) + + def test_no_filters_matches_everyone(self): + employee = make_employee() + self.assertTrue(sync_service.employee_matches_sync_filters(employee, self.setting)) + + def test_empty_department_filter_matches_nobody(self): + self.setting.filter_by_department = True + self.setting.save() + employee = make_employee(department=self.it_dept) + self.assertFalse(sync_service.employee_matches_sync_filters(employee, self.setting)) + + def test_department_filter_matches_only_listed(self): + self.setting.filter_by_department = True + self.setting.save() + self.setting.departments.add(self.it_dept) + self.assertTrue(sync_service.employee_matches_sync_filters( + make_employee(department=self.it_dept), self.setting, + )) + self.assertFalse(sync_service.employee_matches_sync_filters( + make_employee(department=self.hr_dept), self.setting, + )) + + def test_group_filter(self): + self.setting.filter_by_group = True + self.setting.save() + self.setting.groups.add(self.it_group) + in_it = make_employee() + in_it.groups.add(self.it_group) + self.assertTrue(sync_service.employee_matches_sync_filters(in_it, self.setting)) + self.assertFalse(sync_service.employee_matches_sync_filters(make_employee(), self.setting)) + + +class MembershipReconcileTests(TestCase): + def setUp(self): + from django.contrib.auth.models import Group + from core.models import Directory, DirectoryType + from apps.mattermost_connector.models import ( + ChannelSyncSettings, MattermostChannel, MattermostTeam, MattermostUser, + TeamSyncSettings, + ) + + dtype, _ = DirectoryType.objects.get_or_create( + code='department', defaults={'name': 'Відділ', 'hierarchy_type': 'strict'}, + ) + self.it_dept = Directory.objects.create(directory_type=dtype, name='IT-sync') + self.hr_dept = Directory.objects.create(directory_type=dtype, name='HR-sync') + self.it_group = Group.objects.get_or_create(name='IT')[0] + self.team = MattermostTeam.objects.create( + team_id='t-it', name='it-sync', display_name='IT Sync', + ) + self.channel = MattermostChannel.objects.create( + channel_id='c-it', team=self.team, name='it-chat', + display_name='IT Chat', channel_type='P', + ) + self.team_setting = TeamSyncSettings.objects.create( + team=self.team, auto_add_users=True, is_active=True, + filter_by_department=True, + ) + self.team_setting.departments.add(self.it_dept) + self.channel_setting = ChannelSyncSettings.objects.create( + channel=self.channel, auto_add_users=True, is_active=True, + filter_by_group=True, + ) + self.channel_setting.groups.add(self.it_group) + + self.employee = make_employee( + department=self.it_dept, mattermost_user_id='mm-1', + mattermost_sync_enabled=True, + ) + self.employee.groups.add(self.it_group) + MattermostUser.objects.create( + django_user=self.employee, mattermost_id='mm-1', + username=self.employee.username, email=self.employee.email, + ) + self.service = mock.MagicMock() + patcher = mock.patch.object( + sync_service, 'MattermostService', return_value=self.service, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_grants_team_and_channel_and_joins_team_first(self): + self.assertTrue(sync_service.add_user_to_channels(self.employee)) + self.service.add_user_to_team.assert_called_with('t-it', 'mm-1') + self.service.add_user_to_channel.assert_called_with('c-it', 'mm-1') + + def test_revokes_when_filters_no_longer_match(self): + sync_service.add_user_to_teams(self.employee) + sync_service.add_user_to_channels(self.employee) + self.employee.department = self.hr_dept + self.employee.save(update_fields=['department']) + self.employee.groups.clear() + sync_service.add_user_to_channels(self.employee) + sync_service.add_user_to_teams(self.employee) + self.service.remove_user_from_channel.assert_called_with('c-it', 'mm-1') + self.service.remove_user_from_team.assert_called_with('t-it', 'mm-1') + + def test_already_member_is_not_an_error(self): + self.service.add_user_to_team.side_effect = Exception('User is already a member of the team') + self.assertTrue(sync_service.add_user_to_teams(self.employee)) diff --git a/core/admin.py b/core/admin.py index bbe4450..ba43290 100644 --- a/core/admin.py +++ b/core/admin.py @@ -94,6 +94,15 @@ class DirectoryTypeAdmin(ModelAdmin, ImportExportModelAdmin): class DirectoryForm(TreeNodeForm): """Кастомная форма с дополнительными проверками""" + create_ou_in_ad = forms.BooleanField( + label=_('Создать OU в AD автоматически'), required=False, + help_text=_( + 'Если включено и поле «OU в Active Directory» ниже не задано — при сохранении ' + 'будет создана новая OU с названием справочника (вложенная в OU родителя, ' + 'если он привязан; иначе — в users_ou коннектора).' + ), + ) + class Meta: model = Directory fields = '__all__' @@ -111,6 +120,30 @@ class DirectoryForm(TreeNodeForm): if 'parent' in self.fields and self.instance.directory_type_id: self.fields['parent'].queryset = self._get_allowed_parents() + if 'ad_ou_dn' in self.fields: + try: + from apps.ad_connector.ad_service import ADService + service = ADService() + try: + ous = service.list_ous() + root = service.users_ou + finally: + service.unbind() + choices = [('', '— не задано (users_ou коннектора) —')] + if root: + choices.append((root, f'{root} (корень)')) + choices += [(ou['dn'], ou['dn']) for ou in ous if ou['dn'] != root] + self.fields['ad_ou_dn'].widget = forms.Select(choices=choices) + self.fields['ad_ou_dn'].required = False + except Exception: + pass + manage_url = reverse('admin:ad_connector_ou_manage') + self.fields['ad_ou_dn'].help_text = format_html( + '{} Управлять деревом OU в AD →', + self.fields['ad_ou_dn'].help_text or '', + manage_url, + ) + def _get_allowed_parents(self): """Получить разрешенных родителей для текущего типа""" current_type = self.instance.directory_type @@ -150,7 +183,7 @@ class DirectoryAdmin(ModelAdmin, ImportExportModelAdmin): 'fields': ('directory_type', 'parent', 'code', 'name', 'description', 'is_active') }), (_('Должности и локации'), { - 'fields': ('location_types', 'ad_group_dn', 'ad_ou_dn'), + 'fields': ('location_types', 'ad_group_dn', 'ad_ou_dn', 'create_ou_in_ad'), 'description': 'location_types — для должностей: в каких локациях доступна. ' 'ad_group_dn, ad_ou_dn — для отделов.', 'classes': ('collapse',) @@ -162,6 +195,46 @@ class DirectoryAdmin(ModelAdmin, ImportExportModelAdmin): ) filter_horizontal = ['location_types'] + def save_model(self, request, obj, form, change): + old = None + if change: + old = Directory.objects.filter(pk=obj.pk).values('parent_id', 'ad_ou_dn').first() + + if form.cleaned_data.get('create_ou_in_ad') and not obj.ad_ou_dn: + parent_dn = obj.parent.ad_ou_dn if obj.parent_id and obj.parent.ad_ou_dn else None + try: + from apps.ad_connector.ad_service import ADService + service = ADService() + try: + obj.ad_ou_dn = service.create_ou(obj.name, parent_dn) + finally: + service.unbind() + except Exception as exc: + messages.error(request, _('Не удалось создать OU в AD: %s') % exc) + + super().save_model(request, obj, form, change) + + if old and old['ad_ou_dn'] and obj.ad_ou_dn == old['ad_ou_dn'] and old['parent_id'] != obj.parent_id: + from apps.ad_connector.ad_service import ADService + from apps.ad_connector.services.sync_service import relocate_ou_references + try: + service = ADService() + try: + desired_parent = ( + obj.parent.ad_ou_dn + if obj.parent_id and obj.parent.ad_ou_dn + else service.users_ou + ) + current_parent = old['ad_ou_dn'].split(',', 1)[1] + if desired_parent and desired_parent != current_parent: + new_dn = service.move_ou(old['ad_ou_dn'], desired_parent) + relocate_ou_references(old['ad_ou_dn'], new_dn) + messages.success(request, _('OU перенесена в AD: %s') % new_dn) + finally: + service.unbind() + except Exception as exc: + messages.error(request, _('Не удалось перенести OU в AD: %s') % exc) + def get_queryset(self, request): return super().get_queryset(request).select_related( 'parent', 'directory_type' diff --git a/core/portal_sections.py b/core/portal_sections.py index 783acf4..a1b073f 100644 --- a/core/portal_sections.py +++ b/core/portal_sections.py @@ -69,7 +69,10 @@ PORTAL_SECTIONS: dict[str, PortalSection] = { 'help': PortalSection('help', '/help/', 'Справка', 'support'), 'it_support': PortalSection('it_support', '/it-support/', 'Поддержка IT', 'support'), 'it_manager': PortalSection('it_manager', '/it-manager/', 'IT-отдел', 'it', 'core.access_it_manager'), - 'ad_mapping': PortalSection('ad_mapping', '/ad-mapping/', 'Active Directory', 'it', 'core.access_ad_mapping'), + 'ad_mapping': PortalSection( + 'ad_mapping', '/acore-a-panel/ad_connector/adsynclog/match/', 'Active Directory', 'none', + 'core.access_ad_mapping', + ), } diff --git a/core/tests.py b/core/tests.py index 36897cc..206c036 100644 --- a/core/tests.py +++ b/core/tests.py @@ -38,6 +38,8 @@ def make_employee(**overrides): return Employee.objects.create(**defaults) +from django.urls import reverse + from core.test_utils import login_2fa @@ -51,7 +53,7 @@ class PortalPagesSmokeTests(TestCase): '/timetrack/', '/calendar/', '/reports/', '/documents/', '/news/', '/tasks/', '/email/', '/chat/', '/databases/', '/purchases/', '/hr-portal/', - '/help/', '/it-support/', '/ad-mapping/', '/it-manager/', + '/help/', '/it-support/', '/it-manager/', ] @classmethod @@ -97,6 +99,9 @@ class PortalPagesSmokeTests(TestCase): cases = { '/recruiting/': '/hr-portal/?tab=vacancies', '/settings/': settings_target, + '/ad-mapping/': reverse('admin:ad_connector_match_employees'), + '/ad-ou-manage/': reverse('admin:ad_connector_ou_manage'), + '/ad-import/': reverse('admin:ad_connector_import_employees'), } for url, location in cases.items(): with self.subTest(url=url): diff --git a/core/tests_portal_access.py b/core/tests_portal_access.py index 70d7824..4a9931f 100644 --- a/core/tests_portal_access.py +++ b/core/tests_portal_access.py @@ -192,14 +192,12 @@ class PortalAccessViewTests(TestCase): user = make_employee(is_staff=True, totp_enabled=True) login_2fa(self.client, user) assert_denied_to_dashboard(self, self.client.get('/databases/', HTTP_HX_REQUEST='true')) - assert_denied_to_dashboard(self, self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true')) def test_it_group_can_open_databases_and_ad(self): user = make_employee(totp_enabled=True) user.groups.add(Group.objects.get(name='IT')) login_2fa(self.client, user) self.assertEqual(self.client.get('/databases/', HTTP_HX_REQUEST='true').status_code, 200) - self.assertEqual(self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true').status_code, 200) def test_manage_sections_require_permission(self): user = make_employee() diff --git a/templates/ad_connector/_tabs.html b/templates/ad_connector/_tabs.html new file mode 100644 index 0000000..3c98587 --- /dev/null +++ b/templates/ad_connector/_tabs.html @@ -0,0 +1,12 @@ + diff --git a/templates/ad_connector/import_employees.html b/templates/ad_connector/import_employees.html new file mode 100644 index 0000000..34eea0b --- /dev/null +++ b/templates/ad_connector/import_employees.html @@ -0,0 +1,77 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
{{ title }}
+{% include "ad_connector/_tabs.html" with active="import" %} + +{% if ad_error %} +
+ Не удалось получить список пользователей из AD: {{ ad_error }} +
+{% endif %} + +{% if candidates %} +
+
+ {% csrf_token %} +
+
+ + +
+
+ + +
+

Кандидатов: {{ candidates_total }}. Отдел и должность — в каждой строке.

+
+ + + + + + + + + + + + {% for user in candidates %} + + + + + + + {% endfor %} + +
ИмпортУчётная записьОтделДолжность
+ + +
{{ user.username }}
+
{{ user.last_name }} {{ user.first_name }} · {{ user.email }}
+
+ + + +
+ +
+
+{% else %} +

Нет кандидатов на импорт{% if not ad_error %} — все пользователи AD уже есть в системе{% endif %}.

+{% endif %} +{% endblock %} diff --git a/templates/ad_connector/match_employees.html b/templates/ad_connector/match_employees.html new file mode 100644 index 0000000..122ca65 --- /dev/null +++ b/templates/ad_connector/match_employees.html @@ -0,0 +1,143 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
{{ title }}
+{% include "ad_connector/_tabs.html" with active="match" %} + +{% if ad_error %} +
+ {{ ad_error }} +
+{% endif %} + +
+
+
Офисных сотрудников
+
{{ employees_total }}
+
+
+
Связано с AD
+
{{ linked_count }}
+
+
+
Без учётной записи
+
{{ unlinked_count }}
+
+
+
{% if query %}Найдено{% else %}Записей в OU{% endif %}
+
{{ ad_total }}
+
+
+ +
+
+ + +
+ + {% if query %} + Сбросить + {% endif %} +
+ +
+ + + + + + + + + + + {% for employee in employees %} + + + + + + + {% empty %} + + {% endfor %} + +
СотрудникСтатусУчётная запись ADДействия
+
{{ employee.get_full_name }}
+
{{ employee.post.name|default:"—" }} · {{ employee.email }}
+
+ {% if employee.dn_missing %}не найден в AD + {% elif employee.ad_user_dn %} + {% if employee.ad_sync_status == 'synced' %}связан + {% elif employee.ad_sync_status == 'error' %}ошибка + {% else %}ожидает синхронизации{% endif %} + {% elif employee.suggestion %}совпадение ({{ employee.suggestion_reason }}) + {% else %}не связан{% endif %} + + {% if employee.ad_user_dn %} + {% if employee.ad_account %} +
{{ employee.ad_account.sAMAccountName }}
+ {% endif %} +
{{ employee.ad_user_dn }}
+ {% elif not ad_error %} +
+ {% csrf_token %} + + + + +
+ {% else %}—{% endif %} +
+ {% if employee.ad_user_dn %} +
+ {% csrf_token %} + + + +
+ {% elif not ad_error %} +
+ {% csrf_token %} + + + +
+ {% endif %} +
Активных офисных сотрудников нет
+
+ +{% if unmatched_ad_users %} +
+

Учётные записи AD без сотрудника

+ + + + + + + + + + + {% for ad_user in unmatched_ad_users %} + + + + + + + {% endfor %} + +
ЛогинИмяEmailСостояние
{{ ad_user.sAMAccountName|default:"—" }}{{ ad_user.displayName|default:ad_user.cn|default:"—" }}{{ ad_user.mail|default:"—" }}{% if ad_user.is_disabled %}отключён{% else %}активен{% endif %}
+
+{% endif %} +{% endblock %} diff --git a/templates/ad_connector/ou_manage.html b/templates/ad_connector/ou_manage.html new file mode 100644 index 0000000..a1bd817 --- /dev/null +++ b/templates/ad_connector/ou_manage.html @@ -0,0 +1,92 @@ +{% extends "admin/base_site.html" %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
{{ title }}
+{% include "ad_connector/_tabs.html" with active="ou" %} + +{% if ad_error %} +
+ Не удалось получить дерево OU: {{ ad_error }} +
+{% endif %} + +
+

Создать OU

+
+ {% csrf_token %} + + {{ create_form.as_p }} + +
+
+ +
+

Дерево организационных юнитов

+

Корень: {{ root_ou|default:"—" }}

+ + + + + + + + + + + + {% for ou in ous %} + + + + + + + + {% empty %} + + {% endfor %} + +
DNОтделПереименоватьПеренестиУдалить
+
{{ ou.name }}
+
{{ ou.dn }}
+
{{ ou.department|default:"—" }} +
+ {% csrf_token %} + + {{ ou.rename_form.dn }} + {{ ou.rename_form.new_name }} + +
+
+
+ {% csrf_token %} + + + + +
+
+
+ {% csrf_token %} + + + +
+
OU не найдены
+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html index 85cd29e..cebe722 100644 --- a/templates/index.html +++ b/templates/index.html @@ -469,13 +469,11 @@ - - {% if request.user|can_section:'it_manager' or request.user|can_section:'ad_mapping' %} + + {% if request.user|can_section:'it_manager' %}
- - {% if request.user|can_section:'it_manager' %} @@ -484,20 +482,6 @@ {% trans "IT-отдел" %} - {% endif %} - - {% if request.user|can_section:'ad_mapping' %} - - - - - - {% trans "Active Directory" %} - - {% endif %} -
{% endif %}