- 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
141 lines
5.0 KiB
Python
141 lines
5.0 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Iterable, Optional
|
|
|
|
from django.conf import settings
|
|
from ldap3 import BASE, SUBTREE, Connection
|
|
|
|
from employees.models import Employee
|
|
from .text_utils import esc_filter
|
|
|
|
|
|
def _read_user_memberof_dns(conn: Connection, user_dn: str) -> set[str]:
|
|
"""Возвращает текущее множество DN групп (memberOf) пользователя."""
|
|
conn.search(user_dn, "(objectClass=*)", BASE, attributes=["memberOf"])
|
|
if not conn.entries:
|
|
return set()
|
|
raw = getattr(conn.entries[0], "memberOf", [])
|
|
vals = raw.values if hasattr(raw, "values") else raw
|
|
return {str(x) for x in (vals or [])}
|
|
|
|
|
|
def _group_add_member(
|
|
conn: Connection, group_dn: str, user_dn: str, *, do_write: bool
|
|
) -> None:
|
|
"""Добавляет пользователя в группу (или пропускает в dry-run)."""
|
|
from ldap3 import MODIFY_ADD
|
|
|
|
if not do_write:
|
|
return
|
|
ok = conn.modify(group_dn, {"member": [(MODIFY_ADD, [user_dn])]})
|
|
if not ok and conn.result.get("description") not in {"typeOrValueExists"}:
|
|
raise RuntimeError(f"Add member failed: {group_dn}: {conn.result}")
|
|
|
|
|
|
def _group_remove_member(
|
|
conn: Connection, group_dn: str, user_dn: str, *, do_write: bool
|
|
) -> None:
|
|
"""Удаляет пользователя из группы (или пропускает в dry-run)."""
|
|
from ldap3 import MODIFY_DELETE
|
|
|
|
if not do_write:
|
|
return
|
|
ok = conn.modify(group_dn, {"member": [(MODIFY_DELETE, [user_dn])]})
|
|
if not ok and conn.result.get("description") not in {"noSuchAttribute"}:
|
|
raise RuntimeError(f"Remove member failed: {group_dn}: {conn.result}")
|
|
|
|
|
|
def _resolve_group_dns_by_cn(
|
|
conn: Connection, cns: set[str], extra_bases: Optional[list[str]] = None
|
|
) -> dict[str, str]:
|
|
"""Ищет DN групп по их CN в OU=Groups и дополнительных базах (например, Roles отдела)."""
|
|
if not cns:
|
|
return {}
|
|
bases = [getattr(settings, "LDAP_GROUPS_BASE", "")]
|
|
if extra_bases:
|
|
bases.extend(extra_bases)
|
|
found: dict[str, str] = {}
|
|
for base in bases:
|
|
if not base:
|
|
continue
|
|
for cn in sorted(cns - set(found.keys())):
|
|
conn.search(
|
|
base,
|
|
f"(&(objectClass=group)(cn={esc_filter(cn)}))",
|
|
SUBTREE,
|
|
attributes=["distinguishedName", "cn"],
|
|
)
|
|
if conn.entries:
|
|
found[cn] = str(conn.entries[0].entry_dn)
|
|
return found
|
|
|
|
|
|
def _desired_group_cns_for_employee(emp: "Employee") -> set[str]:
|
|
"""Возвращает целевые CN групп для сотрудника из Django (Position/DeptRole/Direct)."""
|
|
cns: set[str] = set()
|
|
pos = getattr(emp, "position", None)
|
|
if pos is not None and hasattr(pos, "groups"):
|
|
cns |= {g.name for g in pos.groups.all()}
|
|
user_obj = getattr(emp, "user", None) or emp
|
|
if hasattr(user_obj, "groups"):
|
|
cns |= {g.name for g in user_obj.groups.all()}
|
|
dept_roles = getattr(emp, "department_roles", None)
|
|
if dept_roles is not None and hasattr(dept_roles, "all"):
|
|
for dr in dept_roles.all():
|
|
if hasattr(dr, "groups"):
|
|
cns |= {g.name for g in dr.groups.all()}
|
|
return cns
|
|
|
|
|
|
def sync_user_groups_by_cns(
|
|
conn: Connection,
|
|
user_dn: str,
|
|
target_cns: Iterable[str],
|
|
*,
|
|
extra_bases: list[str] | None = None,
|
|
do_write: bool = True,
|
|
) -> tuple[int, int]:
|
|
"""Приводит memberOf пользователя к точному набору CN (добавляет/удаляет).
|
|
|
|
Args:
|
|
conn: соединение LDAP.
|
|
user_dn: DN пользователя.
|
|
target_cns: итоговый набор CN групп.
|
|
extra_bases: доп. базы поиска групп (например, OU=Roles,<dept>).
|
|
do_write: False => только расчёт.
|
|
|
|
Returns:
|
|
(added, removed).
|
|
|
|
Raises:
|
|
RuntimeError: если операции LDAP завершаются ошибкой.
|
|
"""
|
|
desired_dns = set(
|
|
_resolve_group_dns_by_cn(
|
|
conn, set(target_cns), extra_bases=extra_bases
|
|
).values()
|
|
)
|
|
current_dns = _read_user_memberof_dns(conn, user_dn)
|
|
|
|
# ограничим удаление только «наших» веток (Groups/OU=Roles),
|
|
# как и в батч-логике
|
|
groups_base = getattr(settings, "LDAP_GROUPS_BASE", "")
|
|
to_add = desired_dns - current_dns
|
|
to_del = {
|
|
dn
|
|
for dn in (current_dns - desired_dns)
|
|
if (groups_base and dn.endswith(groups_base)) or "OU=Roles," in dn
|
|
}
|
|
|
|
added = removed = 0
|
|
if do_write:
|
|
for gdn in sorted(to_add):
|
|
_group_add_member(conn, gdn, user_dn, do_write=True)
|
|
added += 1
|
|
for gdn in sorted(to_del):
|
|
_group_remove_member(conn, gdn, user_dn, do_write=True)
|
|
removed += 1
|
|
else:
|
|
added, removed = len(to_add), len(to_del)
|
|
return added, removed
|