Feat: Implement department move and position group sync in UserService

- Department move: set ldap_user.base_dn via ModifyDnMixin (replaces DirectoryService)
- Position sync: assign/unassign POS-groups via PositionService on position change
- DB update: position_id saved to Employee in atomic block
- Best-effort: position sync errors logged but don't interrupt update
This commit is contained in:
lizerk 2026-03-20 01:45:25 +03:00
parent 0ba594d317
commit efc649aeae

View File

@ -49,7 +49,7 @@ logger = logging.getLogger(__name__)
class UserService(BaseService):
"""Сервис для управления пользователями в LDAP и Django.
Координирует работу подсервисов:
- UserPasswordService — пароли
- UserLoginService — генерация логинов
@ -64,7 +64,7 @@ class UserService(BaseService):
):
"""
Инициализация сервиса.
Args:
ldap_repository: Репозиторий для работы с LDAP (optional)
employee_repository: Репозиторий для работы с Employee (optional)
@ -74,7 +74,7 @@ class UserService(BaseService):
self.ldap_repository = ldap_repository
self.employee_repository = employee_repository
self.sync_state_repository = sync_state_repository
# Подсервисы
self._password_service = UserPasswordService()
self._login_service = UserLoginService()
@ -137,7 +137,7 @@ class UserService(BaseService):
is_active=dto.is_active,
is_ldap_managed=True,
)
if hasattr(emp, "set_unusable_password"):
emp.set_unusable_password()
emp._skip_ldap_sync = True
@ -171,7 +171,7 @@ class UserService(BaseService):
# ).first()
# if dept:
# emp.set_active_department(dept)
self._log_operation(
"create",
model="employee",
@ -240,12 +240,12 @@ class UserService(BaseService):
is_active_val = bool(changes.get("is_active"))
except Exception:
is_active_val = None
# Убираем position из changes — синхронизация должностей
# через LDAP группы пока отключена (TODO)
changes.pop("position", None)
changes.pop("position_id", None)
# Извлекаем position из changes — обработаем отдельно
new_position = changes.pop("position", None)
new_position_id = changes.pop("position_id", None)
old_position = emp.position
with _ldap() as conn:
ldap_changes = dict(changes)
try:
@ -296,6 +296,20 @@ class UserService(BaseService):
setattr(emp, k, v)
updated_fields.append(k)
# Обновляем position в DB
if new_position_id is not None:
if emp.position_id != new_position_id:
emp.position_id = new_position_id
updated_fields.append("position_id")
elif new_position is not None:
pid = (
new_position.pk
if new_position else None
)
if emp.position_id != pid:
emp.position_id = pid
updated_fields.append("position_id")
if move_to_department_dn and hasattr(emp, "set_active_department"):
from .department_service import DepartmentService
dept_svc = DepartmentService()
@ -317,10 +331,12 @@ class UserService(BaseService):
emp.save(update_fields=list(set(updated_fields)))
except Exception as e:
raise DirectoryDbError(str(e)) from e
# TODO: Position group membership sync disabled
# Нужно реализовать через GroupService
# (DirectoryService был удалён)
# Position group membership sync (best-effort)
self._sync_position_groups(
emp, old_position,
new_position, new_position_id,
)
return emp
@ -526,9 +542,57 @@ class UserService(BaseService):
return dn_try
return None
def _sync_position_groups(
self,
emp: Employee,
old_position,
new_position,
new_position_id,
) -> None:
"""Синхронизирует членство в POS-группах при смене должности.
Best-effort: ошибки логируются, но не прерывают
обновление пользователя.
"""
from ...models import Position
from .position_service import PositionService
from .group_service import GroupService
# Определяем новую должность
if new_position is None and new_position_id is not None:
try:
new_position = Position.objects.get(
pk=new_position_id,
)
except Position.DoesNotExist:
new_position = None
if new_position is None and new_position_id is None:
# position не передавался в changes
return
if old_position == new_position:
return
try:
pos_svc = PositionService(
group_service=GroupService(),
user_service=self,
)
if old_position and old_position.ldap_group_dn:
pos_svc.unassign_position(emp, old_position)
if new_position and new_position.ldap_group_dn:
pos_svc.assign_position(emp, new_position)
except Exception:
logger.warning(
"Position group sync failed for "
f"employee #{emp.pk}",
exc_info=True,
)
def _create_user_in_ldap(self, conn: Connection, dto: DirectoryUserDTO) -> str:
"""Создаёт объект user в LDAP (AD) и возвращает его DN.
NOTE: Используется low-level ldap3 (conn.add) из-за необходимости:
- Перебора вариантов CN при коллизиях имён
- Проверки/создания контейнера (ensure_container_exists)
@ -561,7 +625,7 @@ class UserService(BaseService):
cn_list = cn_candidates(pretty_cn, safe_cn)
object_classes = self._mapper_service.get_object_classes_for_user()
dn = self._try_add_with_cn_list(
conn, base_dn, object_classes, dto, sam, upn, cn_list
)
@ -595,10 +659,10 @@ class UserService(BaseService):
emp_pk: Optional[int] = None, # Employee PK для поиска по employeeNumber
) -> str:
"""Применяет LDAP-изменения к существующему пользователю и возвращает (возможный новый) DN.
NOTE: Этот метод слишком большой (~150 строк) и должен быть рефакторен в будущем.
Сейчас просто перенесём как есть для совместимости.
Args:
conn: LDAP соединение
current_dn: Текущий DN пользователя (может быть устаревшим)
@ -608,7 +672,7 @@ class UserService(BaseService):
emp_pk: PK Employee для поиска по employeeNumber если DN устарел
"""
# TODO: DirectoryService был удален, требуется рефакторинг
if not isinstance(current_dn, str) or not current_dn.strip():
raise TypeError("current_dn должен быть непустой строкой")
if not isinstance(model_changes, dict):
@ -623,10 +687,8 @@ class UserService(BaseService):
if new_password:
self._set_password(conn, dn, new_password)
# 2) Перемещение
# TODO: DirectoryService был удален, требуется рефакторинг
# if move_to_department_dn:
# dn = svc._move_to_department(conn, dn, move_to_department_dn)
# 2) Перемещение между OU — обработается в ORM save(),
# см. шаг 3-5 ниже (ldap_user.base_dn = ...)
# 3-5) ORM: UAC + Атрибуты + Аватар (batch через один save)
try:
@ -659,9 +721,15 @@ class UserService(BaseService):
f"User not found at DN={dn}. DN in database doesn't match LDAP. "
"Please run LDAP sync to fix sync state."
)
orm_dirty = False
# 2) Перемещение между OU через ORM
# ModifyDnMixin.save() автоматически выполнит modify_dn
if move_to_department_dn:
ldap_user.base_dn = move_to_department_dn
orm_dirty = True
# 3) UAC
if is_active_val is not None:
uac_val = (
@ -685,33 +753,33 @@ class UserService(BaseService):
),
"display_name": "display_name",
}
# Запоминаем старый cn для проверки изменения
old_cn = ldap_user.cn
for model_key, orm_field in orm_field_map.items():
if model_key in model_changes:
val = model_changes[model_key]
if val or orm_field == "sn":
setattr(ldap_user, orm_field, val if val else ".")
orm_dirty = True
# Вычисляем новый cn если изменились имя/фамилия
new_cn = None
if "first_name" in model_changes or "last_name" in model_changes:
parts = []
given_name_str = get_ldap_str(ldap_user.given_name).strip()
sn_str = get_ldap_str(ldap_user.sn).strip()
if given_name_str:
parts.append(given_name_str)
if sn_str and sn_str != ".":
parts.append(sn_str)
new_cn = " ".join(parts) if parts else get_ldap_str(
ldap_user.sam_account_name
)
if new_cn != old_cn:
# Устанавливаем cn и displayName —
# ModifyDnMixin.save() автоматически обработает
@ -747,50 +815,50 @@ class UserService(BaseService):
def _get_employee_dn(self, employee: Employee) -> str:
"""Возвращает DN сотрудника из LdapSyncState или модели.
Args:
employee: Экземпляр Employee.
Returns:
str: Полный DN пользователя в LDAP.
Raises:
DirectoryServiceError: Если DN не найден.
"""
# Сначала проверяем LdapSyncState
dn = (
LdapSyncState.objects.filter(
model="employee",
model="employee",
object_pk=str(employee.pk)
)
.values_list("ldap_dn", flat=True)
.first()
)
# Если не найден в LdapSyncState, проверяем поле модели
if not dn and hasattr(employee, 'ldap_dn'):
dn = employee.ldap_dn
if not dn:
raise DirectoryServiceError("Employee has no ldap_dn")
return dn
def _move_user_to_base(self, conn: Connection, user_dn: str, base_dn: str) -> str:
"""Перемещает пользовательский объект в указанный контейнер.
NOTE: Используется low-level ldap3 для атомарного move-only
(без обновления атрибутов). ModifyDnMixin решает ту же задачу
через ORM (user.base_dn = ...; user.save()), но совмещает
move с обновлением атрибутов.
Args:
conn: LDAP соединение.
user_dn: Текущий DN пользователя.
base_dn: DN целевого контейнера.
Returns:
str: Новый DN пользователя после перемещения.
Raises:
RuntimeError: Если операция перемещения не удалась.
"""