diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 433f8feb..87f923bc 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -2282,7 +2282,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): - При отсутствии кэша - запрашивается из LDAP и сохраняется - force_refresh=true сбрасывает кэш и обновляет из LDAP """ - from employees.ldap.repositories.ldap_repository import LdapRepository + from employees.ldap.orm_models import LdapUser emp = self.get_object() force_refresh = request.query_params.get('force_refresh', '').lower() == 'true' @@ -2314,23 +2314,18 @@ class EmployeeViewSet(viewsets.ModelViewSet): status=status.HTTP_404_NOT_FOUND, ) - # Запрашиваем данные из LDAP + # Запрашиваем данные из LDAP через django-ldapdb ORM try: - conn = _conn() - ldap_repo = LdapRepository(conn) - attrs = ldap_repo.read_attrs( - ldap_sync.ldap_dn, - ["sAMAccountName"], - ) + ldap_user = LdapUser.objects.get(dn=ldap_sync.ldap_dn) - if not attrs or not attrs.get("sAMAccountName"): + sam_account_name = ldap_user.sam_account_name + if not sam_account_name: return Response( {"detail": "Не удалось получить LDAP информацию"}, status=status.HTTP_404_NOT_FOUND, ) # Кэшируем результат в username - sam_account_name = attrs.get("sAMAccountName") emp.username = sam_account_name emp.save(update_fields=["username"]) diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index 09ec437a..040946a2 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -235,18 +235,13 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): # Записываем Django PK в LDAP employeeNumber для обратной связки try: - from employees.ldap.infrastructure.connections import _ldap - from employees.ldap.repositories.ldap_repository import \ - modify_user_attrs + from employees.ldap.orm_models import LdapUser + ldap_user = LdapUser.objects.get(dn=dn) employee_id_attr = getattr( settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber") - with _ldap() as conn: - modify_user_attrs( - conn, dn, - {employee_id_attr: str(instance.pk)}, - do_write=True - ) + setattr(ldap_user, 'employee_number', str(instance.pk)) + ldap_user.save() except Exception as e: logger.warning(f"Failed to set employeeNumber in LDAP: {e}") @@ -532,7 +527,7 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): @action(detail=True, methods=["get"], url_path="ldap-info") def ldap_info(self, request, pk=None): """GET /api/v1/employees/{id}/ldap-info/ — LDAP информация о сотруднике.""" - from employees.ldap.repositories.ldap_repository import LdapRepository + from employees.ldap.orm_models import LdapUser emp = self.get_object() force_refresh = request.query_params.get( @@ -564,20 +559,15 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): ) try: - conn = _conn() - ldap_repo = LdapRepository(conn) - attrs = ldap_repo.read_attrs( - ldap_sync.ldap_dn, - ["sAMAccountName"], - ) + ldap_user = LdapUser.objects.get(dn=ldap_sync.ldap_dn) - if not attrs or not attrs.get("sAMAccountName"): + sam_account_name = ldap_user.sam_account_name + if not sam_account_name: return Response( {"detail": "Не удалось получить LDAP информацию"}, status=status.HTTP_404_NOT_FOUND, ) - sam_account_name = attrs.get("sAMAccountName") emp.username = sam_account_name emp.save(update_fields=["username"]) @@ -589,6 +579,11 @@ class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet): status=status.HTTP_200_OK, ) + except LdapUser.DoesNotExist: + return Response( + {"detail": "Не удалось получить LDAP информацию"}, + status=status.HTTP_404_NOT_FOUND, + ) except Exception as e: logger.error( f"Error fetching LDAP info for employee {emp.id}: {e}", exc_info=True diff --git a/backend/employees/ldap/__init__.py b/backend/employees/ldap/__init__.py index 21ea90c1..26ea6ad1 100644 --- a/backend/employees/ldap/__init__.py +++ b/backend/employees/ldap/__init__.py @@ -44,11 +44,7 @@ # Текущие импорты (для обратной совместимости) from .directory_service import DirectoryService -from .sync_service import ( - import_departments, - import_users, - export_users, -) +from .services.sync_service import SyncService # Конфигурация from .config import SyncConfig, SyncMode, SyncScope, DISABLED_FLAG @@ -70,10 +66,34 @@ from .domain.dtos import ( # Утилиты работы с группами from .utils.group_utils import sync_user_groups_by_cns +from .utils.group_utils_orm import sync_user_groups_by_cns_orm + +# ORM модели и сервисы +from .orm_models import LdapUser, LdapGroup, LdapOrganizationalUnit +from .orm_services import LdapOrmUserService, LdapOrmGroupService, LdapOrmDepartmentService # Подключения from .infrastructure.connections import _ldap + +# Обратная совместимость: обёртки над SyncService +def import_departments(cfg=None): + """Обёртка для SyncService().import_departments(cfg).""" + svc = SyncService() + return svc.import_departments(cfg or SyncConfig()) + + +def import_users(cfg=None): + """Обёртка для SyncService().import_users(cfg).""" + svc = SyncService() + return svc.import_users(cfg or SyncConfig()) + + +def export_users(cfg=None): + """Обёртка для SyncService().export_users(cfg).""" + svc = SyncService() + return svc.export_users(cfg or SyncConfig()) + __all__ = [ # Сервисы "DirectoryService", @@ -100,6 +120,15 @@ __all__ = [ # Утилиты "sync_user_groups_by_cns", + "sync_user_groups_by_cns_orm", + + # ORM + "LdapUser", + "LdapGroup", + "LdapOrganizationalUnit", + "LdapOrmUserService", + "LdapOrmGroupService", + "LdapOrmDepartmentService", # Подключения "_ldap", diff --git a/backend/employees/ldap/orm_models.py b/backend/employees/ldap/orm_models.py index fb83e0b3..e337ec5b 100644 --- a/backend/employees/ldap/orm_models.py +++ b/backend/employees/ldap/orm_models.py @@ -10,7 +10,19 @@ - Меньше кода, больше удобства """ +import datetime as _dt + from django.conf import settings +from django.utils import timezone as _dj_tz + +# Compatibility: django-ldapdb 1.5.1 uses timezone.utc removed in Django 5.x +if not hasattr(_dj_tz, 'utc'): + class _UtcCompat: + """pytz-compatible UTC stub for django-ldapdb.""" + def localize(self, dt): + return dt.replace(tzinfo=_dt.timezone.utc) + _dj_tz.utc = _UtcCompat() + from ldapdb.models import Model as LdapModel from ldapdb.models.fields import ( CharField, diff --git a/backend/employees/ldap/services/department_service.py b/backend/employees/ldap/services/department_service.py index 6501cc66..8d67c12a 100644 --- a/backend/employees/ldap/services/department_service.py +++ b/backend/employees/ldap/services/department_service.py @@ -20,11 +20,10 @@ from ..errors import ( DirectoryLdapError, DirectoryServiceError, ) -from ..utils.group_utils import sync_user_groups_by_cns +from ..orm_models import LdapOrganizationalUnit from ..infrastructure.connections import _ldap from ..repositories.ldap_repository import ( ensure_container_exists, - ldap_modify_or_ignore, ) from ..utils.dn_utils import _move_to_department from ..utils.ldap_utils import group_type @@ -1043,6 +1042,9 @@ class DepartmentService: ) -> str: """Переименовывает OU отдела и возвращает новый DN. + TODO(ldap-orm): modify_dn — ldap3 навсегда. + django-ldapdb не поддерживает переименование. + Args: conn: LDAP соединение. dept_dn: Текущий DN OU. @@ -1066,6 +1068,9 @@ class DepartmentService: ) -> None: """Устанавливает/очищает managedBy у OU в AD (идемпотентно). + TODO(ldap-orm): Можно заменить на ORM (ou.managed_by = head_dn; save()), + но нужна проверка существования head_dn и обработка ADD/REPLACE/DELETE. + Args: conn: LDAP соединение. dept_dn: DN OU отдела. @@ -1129,35 +1134,32 @@ class DepartmentService: def _set_ou_description( self, conn: Connection, dept_dn: str, description: Optional[str] ) -> None: - """Ставит/очищает description у OU. + """Ставит/очищает description у OU (ORM). Args: - conn: LDAP соединение. + conn: LDAP соединение (не используется, ORM). dept_dn: DN OU отдела. description: Описание или None для удаления. """ - changes = ( - {"description": [(MODIFY_REPLACE, [description])]} - if description - else {"description": [(MODIFY_DELETE, [])]} - ) - ldap_modify_or_ignore(conn, dept_dn, changes, {"noSuchAttribute"}) + try: + ou = LdapOrganizationalUnit.objects.get(dn=dept_dn) + ou.description = description or "" + ou.save() + except LdapOrganizationalUnit.DoesNotExist: + pass def _delete_department_ou(self, conn: Connection, dept_dn: str) -> None: - """Удаляет OU (ignore noSuchObject). + """Удаляет OU (ORM, ignore DoesNotExist). Args: - conn: LDAP соединение. + conn: LDAP соединение (не используется, ORM). dept_dn: DN OU для удаления. - - Raises: - RuntimeError: Если удаление не удалось (кроме noSuchObject). """ - ok = conn.delete(dept_dn) - if not ok and (conn.result or {}).get("description") not in { - "noSuchObject" - }: - raise RuntimeError(f"LDAP delete OU failed: {conn.result}") + try: + ou = LdapOrganizationalUnit.objects.get(dn=dept_dn) + ou.delete() + except LdapOrganizationalUnit.DoesNotExist: + pass def _evict_all_users_from_department_ou( self, conn: Connection, dept_dn: str diff --git a/backend/employees/ldap/services/group_service.py b/backend/employees/ldap/services/group_service.py index 08363a23..a4edc9f0 100644 --- a/backend/employees/ldap/services/group_service.py +++ b/backend/employees/ldap/services/group_service.py @@ -17,6 +17,7 @@ from employees.models import LdapSyncState from ldap3 import BASE, MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, SUBTREE, Connection from ..infrastructure.connections import _ldap +from ..orm_models import LdapGroup from ..repositories.ldap_repository import ldap_modify_or_ignore from ..utils.text_utils import esc_filter, esc_rdn from ..utils.ldap_utils import get_guid_str, group_type @@ -85,6 +86,9 @@ class GroupService: ) -> str: """Создаёт группу в LDAP под указанным контейнером. + TODO(ldap-orm): conn.add() + groupType — ldap3 навсегда. + LdapGroup не имеет поля groupType, нужно добавить в модель. + Args: conn: Подключение LDAP. cn: Имя группы (CN). @@ -126,7 +130,11 @@ class GroupService: raise RuntimeError(f"LDAP delete group failed: {conn.result}") def rename(self, conn: Connection, group_dn: str, new_cn: str) -> str: - """Переименовывает группу (modify_dn).""" + """Переименовывает группу (modify_dn). + + TODO(ldap-orm): modify_dn — ldap3 навсегда. + django-ldapdb не поддерживает переименование. + """ new_rdn = f"CN={esc_rdn(new_cn)}" ok = conn.modify_dn(group_dn, new_rdn) if not ok: @@ -261,6 +269,9 @@ class GroupService: ) -> int: """ Тянет группы из AD под LDAP_GROUPS_BASE и гарантирует их наличие в Django Group. + + TODO(ldap-orm): paged search — ldap3 навсегда. + Массовый импорт с throttling и кешированием. Возвращает количество созданных групп. Обновляет LdapSyncState(model='group'). Троттлит запросы к AD не чаще, чем раз в throttle_seconds. """ @@ -336,9 +347,12 @@ class GroupService: def find_dn_wrapped( self, cn: str, bases: Optional[list[str]] = None ) -> Optional[str]: - """Ищет DN группы (открывает соединение автоматически).""" - with _ldap() as conn: - return self.find_dn(conn, cn, bases=bases) + """Ищет DN группы по CN (ORM).""" + try: + group = LdapGroup.objects.get(cn=cn) + return group.dn + except LdapGroup.DoesNotExist: + return None def create_wrapped( self, @@ -349,7 +363,10 @@ class GroupService: scope: str = "global", security_enabled: bool = True, ) -> str: - """Создает группу (открывает соединение автоматически).""" + """Создает группу (ldap3 — нужен groupType). + + TODO(ldap-orm): conn.add() + groupType остаётся ldap3. + """ with _ldap() as conn: return self.create( conn, @@ -361,40 +378,79 @@ class GroupService: ) def delete_wrapped(self, group_dn: str) -> None: - """Удаляет группу (открывает соединение автоматически).""" - with _ldap() as conn: - self.delete(conn, group_dn) + """Удаляет группу (ORM).""" + try: + group = LdapGroup.objects.get(dn=group_dn) + group.delete() + except LdapGroup.DoesNotExist: + pass # Уже удалена def rename_wrapped(self, group_dn: str, new_cn: str) -> str: - """Переименовывает группу (открывает соединение автоматически).""" + """Переименовывает группу (ldap3 — modify_dn). + + TODO(ldap-orm): modify_dn — ldap3 навсегда. + """ with _ldap() as conn: return self.rename(conn, group_dn, new_cn) def set_description_wrapped( self, group_dn: str, description: Optional[str] ) -> None: - """Устанавливает описание группы (открывает соединение автоматически).""" - with _ldap() as conn: - self.set_description(conn, group_dn, description) + """Устанавливает описание группы (ORM).""" + try: + group = LdapGroup.objects.get(dn=group_dn) + group.description = description or "" + group.save() + except LdapGroup.DoesNotExist: + pass def list_members_wrapped(self, group_dn: str) -> list[str]: - """Список участников группы (открывает соединение автоматически).""" - with _ldap() as conn: - return self.list_members(conn, group_dn) + """Список участников группы (ORM).""" + try: + group = LdapGroup.objects.get(dn=group_dn) + return list(group.member or []) + except LdapGroup.DoesNotExist: + return [] def add_members_wrapped(self, group_dn: str, member_dns: list[str]) -> None: - """Добавляет участников (открывает соединение автоматически).""" - with _ldap() as conn: - self.add_members(conn, group_dn, member_dns) + """Добавляет участников (ORM).""" + try: + group = LdapGroup.objects.get(dn=group_dn) + except LdapGroup.DoesNotExist: + return + members = list(group.member or []) + changed = False + for dn in member_dns: + if dn not in members: + members.append(dn) + changed = True + if changed: + group.member = members + group.save() def remove_members_wrapped(self, group_dn: str, member_dns: list[str]) -> None: - """Удаляет участников (открывает соединение автоматически).""" - with _ldap() as conn: - self.remove_members(conn, group_dn, member_dns) + """Удаляет участников (ORM).""" + try: + group = LdapGroup.objects.get(dn=group_dn) + except LdapGroup.DoesNotExist: + return + members = list(group.member or []) + changed = False + for dn in member_dns: + if dn in members: + members.remove(dn) + changed = True + if changed: + group.member = members + group.save() def replace_members_wrapped( self, group_dn: str, exact_member_dns: list[str] ) -> None: - """Заменяет участников (открывает соединение автоматически).""" - with _ldap() as conn: - self.replace_members(conn, group_dn, exact_member_dns) + """Заменяет участников (ORM).""" + try: + group = LdapGroup.objects.get(dn=group_dn) + except LdapGroup.DoesNotExist: + return + group.member = exact_member_dns + group.save() diff --git a/backend/employees/ldap/services/user_service.py b/backend/employees/ldap/services/user_service.py index b42371bb..43fff8d5 100644 --- a/backend/employees/ldap/services/user_service.py +++ b/backend/employees/ldap/services/user_service.py @@ -20,20 +20,18 @@ from ..errors import ( DirectoryLdapError, DirectoryServiceError, ) -from ..utils.group_utils import sync_user_groups_by_cns +from ..orm_models import LdapUser +from ..utils.group_utils_orm import sync_user_groups_by_cns_orm from ..infrastructure.connections import _ldap from ..repositories.ldap_repository import ( ensure_container_exists, is_taken, ldap_modify_or_ignore, - modify_user_attrs, - read_attrs, ) from ..utils.image_utils import normalize_avatar_to_jpeg from ..utils.ldap_utils import ( build_logins_for_user, cn_candidates, - get_guid_str, ) from ..utils.text_utils import esc_rdn @@ -107,8 +105,9 @@ class UserService: emp.set_unusable_password() emp.save(update_fields=["password"]) - attrs = read_attrs(conn, dn, ["objectGUID"]) - guid = get_guid_str(attrs) + # ORM: чтение objectGUID + запись employeeNumber + ldap_user = LdapUser.objects.get(dn=dn) + guid = str(ldap_user.object_guid) if ldap_user.object_guid else None self._touch_state( model="employee", @@ -119,13 +118,9 @@ class UserService: last_django_modify_ts=timezone.now(), ) - # Записываем Django PK в LDAP employeeNumber для связки - employee_id_attr = getattr( - settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber" - ) - modify_user_attrs( - conn, dn, {employee_id_attr: str(emp.pk)}, do_write=True - ) + # ORM: записываем Django PK в LDAP employeeNumber для связки + ldap_user.employee_number = str(emp.pk) + ldap_user.save() # Временно импортируем для совместимости # TODO: Убрать после полного рефакторинга @@ -147,16 +142,15 @@ class UserService: # 3) Post-actions (best effort) try: if dto.group_cns: - sync_user_groups_by_cns(conn, dn, set(dto.group_cns), do_write=True) + sync_user_groups_by_cns_orm(dn, set(dto.group_cns)) if dto.avatar_bytes: - # Увеличен размер до 384px для максимального качества в LDAP avatar = normalize_avatar_to_jpeg( dto.avatar_bytes, size_px=384, max_kb=100 ) if avatar: - modify_user_attrs( - conn, dn, {"thumbnailPhoto": avatar}, do_write=True - ) + ldap_user_av = LdapUser.objects.get(dn=dn) + ldap_user_av.thumbnail_photo = avatar + ldap_user_av.save() except Exception: pass @@ -359,28 +353,36 @@ class UserService: except DirectoryServiceError: dn = None - with _ldap() as conn: - if dn: - try: - modify_user_attrs(conn, dn, {"userAccountControl": 0x0202}) - except Exception as e: - raise DirectoryLdapError(f"LDAP soft-disable failed: {e}") from e - + # ORM: soft-disable (UAC = disabled + password expired) + if dn: try: - with transaction.atomic(): - emp_pk = emp.pk - emp.delete() - LdapSyncState.objects.filter( - model="employee", object_pk=str(emp_pk) - ).delete() + ldap_user = LdapUser.objects.get(dn=dn) + ldap_user.user_account_control = 0x0202 + ldap_user.save() + except LdapUser.DoesNotExist: + pass # Уже удалён из LDAP except Exception as e: - raise DirectoryDbError(str(e)) from e + raise DirectoryLdapError(f"LDAP soft-disable failed: {e}") from e - if dn: - try: - self._hard_delete_user_in_ldap(conn, dn) - except Exception as e: - raise DirectoryLdapError(f"LDAP hard delete failed: {e}") from e + try: + with transaction.atomic(): + emp_pk = emp.pk + emp.delete() + LdapSyncState.objects.filter( + model="employee", object_pk=str(emp_pk) + ).delete() + except Exception as e: + raise DirectoryDbError(str(e)) from e + + # ORM: hard delete + if dn: + try: + ldap_user = LdapUser.objects.get(dn=dn) + ldap_user.delete() + except LdapUser.DoesNotExist: + pass # Уже удалён из LDAP + except Exception as e: + raise DirectoryLdapError(f"LDAP hard delete failed: {e}") from e # ==================== DN ↔ ID Conversion Methods ==================== # @@ -460,7 +462,12 @@ class UserService: return state def _set_password(self, conn: Connection, dn: str, new_password: str) -> None: - """Меняет пароль пользователя в AD (control).""" + """Меняет пароль пользователя в AD (control). + + TODO(ldap-orm): Невозможно заменить на ORM. + AD modify_password — extended operation, django-ldapdb не поддерживает. + Остаётся через ldap3 навсегда. + """ ok = conn.extend.microsoft.modify_password(dn, new_password) if not ok: msg = conn.result or {} @@ -470,7 +477,11 @@ class UserService: raise RuntimeError(f"LDAP set password failed: {msg}") def _enable_user(self, conn: Connection, dn: str) -> None: - """Включает учётку (UAC=512).""" + """Включает учётку (UAC=512). + + TODO(ldap-orm): Можно заменить на ORM (ldap_user.user_account_control = 512; save()). + Но используется в связке с _create_user_in_ldap → conn уже в контексте. + """ ldap_modify_or_ignore( conn, dn, {"userAccountControl": [(MODIFY_REPLACE, [UAC_ENABLED])]}, set() ) @@ -478,7 +489,12 @@ class UserService: def _unique_logins( self, conn: Connection, dto: DirectoryUserDTO, upn_suffix: str ) -> tuple[str, str]: - """Генерирует уникальные sAMAccountName и UPN.""" + """Генерирует уникальные sAMAccountName и UPN. + + TODO(ldap-orm): is_taken() использует ldap3 raw search с OR фильтром. + Можно заменить на LdapUser.objects.filter(sam_account_name=s).exists(), + но нужен фильтр по нескольким атрибутам одновременно. + """ sam, upn = build_logins_for_user( first_name=dto.first_name, last_name=dto.last_name, @@ -544,7 +560,11 @@ class UserService: return None def _create_user_in_ldap(self, conn: Connection, dto: DirectoryUserDTO) -> str: - """Создаёт объект user в LDAP (AD) и возвращает его DN.""" + """Создаёт объект user в LDAP (AD) и возвращает его DN. + + TODO(ldap-orm): conn.add(), ensure_container_exists, cn_candidates — ldap3 навсегда. + django-ldapdb не поддерживает создание с перебором CN и проверкой контейнера. + """ base_dn = ( dto.department_dn or getattr(settings, "LDAP_USERS_BASE", None) @@ -621,43 +641,49 @@ class UserService: if move_to_department_dn: dn = svc._move_to_department(conn, dn, move_to_department_dn) + # 3-5) ORM: UAC + Атрибуты + Аватар (batch через один save) + ldap_user = LdapUser.objects.get(dn=dn) + orm_dirty = False + # 3) UAC if is_active_val is not None: uac_val = UAC_ENABLED if is_active_val else UAC_DISABLED - modify_user_attrs(conn, dn, {"userAccountControl": uac_val}, do_write=True) + ldap_user.user_account_control = uac_val + orm_dirty = True # 4) Прочие атрибуты - ldap_attrs_map = { - "first_name": "givenName", + phone_write = self._phone_write_attr() + orm_field_map = { + "first_name": "given_name", "last_name": "sn", "email": "mail", - "phone_number": self._phone_write_attr(), - "display_name": "displayName", + "phone_number": "mobile" if phone_write == "mobile" else "telephone_number", + "display_name": "display_name", } - attrs_to_update = {} - for model_key, ldap_attr in ldap_attrs_map.items(): + for model_key, orm_field in orm_field_map.items(): if model_key in model_changes: val = model_changes[model_key] - if val or ldap_attr == "sn": - attrs_to_update[ldap_attr] = val if val else "." - - if attrs_to_update: - modify_user_attrs(conn, dn, attrs_to_update, do_write=True) + if val or orm_field == "sn": + setattr(ldap_user, orm_field, val if val else ".") + orm_dirty = True # 5) Аватар avatar_saved = False if avatar_bytes: - # Увеличен размер до 384px для максимального качества в LDAP avatar = normalize_avatar_to_jpeg( avatar_bytes, size_px=384, max_kb=100 ) if avatar: - modify_user_attrs(conn, dn, {"thumbnailPhoto": avatar}, do_write=True) - avatar_saved = True # Флаг для сохранения в БД + ldap_user.thumbnail_photo = avatar + orm_dirty = True + avatar_saved = True - # 6) Группы + if orm_dirty: + ldap_user.save() + + # 6) Группы (ORM) if group_cns is not None: - sync_user_groups_by_cns(conn, dn, set(group_cns), do_write=True) + sync_user_groups_by_cns_orm(dn, set(group_cns)) # Возвращаем dn и флаг сохранения аватара return (dn, avatar_bytes if avatar_saved else None) @@ -695,6 +721,10 @@ class UserService: def _move_user_to_base(self, conn: Connection, user_dn: str, base_dn: str) -> str: """Перемещает пользовательский объект в указанный контейнер. + TODO(ldap-orm): Невозможно заменить на ORM. + modify_dn (move/rename) — не поддерживается django-ldapdb. + Остаётся через ldap3 навсегда. + Args: conn: LDAP соединение. user_dn: Текущий DN пользователя. diff --git a/backend/employees/ldap/sync_service.py b/backend/employees/ldap/sync_service.py deleted file mode 100644 index d5e0b2b4..00000000 --- a/backend/employees/ldap/sync_service.py +++ /dev/null @@ -1,352 +0,0 @@ -"""DEPRECATED: Этот модуль сохранён для обратной совместимости. - -Используйте вместо него: - from employees.ldap.services.sync_service import SyncService - -Функции в этом модуле являются обёртками над новым SyncService. -""" - -from __future__ import annotations - -import logging -from typing import Iterable, Tuple - -from django.conf import settings -from ldap3 import BASE - -from ..models import Employee, EmployeeDepartment, LdapSyncState -from .config import SyncConfig -from .infrastructure.connections import _ldap -from .utils.dn_utils import ( - _ensure_user_dn, - _move_to_department, - _target_department_ou_dn, - extract_department_from_dn, -) -from .utils.group_utils import ( - _desired_group_cns_for_employee, - sync_user_groups_by_cns, -) -from .repositories.ldap_repository import ( - is_taken, - read_attrs, - modify_user_attrs, -) - -from .utils.ldap_utils import ( - - build_logins_for_user, - -) -from .utils.image_utils import normalize_avatar_to_jpeg -from .services.sync_service import SyncService - -logger = logging.getLogger(__name__) - -# Создаём глобальный экземпляр для backward compatibility -_sync_service = SyncService() - - -# ==================== WRAPPER FUNCTIONS ==================== - - -def import_departments(*, cfg: SyncConfig) -> Tuple[int, int, int]: - """DEPRECATED: Используйте SyncService().import_departments(). - - Импорт OU отделов из LDAP в Django. - - Args: - cfg: Конфигурация синхронизации. - - Returns: - Tuple[int, int, int]: (created, updated, deleted). - """ - return _sync_service.import_departments(cfg) - - -def import_users(*, cfg: SyncConfig) -> Tuple[int, int, int]: - """DEPRECATED: Используйте SyncService().import_users(). - - Импорт пользователей из LDAP в Django. - - Args: - cfg: Конфигурация синхронизации. - - Returns: - Tuple[int, int, int]: (created, updated, deleted). - """ - return _sync_service.import_users(cfg) - - -def export_users(*, cfg: SyncConfig) -> Tuple[int, int, int, int, int]: - """DEPRECATED: Используйте SyncService().export_users(). - - Полный экспорт пользователей из Django в LDAP. - - Args: - cfg: Конфигурация синхронизации. - - Returns: - Tuple[int, int, int, int, int]: - (logins_set, moved, avatars_set, groups_added, groups_removed). - """ - return _export_users_legacy(cfg=cfg) - - -def export_users_delete( - *, cfg: SyncConfig, employees: Iterable[Employee] -) -> int: - """DEPRECATED: Используйте SyncService().export_users_delete(). - - Удаляет учётные записи пользователей в LDAP. - - Args: - cfg: Конфигурация синхронизации. - employees: Итератор пользователей для удаления. - - Returns: - int: Количество успешно удалённых DN. - """ - return _sync_service.export_users_delete(cfg, employees) - - -# ==================== LEGACY IMPLEMENTATIONS ==================== -# Эти функции сохранены для сложной логики export_users, -# которая ещё не полностью перенесена в новые сервисы - - -def _employees_with_dn_qs(): - """Возвращает QuerySet сотрудников с LDAP DN.""" - emp_ids = ( - LdapSyncState.objects.filter(model="employee") - .exclude(ldap_dn="") - .values_list("object_pk", flat=True) - ) - return Employee.objects.filter(pk__in=list(emp_ids)).select_related() - - -def _ensure_and_persist_user_dn(conn, emp, *, do_write): - """Обеспечивает наличие DN и сохраняет в LdapSyncState.""" - user_dn = _ensure_user_dn(conn, emp) - if do_write: - st, _ = LdapSyncState.objects.get_or_create( - model="employee", object_pk=str(emp.pk) - ) - if (st.ldap_dn or "") != user_dn: - st.touch(ldap_dn=user_dn, sync_dir="ldap") - return user_dn - - -def _active_dept_name(emp: Employee) -> str | None: - """Имя активного отдела сотрудника.""" - link = ( - EmployeeDepartment.objects.filter(employee=emp, is_active=True) - .select_related("department") - .first() - ) - return link.department.name if link and link.department_id else None - - -def export_users_create_attrs(*, cfg: SyncConfig) -> int: - """Создаёт недостающие sAMAccountName/UPN и базовые ФИО-атрибуты.""" - upn_suffix: str = getattr(settings, "LDAP_UPN_SUFFIX", "robotail.local") - do_write = not cfg.dry_run - assigned = 0 - - with _ldap() as conn: - for emp in _employees_with_dn_qs(): - try: - user_dn = _ensure_and_persist_user_dn( - conn, emp, do_write=do_write - ) - except RuntimeError as e: - logger.warning("[WARN] %s", e) - continue - - cur = read_attrs( - conn, user_dn, ["sAMAccountName", "userPrincipalName"] - ) - if cur["sAMAccountName"] and cur["userPrincipalName"]: - continue - - guid = ( - LdapSyncState.objects.filter( - model="employee", object_pk=str(emp.pk) - ) - .values_list("ldap_guid", flat=True) - .first() - ) - - sam, upn = build_logins_for_user( - first_name=emp.first_name or "", - last_name=emp.last_name or "", - email=emp.email or "", - upn_suffix=upn_suffix, - is_taken_sam=lambda s: is_taken( - conn, attributes={"sAMAccountName": s} - ), - is_taken_upn=lambda u: is_taken( - conn, attributes={"userPrincipalName": u} - ), - guid=guid, - ) - modify_user_attrs( - conn, - user_dn, - { - "sAMAccountName": sam, - "userPrincipalName": upn, - "givenName": emp.first_name or None, - "sn": emp.last_name or None, - "displayName": ( - f"{emp.first_name} {emp.last_name}".strip() or None - ), - }, - do_write=do_write, - ) - assigned += 1 - - return assigned - - -def export_users_update_profile( - *, cfg: SyncConfig -) -> Tuple[int, int]: - """Обновляет профильные данные: перемещение между отделами и аватары.""" - do_write = not cfg.dry_run - moved = avatars_set = 0 - - with _ldap() as conn: - for emp in _employees_with_dn_qs(): - try: - user_dn = _ensure_and_persist_user_dn( - conn, emp, do_write=do_write - ) - except RuntimeError as e: - logger.warning("[WARN] %s", e) - continue - - # MOVE между отделами - current_dept = extract_department_from_dn(user_dn) - target_dept = _active_dept_name(emp) - if target_dept and current_dept != target_dept: - if do_write: - _move_to_department(conn, user_dn, target_dept) - else: - target_ou = _target_department_ou_dn(target_dept) - if not ( - conn.search( - target_ou, - "(objectClass=organizationalUnit)", - BASE, - attributes=["ou"], - ) - and conn.entries - ): - logger.warning( - "[WARN] Целевой OU не найден: %s", target_ou - ) - continue - moved += 1 - - # avatar -> thumbnailPhoto - avatar_bytes: bytes | None = None - avatar_field = getattr(emp, "avatar", None) - if avatar_field and hasattr(avatar_field, "read"): - avatar_bytes = avatar_field.read() - if avatar_bytes: - try: - # Увеличен размер до 384px для максимального качества в LDAP - avatar_bytes = normalize_avatar_to_jpeg( - avatar_bytes, size_px=384, max_kb=100 - ) - except Exception as exc: - logger.warning( - "[WARN] avatar: не удалось нормализовать для %s: %s", - user_dn, - exc, - ) - else: - modify_user_attrs( - conn, - user_dn, - {"thumbnailPhoto": avatar_bytes}, - do_write=do_write, - ) - avatars_set += 1 - - return moved, avatars_set - - -def export_users_sync_groups(*, cfg: SyncConfig) -> Tuple[int, int]: - """Приводит членства пользователей в LDAP-группах к целевому набору.""" - do_write = not cfg.dry_run - groups_added = groups_removed = 0 - - with _ldap() as conn: - for emp in _employees_with_dn_qs(): - try: - user_dn = _ensure_and_persist_user_dn( - conn, emp, do_write=do_write - ) - except RuntimeError as e: - logger.warning("[WARN] %s", e) - continue - - current_dept = extract_department_from_dn(user_dn) - target_dept = _active_dept_name(emp) - - desired_cns = _desired_group_cns_for_employee(emp) - - extra_bases: list[str] = [] - dept_for_roles = target_dept or current_dept - if dept_for_roles: - dept_base = getattr(settings, "LDAP_DEPARTMENTS_BASE", "") - # NOTE: Группы ролей теперь лежат прямо в OU отдела (не в OU=Roles) - extra_bases.append(f"OU={dept_for_roles},{dept_base}") - - added, removed = sync_user_groups_by_cns( - conn, - user_dn, - desired_cns, - extra_bases=extra_bases, - do_write=do_write, - ) - groups_added += added - groups_removed += removed - - return groups_added, groups_removed - - -def _export_users_legacy(*, cfg: SyncConfig) -> Tuple[int, int, int, int, int]: - """Legacy реализация export_users через старые функции.""" - from django.utils import timezone - - logins_set = export_users_create_attrs(cfg=cfg) - moved, avatars_set = export_users_update_profile(cfg=cfg) - groups_added, groups_removed = export_users_sync_groups(cfg=cfg) - - do_write = not cfg.dry_run - with _ldap() as conn: - from django.db import transaction - - with transaction.atomic(): - now = timezone.now() - for emp in _employees_with_dn_qs().only("pk"): - try: - user_dn = _ensure_and_persist_user_dn( - conn, emp, do_write=do_write - ) - except RuntimeError as e: - logger.warning("[WARN] state.touch пропущен: %s", e) - continue - state, _ = LdapSyncState.objects.get_or_create( - model="employee", object_pk=str(emp.pk) - ) - state.touch( - ldap_dn=user_dn, - last_django_modify_ts=now, - sync_dir="django", - ) - - return logins_set, moved, avatars_set, groups_added, groups_removed diff --git a/backend/employees/ldap/utils/group_utils_orm.py b/backend/employees/ldap/utils/group_utils_orm.py new file mode 100644 index 00000000..97e1b4c8 --- /dev/null +++ b/backend/employees/ldap/utils/group_utils_orm.py @@ -0,0 +1,126 @@ +"""ORM-версии утилит для работы с группами LDAP через django-ldapdb. + +Замена низкоуровневых ldap3 операций для управления memberOf. +""" + +from __future__ import annotations + +import logging +from typing import Iterable, Optional, Set + +from django.conf import settings + +from employees.models import Employee +from ..orm_models import LdapUser, LdapGroup +from .text_utils import esc_filter + +logger = logging.getLogger(__name__) + + +def read_user_memberof_dns(user_dn: str) -> set[str]: + """Возвращает множество DN групп (memberOf) пользователя через ORM.""" + try: + user = LdapUser.objects.get(dn=user_dn) + return set(user.member_of or []) + except LdapUser.DoesNotExist: + return set() + + +def group_add_member_orm(group_dn: str, member_dn: str) -> None: + """Добавляет пользователя в группу через ORM.""" + try: + group = LdapGroup.objects.get(dn=group_dn) + except LdapGroup.DoesNotExist: + raise ValueError(f"LDAP group not found: {group_dn}") + + members = list(group.member or []) + if member_dn not in members: + members.append(member_dn) + group.member = members + group.save() + + +def group_remove_member_orm(group_dn: str, member_dn: str) -> None: + """Удаляет пользователя из группы через ORM.""" + try: + group = LdapGroup.objects.get(dn=group_dn) + except LdapGroup.DoesNotExist: + raise ValueError(f"LDAP group not found: {group_dn}") + + members = list(group.member or []) + if member_dn in members: + members.remove(member_dn) + group.member = members + group.save() + + +def resolve_group_dns_by_cn_orm(cns: set[str]) -> dict[str, str]: + """Ищет DN групп по их CN через ORM.""" + if not cns: + return {} + found: dict[str, str] = {} + for cn in sorted(cns): + try: + group = LdapGroup.objects.get(cn=cn) + found[cn] = group.dn + except LdapGroup.DoesNotExist: + continue + return found + + +def sync_user_groups_by_cns_orm( + user_dn: str, + target_cns: Iterable[str], + *, + do_write: bool = True, +) -> tuple[int, int]: + """Приводит memberOf пользователя к набору CN через ORM. + + Args: + user_dn: DN пользователя. + target_cns: итоговый набор CN групп. + do_write: False => только расчёт. + + Returns: + (added, removed). + """ + desired_dns = set(resolve_group_dns_by_cn_orm(set(target_cns)).values()) + current_dns = read_user_memberof_dns(user_dn) + + groups_base = getattr(settings, "LDAP_GROUPS_BASE", "") + depts_base = getattr(settings, "LDAP_DEPARTMENTS_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 (depts_base and dn.endswith(depts_base) and "CN=ROLE_" in dn) + } + + added = removed = 0 + if do_write: + for gdn in sorted(to_add): + group_add_member_orm(gdn, user_dn) + added += 1 + for gdn in sorted(to_del): + group_remove_member_orm(gdn, user_dn) + removed += 1 + else: + added, removed = len(to_add), len(to_del) + return added, removed + + +# _desired_group_cns_for_employee — чистая Django логика, нет ldap3 +# Реэкспортируем из оригинала +from .group_utils import _desired_group_cns_for_employee + + +__all__ = [ + 'read_user_memberof_dns', + 'group_add_member_orm', + 'group_remove_member_orm', + 'resolve_group_dns_by_cn_orm', + 'sync_user_groups_by_cns_orm', + '_desired_group_cns_for_employee', +] diff --git a/backend/employees/management/commands/sync_directory.py b/backend/employees/management/commands/sync_directory.py index 3543260e..1896e3c1 100644 --- a/backend/employees/management/commands/sync_directory.py +++ b/backend/employees/management/commands/sync_directory.py @@ -4,12 +4,8 @@ from typing import Literal from django.conf import settings from django.core.management.base import BaseCommand, CommandError -from employees.ldap.sync_service import ( - SyncConfig, - export_users, - import_departments, - import_users, -) +from employees.ldap.config import SyncConfig +from employees.ldap.services.sync_service import SyncService class Command(BaseCommand): @@ -77,13 +73,14 @@ class Command(BaseCommand): if mode == "ldap": total_changes = 0 + svc = SyncService() # Отделы if scope in ("all", "departments"): self.stdout.write( self.style.NOTICE("[LDAP] Импорт отделов и managedBy...") ) - created_d, updated_d, deleted_d = import_departments(cfg=cfg) + created_d, updated_d, deleted_d = svc.import_departments(cfg) total_changes += created_d + updated_d + deleted_d deleted_label = "к удалению" if cfg.dry_run else "удалено" self.stdout.write( @@ -95,7 +92,7 @@ class Command(BaseCommand): # Пользователи if scope in ("all", "users"): self.stdout.write(self.style.NOTICE("[LDAP] Импорт пользователей...")) - created_u, updated_u, deleted_u = import_users(cfg=cfg) + created_u, updated_u, deleted_u = svc.import_users(cfg) total_changes += created_u + updated_u + deleted_u deleted_label = "к удалению" if cfg.dry_run else "удалено" self.stdout.write( @@ -115,8 +112,9 @@ class Command(BaseCommand): "[DJANGO] Write-back пользователей (логины, MOVE, avatar, группы)..." ) ) - logins_set, moved, avatars_set, groups_added, groups_removed = export_users( - cfg=cfg + svc = SyncService() + logins_set, moved, avatars_set, groups_added, groups_removed = svc.export_users( + cfg ) self.stdout.write( self.style.SUCCESS( diff --git a/backend/employees/management/commands/sync_employee_ids_to_ldap.py b/backend/employees/management/commands/sync_employee_ids_to_ldap.py index ecb5f5e7..31be88c2 100644 --- a/backend/employees/management/commands/sync_employee_ids_to_ldap.py +++ b/backend/employees/management/commands/sync_employee_ids_to_ldap.py @@ -13,8 +13,7 @@ from django.conf import settings from django.core.management.base import BaseCommand from employees.models import Employee, LdapSyncState -from employees.ldap.infrastructure.connections import _ldap -from employees.ldap.repositories.ldap_repository import modify_user_attrs, read_attrs +from employees.ldap.orm_models import LdapUser logger = logging.getLogger(__name__) @@ -92,82 +91,88 @@ class Command(BaseCommand): skipped_no_employee = 0 errors = 0 - with _ldap() as conn: - for state in sync_states: - dn = state.ldap_dn - pk = state.object_pk + for state in sync_states: + dn = state.ldap_dn + pk = state.object_pk - # Проверяем что Employee существует - try: - employee = Employee.objects.get(pk=pk) - except Employee.DoesNotExist: - if verbose: - self.stdout.write( - self.style.WARNING( - f" [SKIP] pk={pk}: Employee не найден в БД" - ) + # Проверяем что Employee существует + try: + employee = Employee.objects.get(pk=pk) + except Employee.DoesNotExist: + if verbose: + self.stdout.write( + self.style.WARNING( + f" [SKIP] pk={pk}: Employee не найден в БД" ) - skipped_no_employee += 1 - continue + ) + skipped_no_employee += 1 + continue - # Читаем текущее значение employeeNumber + # Читаем текущее значение employeeNumber через ORM + try: + ldap_user = LdapUser.objects.get(dn=dn) + current_value = ldap_user.employee_number or None + except LdapUser.DoesNotExist: + self.stdout.write( + self.style.ERROR( + f" [ERROR] pk={pk}, DN={dn}: не найден в LDAP" + ) + ) + errors += 1 + continue + except Exception as e: + self.stdout.write( + self.style.ERROR( + f" [ERROR] pk={pk}, DN={dn}: не удалось прочитать — {e}" + ) + ) + errors += 1 + continue + + # Проверяем нужно ли обновлять + expected_value = str(pk) + + if current_value == expected_value: + if verbose > 1: + self.stdout.write( + f" [OK] pk={pk}: уже установлено {employee_id_attr}={current_value}" + ) + skipped_already_set += 1 + continue + + if current_value and not force: + if verbose: + self.stdout.write( + self.style.WARNING( + f" [SKIP] pk={pk}: {employee_id_attr}={current_value} " + f"(отличается от pk, используйте --force)" + ) + ) + skipped_already_set += 1 + continue + + # Записываем через ORM + action = "UPDATE" if current_value else "SET" + if verbose or dry_run: + email = employee.email or "(no email)" + self.stdout.write( + f" [{action}] pk={pk}, email={email}, DN={dn}: " + f"{employee_id_attr}={current_value!r} -> {expected_value!r}" + ) + + if not dry_run: try: - current_attrs = read_attrs(conn, dn, [employee_id_attr]) - current_value = current_attrs.get(employee_id_attr) + ldap_user.employee_number = expected_value + ldap_user.save() + updated += 1 except Exception as e: self.stdout.write( self.style.ERROR( - f" [ERROR] pk={pk}, DN={dn}: не удалось прочитать — {e}" + f" [ERROR] pk={pk}: не удалось записать — {e}" ) ) errors += 1 - continue - - # Проверяем нужно ли обновлять - expected_value = str(pk) - - if current_value == expected_value: - if verbose > 1: - self.stdout.write( - f" [OK] pk={pk}: уже установлено {employee_id_attr}={current_value}" - ) - skipped_already_set += 1 - continue - - if current_value and not force: - if verbose: - self.stdout.write( - self.style.WARNING( - f" [SKIP] pk={pk}: {employee_id_attr}={current_value} " - f"(отличается от pk, используйте --force)" - ) - ) - skipped_already_set += 1 - continue - - # Записываем - action = "UPDATE" if current_value else "SET" - if verbose or dry_run: - email = employee.email or "(no email)" - self.stdout.write( - f" [{action}] pk={pk}, email={email}, DN={dn}: " - f"{employee_id_attr}={current_value!r} -> {expected_value!r}" - ) - - if not dry_run: - try: - modify_user_attrs( - conn, dn, {employee_id_attr: expected_value}, do_write=True - ) - updated += 1 - except Exception as e: - self.stdout.write( - self.style.ERROR( - f" [ERROR] pk={pk}: не удалось записать — {e}" - ) - ) - errors += 1 - else: + else: updated += 1 # Итоги