BREAKING CHANGES:
- Department.head автоматически очищается при увольнении руководителя
- LDAP managedBy атрибут очищается при увольнении
Fixes:
- DN не перемещался при восстановлении сотрудника (Dismissed → Users)
- DN не перемещался при увольнении сотрудника без отделов
- Ошибка при увольнении из множественных отделов (двойной modify_dn)
- Неправильные URL patterns для LDAP моделей в админке
- Двойное кодирование DN в редиректе смены пароля
Features:
- Добавлен метод _ensure_ldap_dn_location для гарантированного перемещения DN
- Добавлена проверка already_there в sync_department_member_to_ldap
- Управление паролями LDAP пользователей через админку:
* Кнопка смены пароля в карточке пользователя
* Массовое действие смены пароля
* Автосинхронизация с Django password hash
- Автоочистка Department.head и LDAP managedBy при увольнении руководителя
- Добавлены методы set_password() и change_password() в LdapUser
Changed:
- Переписан сигнал sync_department_member_to_ldap с реальными методами
- Исправлены URL patterns: ldap_* → employees_*
Both LDAP→Django and Django→LDAP sync actions now check for missing
required fields (name, email, employeeNumber) and display warnings
before syncing. Shows up to 10 warnings per action.
Django's delete_selected passes queryset with dn__in filter that
ldapdb doesn't support. Custom delete_selected_ldap reads DNs from
POST and deletes objects one by one. Also hides standard delete_selected
from action choices.
- Override get_deleted_objects() in LdapUserAdmin to avoid dn__in
queryset that ldapdb doesn't support
- Also fix phone_number UNIQUE constraint in LDAP auth backend
Binary thumbnail_photo data caused 500 error when Django admin tried
to render it as a text input. Now displayed as base64 inline image
preview in a readonly field.
- 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
- Removed 30 lines of commented-out DirectoryService code
- Removed unused Position import
- Removed unused old_pos/new_pos/pos_in_payload variables
- Fixed stale comment about displayName auto-update in save()
- Position sync remains as a 3-line TODO comment
AD forbids modifying RDN (CN/OU) via modify_s - only via rename.
ModifyDnMixin now:
1. Tracks _original_rdn_value (cn/ou) from __init__/from_db
2. On save(), if RDN changed: reverts to old value → save() → modify_dn
3. This fixes NOT_ALLOWED_ON_RDN for both admin and API edits
Simplified user_service: just set ldap_user.cn = new_cn and call save().
The mixin handles the rest automatically.
AD forbids modifying RDN (CN) through modify_s - must use rename.
- Removed LdapUser.save() override that synced cn→displayName
(this caused cn to be in modlist → NOT_ALLOWED_ON_RDN error)
- user_service now sets displayName explicitly instead of cn
- CN is only changed via conn.modify_dn() after save()
- Flow: set displayName + other attrs → save() → modify_dn(cn)
- Added save() override in LdapUser to auto-update displayName from cn
- Fixed user_service to set ldap_user.cn BEFORE save() (not after)
- This ensures displayName is synced when givenName/sn change
- Previously displayName wasn't updated because cn was set after save()
- Now: set cn → save() → displayName auto-synced → modify_dn()
ldapdb's build_rdn() checks field.primary_key AND field.db_column,
but the 'dn' field has primary_key=True and db_column=None,
while 'cn'/'ou' fields have db_column set but primary_key=False.
No field satisfies both conditions → save() always raises
'Could not build Distinguished Name'.
Fix: ModifyDnMixin.build_rdn() extracts RDN from existing DN
for loaded objects, and builds from rdn_attributes for new ones.
- Moved get_ldap_str() helper to ldap_utils.py for reuse
- Updated user_service.py to use shared helper from utils
- Updated admin.py to safely extract string values from LDAP fields
- LDAP fields can be lists, strings, or None - helper handles all cases
- Fixes sync_from_ldap_to_django and compare_ldap_django actions
- Prevents 'list' object errors when comparing or assigning LDAP fields
- Added _get_ldap_str() helper to safely extract string values from LDAP fields
- LDAP fields (given_name, sn, etc.) can be returned as lists, not strings
- Fixes AttributeError: 'list' object has no attribute 'strip'
- Used helper in CN generation code (lines 761-764)
- DirectoryService module no longer exists
- Commented out position/department sync code that used DirectoryService
- Marked with TODO for future refactoring
- Fixes ModuleNotFoundError during LDAP sync operations
- Replaced ORM .save() with direct ldap3.Connection.modify()
- ORM save() tries to rebuild DN which fails without proper cn attribute
- Direct modify only changes the employeeNumber attribute
- Fixes Exception: Could not build Distinguished Name
- ldapdb calls timezone.utc.normalize() for datetime field conversion
- Added normalize() method that works the same as localize() for UTC
- Fixes AttributeError: '_UtcCompat' object has no attribute 'normalize'
- Check if Employee with same email exists before creating new one
- Update existing Employee and link it to LDAP instead of creating duplicate
- Prevents UNIQUE constraint failed error on email field
- Sets is_ldap_managed=True and updates user data from LDAP
- Added _resolve_ldap_users() helper to fetch LDAP objects by DN individually
- ldapdb does not support dn__in lookup used by Django admin queryset
- All 3 actions now use _resolve_ldap_users instead of iterating queryset directly
- Made _UtcCompat inherit from datetime.tzinfo instead of plain class
- Implemented required tzinfo methods: utcoffset, tzname, dst
- Fixes TypeError: tzinfo argument must be None or of a tzinfo subclass
- Resolves admin panel crashes when viewing LDAP users with timestamps
- Removed objectGUID field from LdapUser, LdapGroup, and LdapOrganizationalUnit models
- objectGUID contains binary data that causes UnicodeDecodeError when decoded as UTF-8
- Field is not critical for core functionality
- Updated admin.py to not use object_guid attribute
- Удалён LDAP_SERVER_TYPE и все OpenLDAP ветки кода
- user_service.py: только AD (sAMAccountName, UAC, microsoft.modify_password)
- auth_backends.py: AD фильтры и атрибуты (objectCategory, objectGUID)
- Удалён server_configs.py (больше не нужен)
- Удалён OpenLDAP Docker окружение (docker-compose.ldap.yml, ldap/, QUICKSTART_LDAP.md)
- Фронтенд: мульти-шаговая регистрация, AvatarCropper 3:4, verify-email страница
- Убран CSRF для JWT API, IP-ограничения работают независимо
- Полевые ошибки регистрации с красной подсветкой на фронте
ПРОБЛЕМА:
- cn/ou были установлены как primary_key в LdapUser/LdapGroup/LdapOrganizationalUnit
- CN/OU не уникальны глобально (могут быть в разных OU)
- .objects.get(cn=...) возвращал случайную группу при дубликатах
- Нарушался дизайн django-ldapdb, где dn - базовый primary_key
РЕШЕНИЕ:
- Убран primary_key=True из cn/ou полей
- Используется dn (Distinguished Name) как primary_key из базового класса
- DN - уникальный идентификатор объекта в LDAP, требуется для всех операций
- Удалено избыточное поле distinguished_name
ИЗМЕНЕНИЯ:
- orm_models.py: cn/ou теперь обычные CharField, dn наследуется из ldapdb.models.Model
- group_utils_orm.py: .get(cn=...) → .filter(cn=...).first() с предупреждением
- group_service.py: find_dn() и find_dn_wrapped() используют .filter().first()
ОБОСНОВАНИЕ ОТКАЗА ОТ objectGUID:
- objectGUID неизменяем и уникален, НО:
- LDAP протокол требует DN для всех операций (add/modify/delete/rename)
- django-ldapdb жёстко завязан на self.dn для save()/delete()
- GUID можно использовать только в search filter, результат всё равно возвращает DN
- DN - естественный primary key для LDAP объектов
Добавлена подробная документация о:
- Ограничениях django-ldapdb (ModifyDN, динамический DN, collision handling)
- Архитектуре использования ORM vs ldap3 для разных операций
- Структуре DN для пользователей/групп/OU в Active Directory
- Роли base_dn в моделях (только для SUBTREE search, не для create/move)
Улучшены docstring для:
- LdapUser: объяснено где реально хранятся пользователи (по отделам, Dismissed)
- LdapGroup: объяснена структура групп (глобальные, роли отделов)
- LdapOrganizationalUnit: структура OU отделов
- get_users_base() и get_base_dn(): приоритеты и покрытие DN
Это решает вопрос о том, как модель работает с пользователями в разных OU
при переводах между отделами и увольнениях.
- sync_service: заменены read_attrs + modify_user_attrs на LdapUser ORM
для записи employeeNumber при импорте пользователей
- Убран неиспользуемый импорт modify_user_attrs
Добавлена полная поддержка связи Employee ↔ LdapUser через LdapSyncState.
Ключевые изменения:
- orm_services.py теперь автоматически создает/обновляет/удаляет LdapSyncState
- create_user() создает LdapSyncState при указании employee_pk
- update_user() обновляет last_django_modify_ts в LdapSyncState
- delete_user() удаляет LdapSyncState при hard delete
- Добавлен метод get_user_dn_by_employee_pk() для быстрого получения DN
- Улучшен метод get_user_by_employee_pk() - сначала ищет через LdapSyncState
Архитектура связи:
Employee (pk=123, PostgreSQL)
↓ object_pk
LdapSyncState (ldap_dn='CN=...', PostgreSQL)
↓ ldap_dn
LdapUser (dn='CN=...', employee_number='123', LDAP server)
Обновлена документация:
- ORM_INTEGRATION.md теперь объясняет связь моделей
- Добавлена диаграмма архитектуры
- Описаны роли каждой модели
- Объяснено почему нет ForeignKey (разные БД)
Добавлена поддержка django-ldapdb для упрощения работы с LDAP через Django ORM.
Ключевые изменения:
- Установлен django-ldapdb==1.5.1 в requirements.txt
- Созданы LDAP ORM модели (LdapUser, LdapGroup, LdapOrganizationalUnit) в employees/ldap/orm_models.py
- Реализованы ORM сервисы для CRUD операций в employees/ldap/orm_services.py
- Настроен database router в eusrr_backend/db_routers.py
- Добавлена LDAP database конфигурация в settings.py
- Создана документация ORM_INTEGRATION.md
Архитектура:
✅ GET → Employee (PostgreSQL) - быстро
✅ POST/PUT/DELETE → LdapUser (django-ldapdb ORM) - просто
Преимущества:
- Django ORM вместо низкоуровневого ldap3
- Автоматическая валидация через Django fields
- Унифицированный CRUD API
- Меньше кода, больше удобства
Файлы:
+ employees/ldap/orm_models.py - LDAP ORM модели (LdapUser, LdapGroup, LdapOrganizationalUnit)
+ employees/ldap/orm_services.py - ORM сервисы (LdapOrmUserService, LdapOrmGroupService, LdapOrmDepartmentService)
+ employees/ldap/ORM_INTEGRATION.md - документация по интеграции
+ eusrr_backend/db_routers.py - database router для LDAP моделей
~ requirements.txt - добавлен django-ldapdb==1.5.1
~ eusrr_backend/settings.py - настроена LDAP database и router
- Добавлена настройка LDAP_EMPLOYEE_ID_ATTR (по умолчанию 'employeeNumber')
- UserService.create_user() теперь записывает pk в LDAP после создания Employee
- SyncService.import_users() записывает pk при создании новых пользователей
- SyncService.import_users() проверяет и заполняет пустой employeeNumber при update
- Добавлен диагностический скрипт check_employee_id_attrs.py для проверки AD атрибутов
When LDAP is enabled:
- create_role: creates LDAP group before DB record
- update_role: renames LDAP group before DB update
- delete_role: deletes LDAP group before DB deletion
- assign_role: adds to LDAP group before creating RoleAssignment
- revoke_role: removes from LDAP group before deactivating assignment
On LDAP error: operation fails, no DB changes
On DB error after LDAP success: rollback LDAP changes
Fixes: roles could bypass LDAP when it was enabled
BREAKING CHANGES:
- Removed OU=Roles from LDAP structure, role groups now created directly in department OU
- Role groups use ROLE_<Name> naming convention (similar to DEP_, POS_)
New features:
- RoleAssignment model: allows assigning roles to ANY employee, not just department members
- New API endpoints: /assign/, /revoke/, /assignments/ on DepartmentRoleViewSet
- Permission system updated: has_dept_perm() checks RoleAssignment first
- Employee profile shows external roles with 'внешняя роль' badge
- Employee list filter includes role-only employees with department_relation field
- Role assignment modal now searches all employees via AJAX
LDAP changes:
- _ensure_role_group(): creates ROLE_<Name> group in department OU
- assign_role(), revoke_role(): manage RoleAssignment + LDAP sync
- rename_role_group(), delete_role_group(): full lifecycle support
- Removed all references to OU=Roles
Migrations:
- 0035: Create RoleAssignment model
- 0036: Data migration from EmployeeDepartment.role
Tests:
- 7 new tests for role assignment (all passing)
Docs:
- 9 detailed reports on architecture, models, API, LDAP, tests
Implemented automatic movement of dismissed employees to dedicated OU.
Core Changes:
- Configuration: Added LDAP_DISMISSED_BASE setting for dismissed employees OU
- Utility: Created get_base_dn_for_employee() to determine target OU by is_active status
- Service Layer: Modified DepartmentService.remove_member() to use status-based OU selection
- View Logic: Updated EmployeeActionViewSet._apply_effects() to:
* Deactivate employee BEFORE removing from departments (critical for correct OU routing)
* Loop through all departments and call remove_member() for each
* Handle restoration: move from OU=Dismissed back to OU=Users on activation
Infrastructure:
- Management Command: ensure_dismissed_ou.py to create OU=Dismissed in AD
* Checks OU existence before creation
* Supports --force flag for recreation
* Configurable description
Testing:
- test_dismissal_moves_to_dismissed_ou: Verifies dismissal → OU=Dismissed movement
- test_restoration_moves_from_dismissed_to_users: Verifies activation → OU=Users movement
Edge Cases Handled:
- Repeat dismissal: Idempotent (already inactive employees stay in OU=Dismissed)
- Restoration: Automatic move from OU=Dismissed to OU=Users on any activating action
- Missing OU: ensure_container_exists() creates OU=Dismissed if missing (fallback to OU=Users on error)
- LDAP errors: Graceful logging without blocking database operations
Technical Details:
- OU=Dismissed: OU=Dismissed,OU=company,DC=robotail,DC=local
- Dismissal flow: is_active=False → deactivate EmployeeDepartment → update LDAP UAC → remove from all departments
- Restoration flow: is_active=True → update LDAP UAC → check current OU → move if in OU=Dismissed
- Backward compatible: Works with existing dismissals (they will move to OU=Dismissed on next department change)
Files Changed:
- backend/.env: Added LDAP_DISMISSED_BASE configuration
- backend/eusrr_backend/settings.py: Registered LDAP_DISMISSED_BASE setting
- backend/employees/ldap/utils/ldap_utils.py: New get_base_dn_for_employee() utility
- backend/employees/ldap/services/department_service.py: Modified remove_member() logic
- backend/api/v1/employees/views.py: Enhanced dismissal and restoration logic
- backend/employees/management/commands/ensure_dismissed_ou.py: New management command
- backend/tests/api/v1/employees/test_employee_actions.py: Added tests for new behavior
Problem:
- When assigning/changing position, got 502 error
- Error: 'DirectoryService' object has no attribute '_ensure_position_group'
- Method was called on wrong service class
Root Cause:
DirectoryService doesn't have _ensure_position_group method.
This method exists in PositionService (line 190 of position_service.py).
Solution:
- Import and use PositionService for position-specific operations
- Keep DirectoryService for general group operations (add/remove members)
- Use pos_svc._ensure_position_group() to create/get position group DN
- Use dir_svc for add_group_members/remove_group_members
Now position assignment/changes work correctly through LDAP sync.
Problem:
- When clearing employee position (position_id: ''), LDAP sync failed
- Error: LDAP modify failed: 'unwillingToPerform' (code 53)
- System tried to remove user from position group but LDAP refused
Root Causes:
1. Empty string '' not explicitly converted to None
2. unwillingToPerform error not handled (user may not be in group)
Solution:
1. Convert position_id: '' to None (no position) at parsing stage
2. Wrap remove_group_members in try/except
3. Ignore unwillingToPerform errors with warning log
4. Add detailed logging for position group operations
This allows users to clear positions without LDAP sync failures.