2026-09-07 19:43:48 +03:00

541 lines
18 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.

"""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),
},
)