Files
ACORE/apps/ad_connector/views.py
T
lokses 5c3c27e0af help + ad_connector + feed: база знаний и правки тестов
Раздел «Справка» (категории/статьи/предложка), правки ad_connector
и обновление тестов feed под обязательную 2FA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:03:55 +03:00

323 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
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.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.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 _handle_action(request):
"""Обработка действий сопоставления: связать / отвязать / создать"""
action = request.POST.get('action')
employee = get_object_or_404(
Employee, pk=request.POST.get('employee_id'), user_location_type='office',
)
if action == 'link':
dn = request.POST.get('dn', '').strip()
if dn:
Employee.objects.filter(pk=employee.pk).update(
ad_user_dn=dn,
ad_sync_status='not_synced',
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}")
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")
elif action == 'create':
Employee.objects.filter(pk=employee.pk).update(ad_sync_enabled=True)
from apps.ad_connector.tasks import sync_employee_to_ad_task
sync_employee_to_ad_task.delay(employee.pk)
from core.security import log_security_event
log_security_event(
'ad.account_create_requested',
actor=request.user,
target=str(employee.pk),
details=f'email={employee.email}',
)
logger.info(f"AD-сопоставление: поставлена задача создания учётки для {employee.email}")
def _find_suggestion(employee, ad_users, linked_dns):
"""Подбор кандидата в AD: по email, затем по логину, затем по ФИО"""
full_name = employee.get_full_name()
for ad_user in ad_users:
if ad_user['dn'] in linked_dns:
continue
if employee.email and (ad_user.get('mail') or '').lower() == employee.email.lower():
return ad_user, 'email'
for ad_user in ad_users:
if ad_user['dn'] in linked_dns:
continue
if (ad_user.get('sAMAccountName') or '').lower() == (employee.username or '').lower():
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 None, None
@login_required
@section_required('ad_mapping')
def ad_mapping_view(request):
"""Страница сопоставления офисных сотрудников с учётными записями AD"""
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')
.order_by('last_name', 'first_name')
)
ad_users, ad_error = [], ''
try:
service = ADService()
try:
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:
service.unbind()
except ADConfigError as e:
ad_error = str(e)
except Exception as e:
ad_error = f'Помилка з’єднання з Active Directory: {e}'
logger.error(f"AD-сопоставление: {ad_error}")
ad_by_dn = {u['dn']: u for u in ad_users}
linked_dns = {e.ad_user_dn for e in employees if e.ad_user_dn}
linked_count = 0
for employee in employees:
employee.ad_account = ad_by_dn.get(employee.ad_user_dn) if employee.ad_user_dn else None
employee.dn_missing = bool(employee.ad_user_dn) and not employee.ad_account and not ad_error
employee.suggestion, employee.suggestion_reason = (None, None)
if employee.ad_user_dn:
linked_count += 1
elif ad_users:
employee.suggestion, employee.suggestion_reason = _find_suggestion(
employee, ad_users, linked_dns,
)
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'),
'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()
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()
@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)
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(),
}
@login_required
@section_required('ad_mapping')
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_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", "")}',
)
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)