Интеграции AD/Mattermost: OU-управление, импорт, шифрование токенов
- ad_connector: страницы управления OU и импорта из AD, поле ad_ou_dn в Directory, ADSyncLog; расширение sync-сервиса и клиента - mattermost_connector: personal access token шифруется (Fernet, token_service), бот-сервис, команды создания бота и синхронизации; публикация постов в каналы. Тесты (19) - core: ConnectorConfig, Directory (ad_ou_dn), обновления админки Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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()}'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
+185
-10
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
@@ -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}'))
|
||||
@@ -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(
|
||||
|
||||
@@ -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=''),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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}» позначена виконаною.'})
|
||||
|
||||
+6
-5
@@ -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('<span style="color: #999;">Все типы</span>')
|
||||
return mark_safe('<span style="color: #999;">Все типы</span>')
|
||||
|
||||
types_html = ', '.join([
|
||||
f'<span style="background-color: #e3f2fd; padding: 2px 6px; border-radius: 3px; font-size: 11px;">{lt.name}</span>'
|
||||
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',),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<div class="page-content" id="ad-import-page">
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem; gap:1rem; flex-wrap:wrap;">
|
||||
<div>
|
||||
<h1 style="font-size:1.25rem; font-weight:500; color:var(--color-foreground);">Імпорт співробітників з Active Directory</h1>
|
||||
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">Облікові записи AD, яким ще не відповідає жоден співробітник у системі</p>
|
||||
</div>
|
||||
<a href="#" class="btn btn-primary"
|
||||
hx-get="/ad-import/" hx-target="#main-content" hx-push-url="/ad-import/" hx-indicator="#loader">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>
|
||||
</svg>
|
||||
Оновити
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% include "partials/ad_connector_tabs.html" with active="import" %}
|
||||
|
||||
{% if imported %}
|
||||
<div style="background:rgba(22,163,74,0.08); border:1px solid rgba(22,163,74,0.3); border-radius:0.75rem; padding:1rem 1.25rem; margin-bottom:1.5rem; font-size:0.875rem; color:#16a34a;">
|
||||
Імпортовано: {{ imported|join:", " }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<div style="background:color-mix(in srgb, var(--color-destructive) 8%, transparent); border:1px solid color-mix(in srgb, var(--color-destructive) 30%, transparent); border-radius:0.75rem; padding:1rem 1.25rem; margin-bottom:1.5rem; font-size:0.875rem; color:var(--color-destructive);">
|
||||
{% for err in errors %}<div>{{ err }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ad_error %}
|
||||
<div style="background:color-mix(in srgb, var(--color-destructive) 8%, transparent); border:1px solid color-mix(in srgb, var(--color-destructive) 30%, transparent); border-radius:0.75rem; padding:1rem 1.25rem; margin-bottom:1.5rem; display:flex; align-items:center; gap:0.75rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-destructive)" stroke-width="2" style="flex-shrink:0;">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>
|
||||
</svg>
|
||||
<div style="font-size:0.875rem; color:var(--color-destructive);">{{ ad_error }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/ad-import/" hx-target="#main-content" hx-indicator="#loader">
|
||||
{% csrf_token %}
|
||||
|
||||
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0 / 0.05); margin-bottom:1.5rem; padding:1.25rem 1.5rem; display:flex; align-items:flex-end; gap:1rem; flex-wrap:wrap;">
|
||||
<div>
|
||||
<label style="display:block; font-size:0.8125rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">Дата прийняття</label>
|
||||
<input type="date" name="hire_date" value="{{ today }}" class="input" style="max-width:12rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:0.8125rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">Локація (офіс)</label>
|
||||
<select name="user_location" class="input" style="max-width:20rem;">
|
||||
<option value="">— оберіть —</option>
|
||||
{% for loc in locations %}
|
||||
<option value="{{ loc.pk }}">{{ loc.address|default:loc.pk }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:0.8125rem; color:var(--color-muted-foreground);">
|
||||
Однакові для всього пакету імпорту. Відділ і посаду можна вказати окремо для кожного рядка.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0 / 0.05); margin-bottom:1.5rem;">
|
||||
<div style="padding:1.25rem 1.5rem; border-bottom:1px solid var(--color-border);">
|
||||
<h3 style="font-weight:600; color:var(--color-card-foreground); font-size:0.9375rem;">Кандидати на імпорт</h3>
|
||||
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ candidates_total }} облікових записів AD без співробітника</p>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%; border-collapse:collapse; font-size:0.875rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;"></th>
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Обліковий запис</th>
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Відділ</th>
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Посада</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for candidate in candidates %}
|
||||
<tr style="border-bottom:1px solid var(--color-border);" class="tr-hover">
|
||||
<td style="padding:0.875rem 1rem; vertical-align:middle;">
|
||||
<input type="checkbox" name="import_{{ candidate.username }}" value="1">
|
||||
</td>
|
||||
<td style="padding:0.875rem 1rem; vertical-align:middle;">
|
||||
<div style="font-weight:500; color:var(--color-card-foreground);">
|
||||
{{ candidate.last_name }} {{ candidate.first_name }}
|
||||
{% if candidate.is_disabled %}
|
||||
<span style="display:inline-flex; align-items:center; padding:0.125rem 0.5rem; border-radius:9999px; font-size:0.6875rem; font-weight:500; background:var(--color-muted); color:var(--color-muted-foreground); margin-left:0.375rem;">Відключений</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ candidate.username }} · {{ candidate.email }}</div>
|
||||
</td>
|
||||
<td style="padding:0.875rem 1rem; vertical-align:middle;">
|
||||
<select name="department_{{ candidate.username }}" class="input" style="font-size:0.8125rem; padding:0.3rem 0.5rem; height:auto; max-width:14rem;">
|
||||
<option value="">— оберіть —</option>
|
||||
{% for dep in departments %}
|
||||
<option value="{{ dep.pk }}">{{ dep.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td style="padding:0.875rem 1rem; vertical-align:middle;">
|
||||
<select name="post_{{ candidate.username }}" class="input" style="font-size:0.8125rem; padding:0.3rem 0.5rem; height:auto; max-width:14rem;">
|
||||
<option value="">— оберіть —</option>
|
||||
{% for post in posts %}
|
||||
<option value="{{ post.pk }}">{{ post.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" style="padding:2rem; text-align:center; color:var(--color-muted-foreground);">Немає нових облікових записів для імпорту</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "partials/pagination.html" with page=candidates base_url="/ad-import/" page_param="candidates_page" size_param="candidates_per_page" item_label="кандидатів" %}
|
||||
</div>
|
||||
|
||||
{% if candidates %}
|
||||
<button type="submit" class="btn btn-primary">Імпортувати відмічені</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="page-content" id="ad-mapping-page">
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem; gap:1rem; flex-wrap:wrap;">
|
||||
<div>
|
||||
<h1 style="font-size:1.25rem; font-weight:500; color:var(--color-foreground);">Зіставлення з Active Directory</h1>
|
||||
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">Зв'язок офісних співробітників з обліковими записами домену</p>
|
||||
@@ -17,6 +17,19 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% include "partials/ad_connector_tabs.html" with active="mapping" %}
|
||||
|
||||
<form method="get" hx-get="/ad-mapping/" hx-target="#main-content" hx-push-url="true" hx-indicator="#loader"
|
||||
style="display:flex; align-items:center; gap:0.5rem; margin-bottom:1.5rem;">
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Пошук в AD за логіном, ПІБ або email…"
|
||||
class="input" style="max-width:24rem;">
|
||||
<button type="submit" class="btn btn-secondary" style="font-size:0.8125rem;">Знайти</button>
|
||||
{% if query %}
|
||||
<a href="#" class="btn btn-ghost" style="font-size:0.8125rem;"
|
||||
hx-get="/ad-mapping/" hx-target="#main-content" hx-push-url="/ad-mapping/" hx-indicator="#loader">Скинути</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if ad_error %}
|
||||
<div style="background:color-mix(in srgb, var(--color-destructive) 8%, transparent); border:1px solid color-mix(in srgb, var(--color-destructive) 30%, transparent); border-radius:0.75rem; padding:1rem 1.25rem; margin-bottom:1.5rem; display:flex; align-items:center; gap:0.75rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-destructive)" stroke-width="2" style="flex-shrink:0;">
|
||||
@@ -30,7 +43,7 @@
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(11rem, 1fr)); gap:1rem; margin-bottom:1.5rem;">
|
||||
<div style="background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1rem 1.25rem;">
|
||||
<div style="font-size:0.8125rem; color:var(--color-muted-foreground);">Офісних співробітників</div>
|
||||
<div style="font-size:1.5rem; font-weight:600; color:var(--color-card-foreground); margin-top:0.25rem;">{{ employees|length }}</div>
|
||||
<div style="font-size:1.5rem; font-weight:600; color:var(--color-card-foreground); margin-top:0.25rem;">{{ employees_total }}</div>
|
||||
</div>
|
||||
<div style="background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1rem 1.25rem;">
|
||||
<div style="font-size:0.8125rem; color:var(--color-muted-foreground);">Зв'язано з AD</div>
|
||||
@@ -41,7 +54,7 @@
|
||||
<div style="font-size:1.5rem; font-weight:600; color:var(--color-primary); margin-top:0.25rem;">{{ unlinked_count }}</div>
|
||||
</div>
|
||||
<div style="background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1rem 1.25rem;">
|
||||
<div style="font-size:0.8125rem; color:var(--color-muted-foreground);">Записів у домені</div>
|
||||
<div style="font-size:0.8125rem; color:var(--color-muted-foreground);">{% if query %}Знайдено за запитом{% else %}Записів у домені{% endif %}</div>
|
||||
<div style="font-size:1.5rem; font-weight:600; color:var(--color-card-foreground); margin-top:0.25rem;">{{ ad_total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,6 +158,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "partials/pagination.html" with page=employees base_url="/ad-mapping/" page_param="emp_page" size_param="emp_per_page" item_label="співробітників" %}
|
||||
</div>
|
||||
|
||||
<!-- Учётки AD без сотрудника -->
|
||||
@@ -152,7 +166,7 @@
|
||||
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0 / 0.05);">
|
||||
<div style="padding:1.25rem 1.5rem; border-bottom:1px solid var(--color-border);">
|
||||
<h3 style="font-weight:600; color:var(--color-card-foreground); font-size:0.9375rem;">Облікові записи AD без співробітника</h3>
|
||||
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ unmatched_ad_users|length }} записів у домені не зв'язані з жодним співробітником</p>
|
||||
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ unmatched_ad_users_page.paginator.count }} записів у домені не зв'язані з жодним співробітником</p>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%; border-collapse:collapse; font-size:0.875rem;">
|
||||
@@ -165,7 +179,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ad_user in unmatched_ad_users %}
|
||||
{% for ad_user in unmatched_ad_users_page %}
|
||||
<tr style="border-bottom:1px solid var(--color-border);" class="tr-hover">
|
||||
<td style="padding:0.75rem 1rem; font-weight:500; color:var(--color-card-foreground);">{{ ad_user.sAMAccountName|default:"—" }}</td>
|
||||
<td style="padding:0.75rem 1rem;">{{ ad_user.displayName|default:ad_user.cn|default:"—" }}</td>
|
||||
@@ -182,6 +196,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "partials/pagination.html" with page=unmatched_ad_users_page base_url="/ad-mapping/" page_param="ad_page" size_param="ad_per_page" item_label="записів" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<div class="page-content" id="ad-ou-manage-page">
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1rem; gap:1rem; flex-wrap:wrap;">
|
||||
<div>
|
||||
<h1 style="font-size:1.25rem; font-weight:500; color:var(--color-foreground);">Організаційні юніти Active Directory</h1>
|
||||
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">Управління OU та їх прив'язкою до відділів (Directory.ad_ou_dn)</p>
|
||||
</div>
|
||||
<a href="#" class="btn btn-primary"
|
||||
hx-get="/ad-ou-manage/" hx-target="#main-content" hx-push-url="/ad-ou-manage/" hx-indicator="#loader">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>
|
||||
</svg>
|
||||
Оновити
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% include "partials/ad_connector_tabs.html" with active="ou" %}
|
||||
|
||||
{% if notice %}
|
||||
<div style="background:rgba(22,163,74,0.08); border:1px solid rgba(22,163,74,0.3); border-radius:0.75rem; padding:1rem 1.25rem; margin-bottom:1.5rem; font-size:0.875rem; color:#16a34a;">
|
||||
{{ notice }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ad_error %}
|
||||
<div style="background:color-mix(in srgb, var(--color-destructive) 8%, transparent); border:1px solid color-mix(in srgb, var(--color-destructive) 30%, transparent); border-radius:0.75rem; padding:1rem 1.25rem; margin-bottom:1.5rem; display:flex; align-items:center; gap:0.75rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-destructive)" stroke-width="2" style="flex-shrink:0;">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>
|
||||
</svg>
|
||||
<div style="font-size:0.875rem; color:var(--color-destructive);">{{ ad_error }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Создание OU -->
|
||||
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0 / 0.05); margin-bottom:1.5rem;">
|
||||
<div style="padding:1.25rem 1.5rem; border-bottom:1px solid var(--color-border);">
|
||||
<h3 style="font-weight:600; color:var(--color-card-foreground); font-size:0.9375rem;">Нова OU</h3>
|
||||
</div>
|
||||
<form hx-post="/ad-ou-manage/" hx-target="#main-content" hx-indicator="#loader"
|
||||
style="padding:1.25rem 1.5rem; display:flex; align-items:flex-end; gap:0.75rem; flex-wrap:wrap;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="create">
|
||||
<div>
|
||||
<label style="display:block; font-size:0.8125rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">Назва</label>
|
||||
<input type="text" name="name" required class="input" style="max-width:16rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:0.8125rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">Батьківська OU</label>
|
||||
<select name="parent_dn" class="input" style="max-width:26rem;">
|
||||
<option value="{{ root_ou }}">{{ root_ou }} (корінь)</option>
|
||||
{% for ou in ous %}
|
||||
<option value="{{ ou.dn }}">{{ ou.dn }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="font-size:0.8125rem;">Створити</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Список OU -->
|
||||
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0 / 0.05);">
|
||||
<div style="padding:1.25rem 1.5rem; border-bottom:1px solid var(--color-border);">
|
||||
<h3 style="font-weight:600; color:var(--color-card-foreground); font-size:0.9375rem;">OU в домені</h3>
|
||||
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ ous_page.paginator.count }} організаційних юнітів</p>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%; border-collapse:collapse; font-size:0.875rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">OU</th>
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Відділ</th>
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Перейменувати</th>
|
||||
<th style="padding:0.75rem 1rem; text-align:right; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Дії</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ou in ous_page %}
|
||||
<tr style="border-bottom:1px solid var(--color-border);" class="tr-hover">
|
||||
<td style="padding:0.875rem 1rem; vertical-align:middle;">
|
||||
<div style="font-weight:500; color:var(--color-card-foreground);">{{ ou.name }}</div>
|
||||
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.125rem; max-width:24rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="{{ ou.dn }}">{{ ou.dn }}</div>
|
||||
</td>
|
||||
<td style="padding:0.875rem 1rem; vertical-align:middle;">
|
||||
{% if ou.department %}
|
||||
<span style="display:inline-flex; align-items:center; padding:0.125rem 0.625rem; border-radius:9999px; font-size:0.75rem; font-weight:500; background:rgba(37,99,235,0.1); color:#2563eb;">{{ ou.department }}</span>
|
||||
{% else %}
|
||||
<span style="color:var(--color-muted-foreground); font-size:0.8125rem;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding:0.875rem 1rem; vertical-align:middle;">
|
||||
<form hx-post="/ad-ou-manage/" hx-target="#main-content" hx-indicator="#loader" style="display:flex; align-items:center; gap:0.5rem; margin:0;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="rename">
|
||||
<input type="hidden" name="dn" value="{{ ou.dn }}">
|
||||
<input type="text" name="new_name" value="{{ ou.name }}" class="input" style="font-size:0.8125rem; padding:0.3rem 0.5rem; height:auto; max-width:12rem;">
|
||||
<button type="submit" class="btn btn-ghost" style="font-size:0.8125rem; padding:0.3rem 0.75rem;">Перейменувати</button>
|
||||
</form>
|
||||
</td>
|
||||
<td style="padding:0.875rem 1rem; vertical-align:middle; text-align:right; white-space:nowrap;">
|
||||
<form hx-post="/ad-ou-manage/" hx-target="#main-content" hx-indicator="#loader"
|
||||
hx-confirm="Видалити OU «{{ ou.name }}»? Дія доступна лише для порожніх OU."
|
||||
style="display:inline; margin:0;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="dn" value="{{ ou.dn }}">
|
||||
<button type="submit" class="btn btn-ghost" style="font-size:0.8125rem; padding:0.3rem 0.75rem; color:var(--color-destructive);">Видалити</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" style="padding:2rem; text-align:center; color:var(--color-muted-foreground);">OU не знайдено</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "partials/pagination.html" with page=ous_page base_url="/ad-ou-manage/" page_param="ou_page" size_param="ou_per_page" item_label="OU" %}
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:1rem;">
|
||||
Щоб прив'язати OU до відділу, вкажіть її DN у полі «OU в Active Directory» картки відділу (Довідники → Відділи).
|
||||
При синхронізації співробітник переноситься в OU свого відділу автоматично.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user