- Replaced feed-* inline styles in base.html with feed-cards.css component (~60 lines) - Replaced ios-search inline styles in 3 templates with ios-search.css component * employees/employees_list.html * search/results.html * employees/department_list.html - Eliminated ~150 lines of duplicate CSS across templates - All 17 tests passing
390 lines
16 KiB
Python
390 lines
16 KiB
Python
"""
|
||
Сервис для работы с должностями (Position) в Active Directory.
|
||
|
||
Обрабатывает создание, обновление, удаление POS-групп (агрегаторных групп должностей),
|
||
управление участниками (сотрудники с должностью), вложение POS-групп в целевые группы.
|
||
"""
|
||
|
||
from typing import Optional, Set
|
||
|
||
from django.conf import settings
|
||
from django.contrib.auth.models import Group
|
||
from ldap3 import BASE, SUBTREE, Connection
|
||
|
||
from employees.models import Employee, Position, LdapSyncState
|
||
|
||
from ..errors import DirectoryServiceError
|
||
from ..repositories.ldap_repository import ensure_container_exists
|
||
from ..utils.text_utils import esc_filter, esc_rdn
|
||
from ..utils.ldap_utils import group_type
|
||
|
||
|
||
class PositionService:
|
||
"""
|
||
Сервис для работы с должностями (Position) в Active Directory.
|
||
|
||
Отвечает за:
|
||
- Создание и управление POS-группами (CN=POS_<name>, OU=Positions)
|
||
- Назначение/снятие должностей для сотрудников
|
||
- Вложение POS-групп в целевые группы (из Position.groups)
|
||
- Синхронизацию состава участников POS-группы
|
||
|
||
Зависимости (через Dependency Injection):
|
||
- GroupService: для операций с группами (add_members, remove_members, replace_members)
|
||
- UserService: для получения DN сотрудника
|
||
"""
|
||
|
||
def __init__(self, group_service=None, user_service=None):
|
||
"""
|
||
Инициализация PositionService.
|
||
|
||
Args:
|
||
group_service: Сервис для работы с группами (для управления членством)
|
||
user_service: Сервис для работы с пользователями (для получения DN)
|
||
"""
|
||
self._group_service = group_service
|
||
self._user_service = user_service
|
||
|
||
# ======================== PUBLIC API ======================== #
|
||
|
||
def reconcile_position(self, pos: Position) -> str:
|
||
"""
|
||
Публичный метод — открыть соединение и привести POS к консистентности.
|
||
|
||
Приводит должность к консистентному состоянию:
|
||
1) Создаёт/обновляет POS-группу (CN=POS_<name>)
|
||
2) Вкладывает POS-группу в целевые группы (из pos.groups)
|
||
3) Синхронизирует участников POS-группы (сотрудники с этой должностью)
|
||
|
||
Args:
|
||
pos: Должность для синхронизации
|
||
|
||
Returns:
|
||
DN POS-группы
|
||
|
||
Raises:
|
||
RuntimeError: Если LDAP_POSITIONS_BASE не настроен
|
||
ValueError: Если имя должности пустое
|
||
"""
|
||
from ..infrastructure.connections import _ldap
|
||
|
||
with _ldap() as conn:
|
||
return self._reconcile_position(conn, pos)
|
||
|
||
def assign_position(self, employee: Employee, position: Position) -> None:
|
||
"""
|
||
Добавляет сотрудника в POS-группу должности и убеждается, что вложения POS в целевые группы актуальны.
|
||
|
||
Args:
|
||
employee: Сотрудник
|
||
position: Должность
|
||
|
||
Raises:
|
||
DirectoryServiceError: Если DN сотрудника не найден
|
||
RuntimeError: Если операция LDAP не удалась
|
||
"""
|
||
from ..infrastructure.connections import _ldap
|
||
|
||
emp_dn = self._user_service._get_employee_dn(employee)
|
||
with _ldap() as conn:
|
||
pos_dn = self._ensure_position_group(conn, position)
|
||
self._group_service.add_members(conn, pos_dn, [emp_dn])
|
||
# вложения можно обновлять best-effort, не обязательно каждый раз
|
||
try:
|
||
self._reconcile_position_nesting(conn, position)
|
||
except Exception:
|
||
pass
|
||
|
||
def unassign_position(self, employee: Employee, position: Position) -> None:
|
||
"""
|
||
Удаляет сотрудника из POS-группы должности.
|
||
|
||
Args:
|
||
employee: Сотрудник
|
||
position: Должность
|
||
"""
|
||
from ..infrastructure.connections import _ldap
|
||
|
||
try:
|
||
emp_dn = self._user_service._get_employee_dn(employee)
|
||
except DirectoryServiceError:
|
||
emp_dn = None
|
||
if not emp_dn:
|
||
return
|
||
with _ldap() as conn:
|
||
pos_dn = self._ensure_position_group(conn, position)
|
||
self._group_service.remove_members(conn, pos_dn, [emp_dn])
|
||
|
||
def delete_position_group(self, position: Position) -> None:
|
||
"""
|
||
Best-effort: снять POS-группу из всех правовых групп и удалить её.
|
||
|
||
Args:
|
||
position: Должность с POS-группой для удаления
|
||
"""
|
||
from ..infrastructure.connections import _ldap
|
||
|
||
with _ldap() as conn:
|
||
dn = (position.ldap_group_dn or "").strip()
|
||
if not dn:
|
||
return
|
||
# снять вложения
|
||
bases = [
|
||
b
|
||
for b in [
|
||
getattr(settings, "LDAP_GROUPS_BASE", None),
|
||
getattr(settings, "LDAP_BASE_DN", None),
|
||
]
|
||
if b
|
||
]
|
||
for base in bases:
|
||
ok = conn.search(
|
||
search_base=base,
|
||
search_filter=f"(&(objectClass=group)(member={esc_filter(dn)}))",
|
||
search_scope=SUBTREE,
|
||
attributes=["distinguishedName"],
|
||
)
|
||
if ok and conn.entries:
|
||
for e in conn.entries:
|
||
self._group_service.remove_members(conn, str(e.entry_dn), [dn])
|
||
# удалить саму POS-группу
|
||
try:
|
||
self._group_service.delete(conn, dn)
|
||
except Exception:
|
||
pass
|
||
|
||
# ======================== PRIVATE HELPERS ======================== #
|
||
|
||
def _positions_base(self) -> str:
|
||
"""
|
||
Получить базовый DN для контейнера должностей (OU=Positions).
|
||
|
||
Returns:
|
||
DN контейнера должностей из настроек LDAP_POSITIONS_BASE
|
||
|
||
Raises:
|
||
RuntimeError: Если LDAP_POSITIONS_BASE не настроен
|
||
"""
|
||
base = getattr(settings, "LDAP_POSITIONS_BASE", None)
|
||
if not base:
|
||
raise RuntimeError("LDAP_POSITIONS_BASE is not configured")
|
||
return base
|
||
|
||
def _ensure_positions_base(self, conn: Connection) -> str:
|
||
"""
|
||
Гарантировать наличие контейнера OU=Positions в AD.
|
||
|
||
Args:
|
||
conn: Открытое LDAP-соединение
|
||
|
||
Returns:
|
||
DN контейнера должностей
|
||
|
||
Raises:
|
||
RuntimeError: Если создание контейнера не удалось
|
||
"""
|
||
base = self._positions_base()
|
||
ensure_container_exists(conn, base)
|
||
return base
|
||
|
||
def _ensure_position_group(self, conn: Connection, pos: Position) -> str:
|
||
"""
|
||
Гарантирует наличие агрегаторной группы должности: CN=POS_<name>,OU=Positions,...
|
||
Обновляет pos.ldap_group_dn при необходимости и возвращает DN.
|
||
|
||
Логика:
|
||
1. Если pos.ldap_group_dn существует - проверить существование и актуальность CN
|
||
2. Если нет - искать по CN=POS_<name> в OU=Positions
|
||
3. Если не найдено - создать новую POS-группу
|
||
4. Обновить pos.ldap_group_dn в БД при изменениях
|
||
|
||
Args:
|
||
conn: Открытое LDAP-соединение
|
||
pos: Должность
|
||
|
||
Returns:
|
||
DN POS-группы должности
|
||
|
||
Raises:
|
||
ValueError: Если имя должности пустое
|
||
RuntimeError: Если операция LDAP не удалась
|
||
"""
|
||
ensure_container_exists(conn, self._positions_base())
|
||
name = (pos.name or "").strip()
|
||
if not name:
|
||
raise ValueError("Position.name is empty")
|
||
|
||
expected_cn = f"POS_{name}"
|
||
expected_rdn = f"CN={esc_rdn(expected_cn)}"
|
||
|
||
saved_dn = (pos.ldap_group_dn or "").strip()
|
||
if saved_dn:
|
||
ok = conn.search(
|
||
saved_dn,
|
||
"(objectClass=group)",
|
||
search_scope=BASE,
|
||
attributes=["distinguishedName"],
|
||
)
|
||
if ok and conn.entries:
|
||
cur_rdn = saved_dn.split(",", 1)[0]
|
||
if cur_rdn != expected_rdn:
|
||
if not conn.modify_dn(saved_dn, expected_rdn):
|
||
raise RuntimeError(
|
||
f"LDAP rename POS group failed: {conn.result}"
|
||
)
|
||
base = ",".join(saved_dn.split(",")[1:])
|
||
new_dn = f"{expected_rdn},{base}"
|
||
else:
|
||
new_dn = saved_dn
|
||
if new_dn != pos.ldap_group_dn:
|
||
Position.objects.filter(pk=pos.pk).update(ldap_group_dn=new_dn)
|
||
pos.ldap_group_dn = new_dn
|
||
return new_dn
|
||
|
||
# поиск по CN в OU=Positions
|
||
ok = conn.search(
|
||
self._positions_base(),
|
||
f"(&(objectClass=group)(cn={esc_filter(expected_cn)}))",
|
||
search_scope=SUBTREE,
|
||
attributes=["distinguishedName"],
|
||
)
|
||
if ok and conn.entries:
|
||
dn = str(conn.entries[0].entry_dn)
|
||
if dn != saved_dn:
|
||
Position.objects.filter(pk=pos.pk).update(ldap_group_dn=dn)
|
||
pos.ldap_group_dn = dn
|
||
return dn
|
||
|
||
# создаём
|
||
dn = f"{expected_rdn},{self._positions_base()}"
|
||
attrs = {
|
||
"cn": expected_cn,
|
||
"sAMAccountName": expected_cn,
|
||
"groupType": group_type("global", True),
|
||
"description": f"{name} position members",
|
||
}
|
||
ok = conn.add(dn, ["top", "group"], attrs)
|
||
if not ok:
|
||
raise RuntimeError(f"LDAP add POS group failed: {conn.result}")
|
||
Position.objects.filter(pk=pos.pk).update(ldap_group_dn=dn)
|
||
pos.ldap_group_dn = dn
|
||
return dn
|
||
|
||
def _reconcile_position_nesting(self, conn: Connection, position: Position) -> str:
|
||
"""
|
||
Делает так, чтобы POS-группа должности была участником ровно тех AD-групп,
|
||
что привязаны к Position.groups.
|
||
|
||
Логика:
|
||
1. Получить список целевых групп из position.groups (Django Group -> DN в AD)
|
||
2. Получить список текущих групп, где POS-группа уже состоит
|
||
3. Добавить POS-группу в недостающие целевые группы
|
||
4. Удалить POS-группу из лишних групп
|
||
|
||
Args:
|
||
conn: Открытое LDAP-соединение
|
||
position: Должность
|
||
|
||
Returns:
|
||
DN POS-группы должности
|
||
|
||
Raises:
|
||
RuntimeError: Если операция LDAP не удалась
|
||
"""
|
||
pos_dn = self._ensure_position_group(conn, position)
|
||
|
||
# желаемые группы (по именам Django Group -> CN в AD)
|
||
desired_dns: Set[str] = set()
|
||
for g in position.groups.all():
|
||
dn = self._group_service.find_dn(
|
||
conn,
|
||
g.name,
|
||
bases=[
|
||
getattr(settings, "LDAP_GROUPS_BASE", None)
|
||
or getattr(settings, "LDAP_BASE_DN", "")
|
||
],
|
||
)
|
||
if dn:
|
||
desired_dns.add(dn)
|
||
|
||
# текущие группы, где POS уже состоит
|
||
current_dns: Set[str] = set()
|
||
search_bases = [
|
||
b
|
||
for b in [
|
||
getattr(settings, "LDAP_GROUPS_BASE", None),
|
||
getattr(settings, "LDAP_BASE_DN", None),
|
||
]
|
||
if b
|
||
]
|
||
for base in search_bases:
|
||
ok = conn.search(
|
||
search_base=base,
|
||
search_filter=f"(&(objectClass=group)(member={esc_filter(pos_dn)}))",
|
||
search_scope=SUBTREE,
|
||
attributes=["distinguishedName"],
|
||
)
|
||
if ok and conn.entries:
|
||
current_dns.update(str(e.entry_dn) for e in conn.entries)
|
||
|
||
# добавить недостающее
|
||
for add_dn in desired_dns - current_dns:
|
||
self._group_service.add_members(conn, add_dn, [pos_dn])
|
||
|
||
# снять лишнее
|
||
for rem_dn in current_dns - desired_dns:
|
||
self._group_service.remove_members(conn, rem_dn, [pos_dn])
|
||
|
||
return pos_dn
|
||
|
||
def _reconcile_position(self, conn: Connection, pos: Position) -> str:
|
||
"""
|
||
Приводит к консистентности:
|
||
1) наличие POS_* группы,
|
||
2) вложение POS_* в группы из pos.groups,
|
||
3) состав участников POS_* = сотрудники с этой должностью.
|
||
Возвращает DN POS_* группы.
|
||
|
||
Args:
|
||
conn: Открытое LDAP-соединение
|
||
pos: Должность для синхронизации
|
||
|
||
Returns:
|
||
DN POS-группы должности
|
||
|
||
Raises:
|
||
RuntimeError: Если операция LDAP не удалась
|
||
"""
|
||
pos_dn = self._ensure_position_group(conn, pos)
|
||
|
||
# 2) вложение POS_* в target-группы
|
||
expected_container_dns: Set[str] = set()
|
||
for g in pos.groups.all():
|
||
dn = self._group_service.find_dn(conn, g.name) # ищем по CN группы
|
||
if dn:
|
||
expected_container_dns.add(dn)
|
||
|
||
current_container_dns = self._group_service.groups_with_member(conn, pos_dn)
|
||
to_add = expected_container_dns - current_container_dns
|
||
to_del = current_container_dns - expected_container_dns
|
||
|
||
for dn in to_add:
|
||
self._group_service.add_members(conn, dn, [pos_dn])
|
||
for dn in to_del:
|
||
self._group_service.remove_members(conn, dn, [pos_dn])
|
||
|
||
# 3) участники POS_* = сотрудники с этой позицией
|
||
emp_ids = list(
|
||
Employee.objects.filter(position_id=pos.id, is_active=True).values_list(
|
||
"id", flat=True
|
||
)
|
||
)
|
||
dn_map = dict(
|
||
LdapSyncState.objects.filter(
|
||
model="employee", object_pk__in=[str(i) for i in emp_ids]
|
||
).values_list("object_pk", "ldap_dn")
|
||
)
|
||
member_dns = [dn for dn in dn_map.values() if dn]
|
||
self._group_service.replace_members(conn, pos_dn, member_dns)
|
||
return pos_dn
|