Игорь Надеин 4d04aafdb9 refactor: Replace inline CSS with components (Phase 3)
- 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
2025-10-28 08:54:38 +03:00

273 lines
9.3 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.

"""Утилиты для работы с Distinguished Names (DN).
Функции для парсинга, преобразования и манипуляций с LDAP Distinguished Names.
"""
from __future__ import annotations
from typing import Optional, Sequence
from django.conf import settings
from ldap3 import BASE, SUBTREE, Connection
from ..errors import DirectoryLdapError, DirectoryServiceError
from employees.models import Employee, LdapSyncState
def extract_department_from_dn(
dn: str, *, departments_anchor: str = "OU=Departments"
) -> Optional[str]:
"""Возвращает имя отдела из DN при контейнерной модели.
Ищет сегмент OU=Departments и берёт OU слева от него как название отдела.
Args:
dn (str): Distinguished Name пользователя.
departments_anchor (str): Маркер корневого OU отделов.
Returns:
Optional[str]: Имя отдела или None, если пользователь не под
OU=Departments.
Raises:
TypeError: Если dn не строка.
ValueError: Если dn пустой.
"""
if not isinstance(dn, str):
raise TypeError("dn должен быть строкой")
dn = dn.strip()
if not dn:
raise ValueError("пустой DN")
parts: Sequence[str] = [p.strip() for p in dn.split(",") if p.strip()]
# точное совпадение полного сегмента
try:
idx = parts.index(departments_anchor)
except ValueError:
# fallback: искать просто 'OU=Departments' без хвоста
try:
idx = next(
i
for i, p in enumerate(parts)
if p.strip().lower() == "ou=departments"
)
except StopIteration:
return None
dept_idx = idx - 1
if dept_idx < 0 or not parts[dept_idx].startswith("OU="):
return None
value = parts[dept_idx][3:].strip()
return value or None
def rewrite_dn_suffix(dn: str, old_suffix: str, new_suffix: str) -> str:
"""Заменяет хвост DN c old_suffix на new_suffix.
Args:
dn (str): Distinguished Name.
old_suffix (str): Старый суффикс (например, "DC=old,DC=com").
new_suffix (str): Новый суффикс (например, "DC=new,DC=com").
Returns:
str: DN с новым суффиксом или исходный DN, если не найдено совпадение.
"""
if not dn or not dn.lower().endswith(old_suffix.lower()):
return dn
return dn[: -len(old_suffix)] + new_suffix
def _target_department_ou_dn(dept_name: str) -> str:
"""Строит DN OU отдела: OU=<dept>,<DEPARTMENTS_BASE>.
Args:
dept_name (str): Имя отдела.
Returns:
str: Полный DN отдела.
"""
base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "")
return f"OU={dept_name},{base}"
def _ensure_user_dn(conn: Connection, emp: Employee) -> str:
"""Находит DN пользователя в LDAP по различным идентификаторам.
Алгоритм:
1) Проверяет DN из LdapSyncState
2) Ищет по email, UPN, sAMAccountName
3) Ищет по givenName+sn, displayName
Args:
conn (Connection): LDAP-соединение.
emp (Employee): Объект сотрудника Django.
Returns:
str: DN пользователя в LDAP.
Raises:
RuntimeError: Если DN не найден.
"""
from .text_utils import esc_filter
# 0) валидируем DN из LdapSyncState
st = (
LdapSyncState.objects.filter(model="employee", object_pk=str(emp.pk))
.values("ldap_dn", "ldap_guid")
.first()
) or {}
state_dn = (st.get("ldap_dn") or "").strip()
if state_dn:
conn.search(
state_dn, "(objectClass=user)", BASE, attributes=["distinguishedName"]
)
if conn.entries:
return state_dn
# 1) собираем базы
bases: list[str] = []
for key in ("LDAP_USERS_BASE", "LDAP_DEPARTMENTS_BASE", "LDAP_BASE_DN"):
val = getattr(settings, key, "")
if val and val not in bases:
bases.append(val)
# 2) строим фильтры (mail, upn, sam, cn из state_dn, given+sn, displayName)
filters: list[str] = []
if emp.email:
filters.append(
f"(&(objectCategory=person)(objectClass=user)"
f"(mail={esc_filter(emp.email)}))"
)
filters.append(
f"(&(objectCategory=person)(objectClass=user)"
f"(userPrincipalName={esc_filter(emp.email)}))"
)
local = emp.email.split("@", 1)[0]
if local:
filters.append(
f"(&(objectCategory=person)(objectClass=user)"
f"(sAMAccountName={esc_filter(local)}))"
)
# CN из DN sync-state (если он был)
if state_dn:
cn_part = state_dn.split(",", 1)[0]
if cn_part.upper().startswith("CN="):
cn_val = cn_part[3:]
filters.append(
f"(&(objectCategory=person)(objectClass=user)"
f"(cn={esc_filter(cn_val)}))"
)
if emp.first_name and emp.last_name:
filters.append(
f"(&(objectCategory=person)(objectClass=user)"
f"(givenName={esc_filter(emp.first_name)})"
f"(sn={esc_filter(emp.last_name)}))"
)
disp = f"{emp.first_name} {emp.last_name}".strip()
filters.append(
f"(&(objectCategory=person)(objectClass=user)"
f"(displayName={esc_filter(disp)}))"
)
# 3) поиск
for flt in filters:
for base in bases:
if (
conn.search(base, flt, SUBTREE, attributes=["distinguishedName"])
and conn.entries
):
return str(conn.entries[0].entry_dn)
raise RuntimeError(
f"LDAP DN не найден для employee id={emp.pk}, email={emp.email!r}. "
"Обновите DN импортом из LDAP или скорректируйте идентификаторы "
"(mail/UPN/sAM/cn)."
)
def _move_to_department(conn: Connection, emp_dn: str, dept: str) -> str:
"""Перемещает запись сотрудника в OU отдела с помощью LDAP ModifyDN.
Алгоритм:
1) Проверяем существование целевого OU (BASE-search по dept_dn).
2) Если сотрудник уже под нужным OU — ничего не делаем.
3) Вызываем modify_dn(dn, relative_dn=<старый RDN>,
new_superior=<dept_dn>, delete_old_dn=True).
4) Возвращаем новый DN ("<старый RDN>,<dept_dn>").
Args:
conn: Открытое LDAP-соединение (ldap3.Connection).
emp_dn: Полный DN сотрудника (например,
"CN=John Doe,OU=Users,DC=...").
dept: Имя отдела или полный DN OU отдела (например,
"OU=IT,OU=Departments,DC=...").
Returns:
str: Новый DN сотрудника после перемещения.
Raises:
DirectoryLdapError: Если OU не найден или операция ModifyDN
завершилась ошибкой.
DirectoryServiceError: Если DN имеет неверный формат.
"""
dept_dn = (
dept
if "=" in dept and dept.upper().startswith("OU=")
else _target_department_ou_dn(dept)
)
# 1) Проверяем, что целевой OU существует
try:
ok = conn.search(
search_base=dept_dn,
search_filter="(objectClass=organizationalUnit)",
search_scope=BASE,
attributes=["distinguishedName"],
)
except Exception as e:
raise DirectoryLdapError(f"Failed to lookup target OU: {e}") from e
if not ok or len(getattr(conn, "entries", [])) == 0:
raise DirectoryLdapError(f"Target OU not found: {dept_dn}")
# 2) Разбираем DN сотрудника на RDN и родителя
parts = emp_dn.split(",", 1)
if len(parts) != 2:
raise DirectoryServiceError(f"Malformed DN: {emp_dn}")
emp_rdn, emp_parent = parts[0], parts[1]
# Уже под нужным OU?
if emp_parent == dept_dn:
return emp_dn
# 3) Делаем перемещение через ModifyDN
try:
res = conn.modify_dn(
dn=emp_dn,
relative_dn=emp_rdn,
delete_old_dn=True,
new_superior=dept_dn,
)
if not res:
err = getattr(conn, "last_error", None) or getattr(
conn, "result", None
)
raise RuntimeError(err)
except Exception as e:
raise DirectoryLdapError(f"LDAP modifyDN (move) failed: {e}") from e
# 4) Возвращаем новый DN
new_dn = f"{emp_rdn},{dept_dn}"
return new_dn
__all__ = [
"extract_department_from_dn",
"rewrite_dn_suffix",
"_target_department_ou_dn",
"_ensure_user_dn",
"_move_to_department",
]