"""Celery задачи для отложенной LDAP-синхронизации. При сбое соединения с LDAP операция сохраняется в LdapSyncQueue и повторяется через эти задачи с экспоненциальным backoff. """ import logging from datetime import timedelta from celery import shared_task from django.conf import settings from django.utils import timezone logger = logging.getLogger(__name__) def _require_payload_value(payload: dict, key: str): """Возвращает обязательное поле payload или поднимает ValueError.""" value = payload.get(key) if value in (None, ""): raise ValueError(f"LDAP queue payload missing required field: {key}") return value def _payload_changes(payload: dict) -> dict: """Нормализует поле changes и валидирует его тип.""" changes = payload.get("changes") or {} if not isinstance(changes, dict): raise ValueError("LDAP queue payload field 'changes' must be a dict") return changes # --------------------------------------------------------------------------- # Исполнители отложенных LDAP-операций # --------------------------------------------------------------------------- def _execute_employee_save(payload: dict) -> None: """Повторяет синхронизацию Employee → LDAP.""" from employees.ldap import UserService from employees.models import Employee emp = Employee.objects.get(pk=payload["object_pk"]) svc = UserService() svc.update_user( emp=emp, changes=payload.get("changes", {}), group_cns=None, move_to_department_dn=None, ) def _execute_employee_delete(payload: dict) -> None: """Повторяет удаление Employee из LDAP.""" from employees.ldap import UserService from employees.models import LdapSyncState sync_state = LdapSyncState.objects.filter( model="employee", object_pk=payload["object_pk"] ).first() if not sync_state or not sync_state.ldap_dn: logger.info( "Queued LDAP employee deletion skipped", extra={ "event": "ldap.queue.employee_delete_skipped", "employee_id": payload["object_pk"], "reason": "sync_state_missing", }, ) return svc = UserService() # Создаём легковесный объект-заглушку для delete_user from types import SimpleNamespace emp_stub = SimpleNamespace(pk=payload["object_pk"], id=payload["object_pk"]) svc.delete_user(emp_stub) def _execute_department_save(payload: dict) -> None: """Повторяет синхронизацию Department → LDAP.""" from employees.ldap import DepartmentService from employees.models import Department object_pk = _require_payload_value(payload, "object_pk") dept = Department.objects.filter(pk=object_pk).first() if dept is None: logger.info( "Queued LDAP department sync skipped", extra={ "event": "ldap.queue.department_sync_skipped", "department_id": object_pk, "reason": "department_missing", }, ) return DepartmentService().sync_department_state( dept, created=bool(payload.get("created")), changes=_payload_changes(payload), sync_head=bool(payload.get("sync_head")), ) def _execute_department_delete(payload: dict) -> None: """Повторяет удаление Department OU из LDAP.""" from employees.ldap import DepartmentService DepartmentService().sync_department_delete( object_pk=_require_payload_value(payload, "object_pk"), dept_dn=payload.get("dept_dn"), ) def _execute_department_member(payload: dict) -> None: """Повторяет sync члена отдела → LDAP.""" from employees.ldap import DepartmentService from employees.models import Department, Employee, EmployeeDepartment employee_pk = _require_payload_value(payload, "employee_pk") department_pk = _require_payload_value(payload, "department_pk") emp = Employee.objects.filter(pk=employee_pk).first() if emp is None: logger.info( "Queued LDAP department member sync skipped", extra={ "event": "ldap.queue.department_member_sync_skipped", "employee_id": employee_pk, "department_id": department_pk, "reason": "employee_missing", }, ) return dept = Department.objects.filter(pk=department_pk).first() if dept is None: logger.info( "Queued LDAP department member sync skipped", extra={ "event": "ldap.queue.department_member_sync_skipped", "employee_id": employee_pk, "department_id": department_pk, "reason": "department_missing", }, ) return link = ( EmployeeDepartment.objects.filter( employee_id=employee_pk, department_id=department_pk, ) .select_related("role") .first() ) is_active = bool(payload.get("is_active")) role_hint = payload.get("role") if link is not None: is_active = link.is_active role_hint = link.role or role_hint else: logger.warning( "Queued LDAP member sync is using payload fallback", extra={ "event": "ldap.queue.department_member_payload_fallback", "employee_id": employee_pk, "department_id": department_pk, "reason": "membership_missing", }, ) DepartmentService().sync_member_state( emp, dept, is_active=is_active, role=role_hint, ) def _execute_group_save(payload: dict) -> None: """Повторяет синхронизацию Group → LDAP.""" from django.contrib.auth.models import Group from employees.ldap import GroupService from employees.models import LdapSyncState group = Group.objects.get(pk=payload["object_pk"]) svc = GroupService() if payload.get("created"): svc.create( cn=group.name, parent_dn=payload.get("parent_dn"), description=payload.get("description"), scope=payload.get("scope", "global"), security_enabled=payload.get("security_enabled", True), ) else: dn = payload.get("dn") if not dn: sync_state = LdapSyncState.objects.filter( model="group", object_pk=str(group.pk) ).first() dn = sync_state.ldap_dn if sync_state else None if not dn: return if payload.get("old_name") and payload["old_name"] != group.name: new_dn = svc.rename(dn, group.name) sync_state = LdapSyncState.objects.filter( model="group", object_pk=str(group.pk) ).first() if sync_state: sync_state.ldap_dn = new_dn sync_state.save(update_fields=["ldap_dn"]) dn = new_dn if "description" in payload: svc.set_description(dn, payload["description"] or None) def _execute_group_delete(payload: dict) -> None: """Повторяет удаление Group из LDAP.""" from employees.ldap import GroupService dn = payload.get("dn") if not dn: return svc = GroupService() svc.delete(dn) def _execute_group_members(payload: dict) -> None: """Повторяет синхронизацию участников Group → LDAP.""" from employees.ldap import GroupService dn = payload.get("dn") if not dn: return svc = GroupService() action = payload["action"] member_dns = payload.get("member_dns", []) if action == "add" and member_dns: svc.add_members(dn, member_dns) elif action == "remove" and member_dns: svc.remove_members(dn, member_dns) elif action == "clear": svc.replace_members(dn, []) def _execute_position_save(payload: dict) -> None: """Повторяет синхронизацию Position → LDAP.""" from employees.ldap import PositionService from employees.models import Position position = Position.objects.get(pk=payload["object_pk"]) svc = PositionService() svc.reconcile_position(position) def _execute_position_delete(payload: dict) -> None: """Повторяет удаление Position POS-группы из LDAP.""" from types import SimpleNamespace from employees.ldap import PositionService pos_stub = SimpleNamespace( pk=payload["object_pk"], id=payload["object_pk"], name=payload.get("name", ""), ) svc = PositionService() svc.delete_position_group(pos_stub) def _execute_department_role_save(payload: dict) -> None: """Повторяет синхронизацию роли отдела → LDAP.""" from employees.ldap import DepartmentService from employees.models import DepartmentRole role_pk = _require_payload_value(payload, "role_pk") role = ( DepartmentRole.objects.filter(pk=role_pk).select_related("department").first() ) if role is None: logger.info( "Queued LDAP department role sync skipped", extra={ "event": "ldap.queue.department_role_sync_skipped", "department_role_id": role_pk, "reason": "role_missing", }, ) return DepartmentService().sync_role_state(role) def _execute_department_role_delete(payload: dict) -> None: """Повторяет удаление LDAP-следов роли отдела.""" from employees.ldap import DepartmentService from employees.models import Department department_pk = _require_payload_value(payload, "department_pk") dept = Department.objects.filter(pk=department_pk).first() if dept is None: logger.info( "Queued LDAP department role deletion skipped", extra={ "event": "ldap.queue.department_role_delete_skipped", "department_id": department_pk, "reason": "department_missing", }, ) return DepartmentService().sync_role_delete( dept, role_group_dn=payload.get("role_group_dn"), ) def _execute_role_assignment(payload: dict) -> None: """Повторяет синхронизацию назначения роли → LDAP.""" from employees.ldap import DepartmentService from employees.models import DepartmentRole, Employee employee_pk = _require_payload_value(payload, "employee_pk") role_pk = _require_payload_value(payload, "role_pk") employee = Employee.objects.filter(pk=employee_pk).first() if employee is None: logger.info( "Queued LDAP role assignment sync skipped", extra={ "event": "ldap.queue.role_assignment_sync_skipped", "employee_id": employee_pk, "department_role_id": role_pk, "reason": "employee_missing", }, ) return role = DepartmentRole.objects.filter(pk=role_pk).first() if role is None: logger.info( "Queued LDAP role assignment sync skipped", extra={ "event": "ldap.queue.role_assignment_sync_skipped", "employee_id": employee_pk, "department_role_id": role_pk, "reason": "role_missing", }, ) return DepartmentService().sync_role_assignment_state( employee, role, is_active=bool(payload.get("is_active")), ) def _execute_guest_operation(payload: dict) -> None: """Повторяет синхронизацию гостевой LDAP-учетки.""" from guests.tasks import execute_guest_queue_operation operation = payload.get("_operation") or payload.get("operation") or "guest_sync" execute_guest_queue_operation(operation, payload) # Реестр: operation → callable _EXECUTORS = { "employee_save": _execute_employee_save, "employee_delete": _execute_employee_delete, "department_save": _execute_department_save, "department_delete": _execute_department_delete, "department_member": _execute_department_member, "group_save": _execute_group_save, "group_delete": _execute_group_delete, "group_members": _execute_group_members, "position_save": _execute_position_save, "position_delete": _execute_position_delete, "department_role_save": _execute_department_role_save, "department_role_delete": _execute_department_role_delete, "role_assignment": _execute_role_assignment, "guest_sync": _execute_guest_operation, "guest_disable": _execute_guest_operation, "guest_delete": _execute_guest_operation, } # --------------------------------------------------------------------------- # Celery задачи # --------------------------------------------------------------------------- @shared_task(bind=True, max_retries=0, ignore_result=True) def process_ldap_queue_item(self, queue_id: int): """Выполняет одну отложенную LDAP-операцию из очереди. При ошибке вызывает schedule_retry() с exponential backoff. Успешное выполнение отмечает запись как completed. """ from employees.models import LdapSyncQueue if not getattr(settings, "LDAP_ENABLED", False): return try: item = LdapSyncQueue.objects.get(pk=queue_id) except LdapSyncQueue.DoesNotExist: logger.warning( "LDAP queue item not found", extra={"event": "ldap.queue.item_missing", "queue_id": queue_id}, ) return if item.status not in ( LdapSyncQueue.Status.PENDING, LdapSyncQueue.Status.IN_PROGRESS, ): return executor = _EXECUTORS.get(item.operation) if not executor: logger.error( "LDAP queue operation is unsupported", extra={ "event": "ldap.queue.operation_unsupported", "queue_id": item.pk, "ldap_operation": item.operation, }, ) item.status = LdapSyncQueue.Status.FAILED item.last_error = f"Unknown operation: {item.operation}" item.save(update_fields=["status", "last_error", "updated_at"]) return item.status = LdapSyncQueue.Status.IN_PROGRESS item.save(update_fields=["status", "updated_at"]) try: payload = dict(item.payload or {}) payload.setdefault("object_pk", item.object_pk) executor(payload) item.mark_completed() logger.info( "LDAP queue item completed", extra={ "event": "ldap.queue.item_completed", "queue_id": item.pk, "ldap_operation": item.operation, "ldap_model": item.model_name, "object_id": item.object_pk, "attempt": item.attempts + 1, }, ) except Exception as e: logger.exception( "LDAP queue item failed", extra={ "event": "ldap.queue.item_failed", "queue_id": item.pk, "ldap_operation": item.operation, "attempt": item.attempts + 1, "max_attempts": item.max_attempts, "error_type": type(e).__name__, }, ) item.schedule_retry(str(e)) @shared_task(ignore_result=True) def process_ldap_queue(): """Periodic task для обработки pending-элементов очереди. Берет только записи, у которых наступил next_retry_at. """ from employees.models import LdapSyncQueue if not getattr(settings, "LDAP_ENABLED", False): return now = timezone.now() task_time_limit = getattr(settings, "CELERY_TASK_TIME_LIMIT", 30 * 60) stale_cutoff = now - timedelta(seconds=task_time_limit + 300) stale_count = LdapSyncQueue.objects.filter( status=LdapSyncQueue.Status.IN_PROGRESS, updated_at__lt=stale_cutoff, ).update( status=LdapSyncQueue.Status.PENDING, next_retry_at=now, last_error=( "Recovered stale in-progress LDAP sync item after worker interruption" ), updated_at=now, ) if stale_count: logger.warning( "Stale LDAP queue items recovered", extra={ "event": "ldap.queue.stale_items_recovered", "item_count": stale_count, }, ) pending = LdapSyncQueue.objects.filter( status=LdapSyncQueue.Status.PENDING, next_retry_at__lte=now, ).values_list("pk", flat=True)[:100] for item_pk in pending: process_ldap_queue_item.delay(item_pk) # Также подхватываем новые элементы без next_retry_at new_items = LdapSyncQueue.objects.filter( status=LdapSyncQueue.Status.PENDING, next_retry_at__isnull=True, ).values_list("pk", flat=True)[:100] for item_pk in new_items: process_ldap_queue_item.delay(item_pk) total = len(pending) + len(new_items) if total: logger.info( "LDAP queue items dispatched", extra={ "event": "ldap.queue.items_dispatched", "item_count": total, "retry_item_count": len(pending), "new_item_count": len(new_items), }, )