5c3c27e0af
Раздел «Справка» (категории/статьи/предложка), правки ad_connector и обновление тестов feed под обязательную 2FA. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
323 lines
13 KiB
Python
323 lines
13 KiB
Python
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)
|