diff --git a/backend/api/v1/employees/views/actions.py b/backend/api/v1/employees/views/actions.py
index ea7a0f6b..8b2dcfd0 100644
--- a/backend/api/v1/employees/views/actions.py
+++ b/backend/api/v1/employees/views/actions.py
@@ -7,7 +7,7 @@ import logging
from django.db import transaction
from django.utils import timezone
from employees.constants import ACTION_DISMISSED
-from employees.models import EmployeeAction, EmployeeDepartment
+from employees.models import Department, EmployeeAction, EmployeeDepartment
from rest_framework import filters, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@@ -87,6 +87,20 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
emp = action_obj.employee
if action_obj.action == ACTION_DISMISSED:
+ # Снимаем с должности руководителя (до деактивации!)
+ headed_depts = list(Department.objects.filter(head=emp))
+ if headed_depts:
+ for dept in headed_depts:
+ logger.info(
+ f"Clearing head of department '{dept.name}' "
+ f"(employee_id={emp.id}) due to dismissal"
+ )
+ Department.objects.filter(head=emp).update(
+ head=None, head_appointed_at=None
+ )
+ # Очищаем managedBy в LDAP (best-effort)
+ self._clear_ldap_managed_by(headed_depts)
+
# Деактивируем сотрудника (сигнал синхронизирует в LDAP)
if emp.is_active:
emp.is_active = False
@@ -107,6 +121,104 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
emp._ldap_changes = {"is_active": True}
emp.save(update_fields=["is_active"])
+ # Гарантируем, что DN в правильном OU (Users или Dismissed)
+ # Покрывает: восстановление, увольнение без отделов, множественные отделы
+ self._ensure_ldap_dn_location(emp)
+
+ @staticmethod
+ def _ensure_ldap_dn_location(emp):
+ """Гарантирует, что LDAP DN сотрудника в правильном OU.
+
+ is_active=False → OU=Dismissed
+ is_active=True → OU=Users
+ """
+ from django.conf import settings
+
+ if not getattr(settings, "LDAP_ENABLED", False):
+ return
+
+ try:
+ from employees.ldap.utils.ldap_utils import get_base_dn_for_employee
+ from employees.models import LdapSyncState
+
+ sync_state = LdapSyncState.objects.filter(
+ model="employee", object_pk=str(emp.pk)
+ ).first()
+ if not sync_state or not sync_state.ldap_dn:
+ return
+
+ current_dn = sync_state.ldap_dn
+ target_base = get_base_dn_for_employee(emp)
+
+ # Проверяем, уже ли в правильном OU
+ parts = current_dn.split(",", 1)
+ if len(parts) == 2 and parts[1].lower() == target_base.lower():
+ return
+
+ from employees.ldap.infrastructure.connections import _ldap
+ from employees.ldap.repositories.ldap_repository import (
+ ensure_container_exists,
+ )
+
+ with _ldap() as conn:
+ ensure_container_exists(conn, target_base)
+ rdn = parts[0]
+ ok = conn.modify_dn(current_dn, rdn, new_superior=target_base)
+ if ok:
+ new_dn = f"{rdn},{target_base}"
+ sync_state.ldap_dn = new_dn
+ sync_state.save(update_fields=["ldap_dn"])
+ logger.info(
+ f"Moved employee {emp.id} DN: "
+ f"{current_dn} → {new_dn}"
+ )
+ else:
+ logger.warning(
+ f"LDAP modify_dn failed for employee "
+ f"{emp.id}: {conn.result}"
+ )
+ except Exception as e:
+ logger.warning(
+ f"Failed to ensure LDAP DN location for "
+ f"employee {emp.id}: {e}"
+ )
+
+ @staticmethod
+ def _clear_ldap_managed_by(departments):
+ """Очищает LDAP managedBy для переданных отделов (best-effort)."""
+ from django.conf import settings
+
+ if not getattr(settings, "LDAP_ENABLED", False):
+ return
+
+ try:
+ from employees.ldap.orm_models import LdapOrganizationalUnit
+ from employees.models import LdapSyncState
+
+ for dept in departments:
+ sync = LdapSyncState.objects.filter(
+ model="department", object_pk=str(dept.pk)
+ ).first()
+ if not sync or not sync.ldap_dn:
+ continue
+ try:
+ ou = LdapOrganizationalUnit.objects.get(dn=sync.ldap_dn)
+ if ou.managed_by:
+ ou.managed_by = ""
+ ou.save()
+ logger.info(
+ f"Cleared LDAP managedBy for OU={sync.ldap_dn}"
+ )
+ except LdapOrganizationalUnit.DoesNotExist:
+ pass
+ except Exception as e:
+ logger.warning(
+ f"Failed to clear LDAP managedBy for "
+ f"dept '{dept.name}': {e}"
+ )
+ except Exception as e:
+ logger.warning(f"Failed to clear LDAP managedBy: {e}")
+
@transaction.atomic
def perform_create(self, serializer):
obj = serializer.save()
diff --git a/backend/employees/ldap/admin.py b/backend/employees/ldap/admin.py
index 741d9445..64b31b98 100644
--- a/backend/employees/ldap/admin.py
+++ b/backend/employees/ldap/admin.py
@@ -55,10 +55,16 @@ class LdapUserAdmin(admin.ModelAdmin):
"""
def get_urls(self):
- """Переопределяем URLs для корректной работы с DN как PK."""
- urls = super().get_urls()
- # Django автоматически добавит наши URL patterns с правильным кодированием
- return urls
+ """Добавляем URL для смены пароля конкретного пользователя."""
+ from django.urls import re_path
+ urls = [
+ re_path(
+ r'^(.+)/password/$',
+ self.admin_site.admin_view(self.user_change_password),
+ name='ldapuser_password_change',
+ ),
+ ]
+ return urls + super().get_urls()
list_display = (
'cn_display',
@@ -87,6 +93,7 @@ class LdapUserAdmin(admin.ModelAdmin):
'member_of_display',
'sync_info',
'thumbnail_photo_display',
+ 'password_change_link',
)
fieldsets = (
@@ -117,6 +124,7 @@ class LdapUserAdmin(admin.ModelAdmin):
'fields': (
'user_account_control',
'description',
+ 'password_change_link',
)
}),
('🖼️ Дополнительно', {
@@ -143,6 +151,7 @@ class LdapUserAdmin(admin.ModelAdmin):
'sync_from_ldap_to_django',
'sync_from_django_to_ldap',
'show_sync_diff',
+ 'change_password_action',
]
# Пагинация (LDAP может быть медленным)
@@ -346,6 +355,41 @@ class LdapUserAdmin(admin.ModelAdmin):
)
sync_info.short_description = 'Информация о синхронизации'
+ def password_change_link(self, obj):
+ """Ссылка на форму смены пароля (как в стандартной Django admin)."""
+ if not obj.dn:
+ return '-'
+
+ # Кодируем DN для URL
+ from urllib.parse import quote
+ dn_encoded = quote(obj.dn, safe='')
+
+ change_password_url = reverse(
+ 'admin:ldapuser_password_change',
+ args=[dn_encoded],
+ )
+
+ return format_html(
+ '
'
+ ''
+ 'Пароль будет изменён в LDAP и автоматически синхронизирован с Django БД.'
+ '
',
+ change_password_url
+ )
+ password_change_link.short_description = 'Управление паролем'
+
# Actions для синхронизации
@admin.action(description='🔄 Синхронизировать LDAP → Django (LDAP как источник истины)')
@@ -518,6 +562,144 @@ class LdapUserAdmin(admin.ModelAdmin):
level=messages.WARNING
)
+ @admin.action(description='🔑 Изменить пароль выбранных пользователей')
+ def change_password_action(self, request, queryset):
+ """Изменяет пароль выбранных LDAP пользователей.
+
+ Показывает форму для ввода нового пароля и применяет его
+ к выбранным пользователям через AD extended operation.
+ """
+ ldap_users = self._resolve_ldap_users(request)
+
+ if not ldap_users:
+ self.message_user(
+ request,
+ '❌ Не удалось найти выбранных пользователей',
+ level=messages.ERROR
+ )
+ return
+
+ # Если форма не отправлена - показываем её
+ if 'new_password' not in request.POST:
+ from django import forms
+ from django.template.response import TemplateResponse
+
+ class PasswordForm(forms.Form):
+ new_password = forms.CharField(
+ label='Новый пароль',
+ widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
+ min_length=7,
+ help_text='Минимум 7 символов, должен соответствовать политике AD'
+ )
+ confirm_password = forms.CharField(
+ label='Подтверждение пароля',
+ widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
+ )
+
+ def clean(self):
+ cleaned = super().clean()
+ pwd1 = cleaned.get('new_password')
+ pwd2 = cleaned.get('confirm_password')
+ if pwd1 and pwd2 and pwd1 != pwd2:
+ raise forms.ValidationError('Пароли не совпадают')
+ return cleaned
+
+ form = PasswordForm()
+
+ context = {
+ **self.admin_site.each_context(request),
+ 'title': 'Изменение пароля LDAP пользователей',
+ 'form': form,
+ 'users': ldap_users,
+ 'users_count': len(ldap_users),
+ 'action_name': 'change_password_action',
+ 'opts': self.model._meta,
+ 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME,
+ 'media': self.media,
+ }
+ request.current_app = self.admin_site.name
+
+ # Используем кастомный шаблон для смены пароля
+ return TemplateResponse(
+ request,
+ 'admin/ldap_change_password.html',
+ context,
+ )
+
+ # Форма отправлена - обрабатываем
+ new_password = request.POST.get('new_password', '').strip()
+ confirm_password = request.POST.get('confirm_password', '').strip()
+
+ if not new_password:
+ self.message_user(
+ request,
+ '❌ Пароль не может быть пустым',
+ level=messages.ERROR
+ )
+ return
+
+ if new_password != confirm_password:
+ self.message_user(
+ request,
+ '❌ Пароли не совпадают',
+ level=messages.ERROR
+ )
+ return
+
+ if len(new_password) < 7:
+ self.message_user(
+ request,
+ '❌ Пароль должен содержать минимум 7 символов',
+ level=messages.ERROR
+ )
+ return
+
+ # Применяем пароль ко всем выбранным пользователям
+ success_count = 0
+ error_count = 0
+
+ for ldap_user in ldap_users:
+ try:
+ ldap_user.set_password(new_password)
+ success_count += 1
+
+ # Обновляем Django Employee если есть связь
+ if ldap_user.employee_number:
+ try:
+ emp = Employee.objects.get(pk=int(ldap_user.employee_number))
+ emp.set_password(new_password)
+ emp.save(update_fields=['password'])
+ except (Employee.DoesNotExist, ValueError):
+ pass # Не критично
+
+ except ValueError as e:
+ error_count += 1
+ self.message_user(
+ request,
+ f'⚠️ {ldap_user.cn}: {e}',
+ level=messages.WARNING
+ )
+ except Exception as e:
+ error_count += 1
+ self.message_user(
+ request,
+ f'❌ {ldap_user.cn}: ошибка - {e}',
+ level=messages.ERROR
+ )
+
+ if success_count:
+ self.message_user(
+ request,
+ f'✅ Пароль успешно изменён для {success_count} пользователей',
+ level=messages.SUCCESS
+ )
+ if error_count:
+ self.message_user(
+ request,
+ f'❌ Ошибок: {error_count}',
+ level=messages.WARNING
+ )
+
@admin.action(description='🔍 Показать различия LDAP ↔ Django')
def show_sync_diff(self, request, queryset):
"""Показывает различия между LDAP и Django для выбранных пользователей."""
@@ -568,6 +750,100 @@ class LdapUserAdmin(admin.ModelAdmin):
level=messages.SUCCESS
)
+ # Страница смены пароля для конкретного пользователя
+
+ def user_change_password(self, request, dn_encoded):
+ """Форма смены пароля для конкретного LDAP пользователя."""
+ from django import forms
+ from django.template.response import TemplateResponse
+ from django.http import HttpResponseRedirect
+ from urllib.parse import unquote
+
+ # Декодируем DN
+ dn = unquote(dn_encoded)
+
+ # Находим пользователя
+ try:
+ user = LdapUser.objects.get(dn=dn)
+ except LdapUser.DoesNotExist:
+ self.message_user(
+ request,
+ '❌ Пользователь не найден',
+ level=messages.ERROR
+ )
+ return HttpResponseRedirect('../../') # Возврат на список
+
+ # Форма для смены пароля
+ class PasswordChangeForm(forms.Form):
+ password1 = forms.CharField(
+ label='Новый пароль',
+ widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
+ min_length=7,
+ help_text='Минимум 7 символов, должен соответствовать политике AD'
+ )
+ password2 = forms.CharField(
+ label='Подтверждение пароля',
+ widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
+ )
+
+ def clean(self):
+ cleaned = super().clean()
+ pwd1 = cleaned.get('password1')
+ pwd2 = cleaned.get('password2')
+ if pwd1 and pwd2 and pwd1 != pwd2:
+ raise forms.ValidationError('Пароли не совпадают')
+ return cleaned
+
+ if request.method == 'POST':
+ form = PasswordChangeForm(request.POST)
+ if form.is_valid():
+ new_password = form.cleaned_data['password1']
+
+ try:
+ # Меняем пароль в LDAP
+ user.set_password(new_password)
+
+ # Синхронизируем с Django Employee
+ if user.employee_number:
+ try:
+ emp = Employee.objects.get(pk=int(user.employee_number))
+ emp.set_password(new_password)
+ emp.save(update_fields=['password'])
+ except (Employee.DoesNotExist, ValueError):
+ pass # Не критично
+
+ self.message_user(
+ request,
+ f'✅ Пароль успешно изменён для {user.cn}',
+ level=messages.SUCCESS
+ )
+
+ # Перенаправляем на список пользователей (избегаем проблем с кодировкой DN)
+ return HttpResponseRedirect('../../')
+
+ except ValueError as e:
+ form.add_error(None, f'Ошибка валидации: {e}')
+ except Exception as e:
+ form.add_error(None, f'Ошибка LDAP: {e}')
+ else:
+ form = PasswordChangeForm()
+
+ context = {
+ **self.admin_site.each_context(request),
+ 'title': f'Изменение пароля: {user.cn}',
+ 'form': form,
+ 'user': user,
+ 'opts': self.model._meta,
+ 'original': user,
+ 'media': self.media,
+ }
+
+ return TemplateResponse(
+ request,
+ 'admin/ldap_user_password_change.html',
+ context,
+ )
+
# Переопределение разрешений (опционально - можно разрешить редактирование)
def has_add_permission(self, request):
@@ -806,7 +1082,7 @@ class LdapOrganizationalUnitAdmin(admin.ModelAdmin):
ldap_user = LdapUser.objects.get(dn=mb)
# Правильный URL pattern для LDAP моделей
url = reverse(
- 'admin:ldap_ldapuser_change', args=[quote(ldap_user.dn, safe='')],
+ 'admin:employees_ldapuser_change', args=[quote(ldap_user.dn, safe='')],
)
return format_html(
'👤 {} ({})
'
@@ -1954,7 +2230,7 @@ class LdapGroupAdmin(admin.ModelAdmin):
try:
ldap_user = LdapUser.objects.get(dn=member_dn)
url = reverse(
- 'admin:ldap_ldapuser_change',
+ 'admin:employees_ldapuser_change',
args=[quote(ldap_user.dn, safe='')],
)
html += format_html(
@@ -1968,7 +2244,7 @@ class LdapGroupAdmin(admin.ModelAdmin):
try:
ldap_group = LdapGroup.objects.get(dn=member_dn)
url = reverse(
- 'admin:ldap_ldapgroup_change',
+ 'admin:employees_ldapgroup_change',
args=[quote(ldap_group.dn, safe='')],
)
html += format_html(
@@ -2013,7 +2289,7 @@ class LdapGroupAdmin(admin.ModelAdmin):
try:
parent_group = LdapGroup.objects.get(dn=group_dn)
url = reverse(
- 'admin:ldap_ldapgroup_change',
+ 'admin:employees_ldapgroup_change',
args=[quote(parent_group.dn, safe='')],
)
html += format_html(
diff --git a/backend/employees/ldap/orm_models.py b/backend/employees/ldap/orm_models.py
index f79ef900..37971bcb 100644
--- a/backend/employees/ldap/orm_models.py
+++ b/backend/employees/ldap/orm_models.py
@@ -284,6 +284,14 @@ class LdapUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
# Управление учетной записью
user_account_control = IntegerField(db_column='userAccountControl')
+ # Пароль (write-only атрибут AD)
+ # ВАЖНО: unicodePwd - специальный атрибут Active Directory
+ # - Write-only: можно записать, но нельзя прочитать
+ # - Требует SSL/TLS соединения
+ # - Требует специальной кодировки: UTF-16-LE с кавычками
+ # - Используйте метод set_password() вместо прямой записи
+ unicode_pwd = CharField(db_column='unicodePwd', blank=True)
+
# Дополнительная информация
description = CharField(db_column='description', blank=True)
thumbnail_photo = ImageField(db_column='thumbnailPhoto', blank=True)
@@ -307,6 +315,57 @@ class LdapUser(LdapSyncStateMixin, ModifyDnMixin, LdapModel):
def __repr__(self):
return f""
+ def set_password(self, new_password: str) -> None:
+ """Устанавливает новый пароль для пользователя через AD extended operation.
+
+ Использует Microsoft-специфичное расширение modify_password для корректной
+ работы с политиками паролей AD. Это более надежный способ, чем прямая
+ запись в unicodePwd.
+
+ Args:
+ new_password: Новый пароль (plaintext)
+
+ Raises:
+ ValueError: Если пароль пустой или не соответствует политике
+ DirectoryLdapError: Если операция не удалась
+
+ Example:
+ >>> ldap_user = LdapUser.objects.get(dn='CN=...')
+ >>> ldap_user.set_password('NewSecurePass123!')
+ >>> # Пароль изменён в AD, НЕ требуется .save()
+ """
+ from .infrastructure.connections import _ldap
+ from .services.user_password_service import UserPasswordService
+
+ service = UserPasswordService()
+ with _ldap() as conn:
+ service.set_password(conn, self.dn, new_password)
+
+ def change_password(self, old_password: str, new_password: str) -> None:
+ """Меняет пароль пользователя (требует знания старого пароля).
+
+ Используется для самостоятельной смены пароля пользователем.
+
+ Args:
+ old_password: Текущий пароль
+ new_password: Новый пароль
+
+ Raises:
+ ValueError: Если новый пароль не соответствует политике
+ DirectoryLdapError: Если старый пароль неверен или операция не удалась
+
+ Example:
+ >>> ldap_user = LdapUser.objects.get(dn='CN=...')
+ >>> ldap_user.change_password('OldPass123', 'NewSecurePass123!')
+ >>> # Пароль изменён в AD, НЕ требуется .save()
+ """
+ from .infrastructure.connections import _ldap
+ from .services.user_password_service import UserPasswordService
+
+ service = UserPasswordService()
+ with _ldap() as conn:
+ service.change_password(conn, self.dn, old_password, new_password)
+
class LdapGroup(ModifyDnMixin, LdapModel):
"""LDAP модель для глобальных групп Active Directory.
diff --git a/backend/employees/signals/ldap/department.py b/backend/employees/signals/ldap/department.py
index 8acdc61b..a3bfcbd4 100644
--- a/backend/employees/signals/ldap/department.py
+++ b/backend/employees/signals/ldap/department.py
@@ -8,6 +8,7 @@ import logging
from django.conf import settings
from django.db.models.signals import post_delete, post_save, m2m_changed
from django.dispatch import receiver
+from django.utils import timezone
from employees.ldap import DepartmentService
from employees.ldap.domain.dtos import DirectoryDepartmentDTO
@@ -157,7 +158,11 @@ def sync_department_to_ldap_on_delete(sender, instance, **kwargs):
@receiver(post_save, sender='employees.EmployeeDepartment')
def sync_department_member_to_ldap(sender, instance, created, **kwargs):
- """Синхронизирует добавление/изменение члена отдела с LDAP."""
+ """Синхронизирует добавление/изменение члена отдела с LDAP.
+
+ is_active=True → перемещаем в OU отдела + добавляем в группу
+ is_active=False → перемещаем в Users/Dismissed OU + убираем из группы
+ """
if not _is_ldap_enabled():
return
@@ -165,24 +170,106 @@ def sync_department_member_to_ldap(sender, instance, created, **kwargs):
return
try:
+ from employees.ldap.infrastructure.connections import _ldap
+ from employees.ldap.repositories.ldap_repository import ensure_container_exists
from employees.ldap.services.department_service import DepartmentService
from employees.ldap.services.group_service import GroupService
from employees.ldap.services.user_service import UserService
-
+ from employees.ldap.utils.dn_utils import _move_to_department
+ from employees.ldap.utils.ldap_utils import get_base_dn_for_employee
+ from employees.ldap.services.constants import SyncDirection
+
group_service = GroupService()
user_service = UserService(group_service)
dept_service = DepartmentService(group_service, user_service)
+ employee = instance.employee
+ department = instance.department
+
+ # Получаем DN сотрудника
+ try:
+ emp_dn = user_service._get_employee_dn(employee)
+ except Exception:
+ logger.debug(
+ f"Employee {employee.id} has no LDAP DN, skipping member sync"
+ )
+ return
+
if instance.is_active:
- # Добавляем/перемещаем пользователя в OU отдела
- dept_service._move_user_to_department(instance.employee, instance.department)
-
+ # === Активация: перемещаем в OU отдела + добавляем в группу ===
+ try:
+ dept_dn = dept_service._get_department_dn(department)
+ except Exception:
+ dept_dn = None
+
+ with _ldap() as conn:
+ ensured_dn = dept_service._ensure_department_ou(conn, department.name)
+ if not dept_dn or dept_dn != ensured_dn:
+ dept_service._touch_state(
+ model="department",
+ object_pk=department.pk,
+ ldap_dn=ensured_dn,
+ last_django_modify_ts=timezone.now(),
+ sync_dir=SyncDirection.AUTO,
+ )
+ dept_dn = ensured_dn
+
+ # Перемещаем пользователя в OU отдела
+ new_dn = _move_to_department(conn, emp_dn, dept_dn)
+ if new_dn != emp_dn:
+ dept_service._touch_state(
+ model="employee",
+ object_pk=employee.pk,
+ ldap_dn=new_dn,
+ sync_dir=SyncDirection.LDAP,
+ )
+
+ # Добавляем в группу отдела
+ group_dn = dept_service._ensure_department_group(conn, department, dept_dn)
+ if group_dn:
+ group_service.add_members(group_dn, [new_dn])
+
# Устанавливаем роль если есть
if instance.role:
- dept_service.set_member_role(instance.department, instance.employee, instance.role)
+ dept_service.set_member_role(department, employee, instance.role)
else:
- # Перемещаем обратно в Users OU
- dept_service._move_user_to_base_ou(instance.employee)
+ # === Деактивация: убираем из группы + перемещаем в Users/Dismissed OU ===
+ with _ldap() as conn:
+ # Убираем из группы отдела
+ grp_dn = (department.ldap_group_dn or "").strip()
+ if grp_dn:
+ try:
+ group_service.remove_members(grp_dn, [emp_dn])
+ except Exception as grp_err:
+ logger.warning(
+ f"Failed to remove {employee.id} "
+ f"from group {grp_dn}: {grp_err}"
+ )
+
+ # Определяем целевой OU
+ target_base = get_base_dn_for_employee(employee)
+
+ # Проверяем: уже в целевом OU?
+ parts = emp_dn.split(",", 1)
+ already_there = (
+ len(parts) == 2
+ and parts[1].lower() == target_base.lower()
+ )
+ if not already_there:
+ ensure_container_exists(conn, target_base)
+ new_dn = user_service._move_user_to_base(
+ conn, emp_dn, target_base
+ )
+ dept_service._touch_state(
+ model="employee",
+ object_pk=employee.pk,
+ ldap_dn=new_dn,
+ sync_dir=SyncDirection.LDAP,
+ )
+ logger.info(
+ f"Moved employee {employee.id} "
+ f"from {emp_dn} to {new_dn}"
+ )
except Exception as e:
logger.error(
diff --git a/backend/templates/admin/ldap_change_password.html b/backend/templates/admin/ldap_change_password.html
new file mode 100644
index 00000000..c8115fed
--- /dev/null
+++ b/backend/templates/admin/ldap_change_password.html
@@ -0,0 +1,63 @@
+{% extends "admin/base_site.html" %}
+{% load i18n admin_urls static %}
+
+{% block extrahead %}
+ {{ block.super }}
+ {{ media }}
+{% endblock %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+
+
🔑 Изменение пароля LDAP пользователей
+
+
+
Выбрано пользователей: {{ users_count }}
+
+ {% for user in users %}
+ - {{ user.display_name }} ({{ user.sam_account_name }})
+ {% endfor %}
+
+
+
+
+
+
+
⚠️ Требования к паролю:
+
+ - Минимум 7 символов
+ - Должен содержать буквы верхнего и нижнего регистра
+ - Должен содержать цифры
+ - Может содержать специальные символы
+ - Не должен содержать имя пользователя
+ - Должен соответствовать политике безопасности Active Directory
+
+
+
Важно: Пароль будет изменён и в LDAP, и в Django БД для синхронизированных пользователей.
+
+
+{% endblock %}
diff --git a/backend/templates/admin/ldap_user_password_change.html b/backend/templates/admin/ldap_user_password_change.html
new file mode 100644
index 00000000..613ba1f8
--- /dev/null
+++ b/backend/templates/admin/ldap_user_password_change.html
@@ -0,0 +1,103 @@
+{% extends "admin/base_site.html" %}
+{% load i18n admin_urls static %}
+
+{% block extrahead %}
+ {{ block.super }}
+ {{ media }}
+{% endblock %}
+
+{% block breadcrumbs %}
+
+{% endblock %}
+
+{% block content %}
+
+
🔑 Изменение пароля
+
+
+
Пользователь:
+
+
+ | CN: |
+ {{ user.cn }} |
+
+
+ | sAMAccountName: |
+ {{ user.sam_account_name }} |
+
+
+ | Email: |
+ {{ user.mail }} |
+
+ {% if user.employee_number %}
+
+ | Employee ID: |
+ {{ user.employee_number }} |
+
+ {% endif %}
+
+
+
+
+
+
+
⚠️ Требования к паролю:
+
+ - Минимум 7 символов
+ - Должен содержать буквы верхнего и нижнего регистра
+ - Должен содержать цифры
+ - Может содержать специальные символы
+ - Не должен содержать имя пользователя
+ - Должен соответствовать политике безопасности Active Directory
+
+
+
+ Важно: Пароль будет изменён в LDAP (Active Directory)
+ {% if user.employee_number %}
+ и автоматически синхронизирован с Django БД.
+ {% else %}
+ (без синхронизации с Django, т.к. employeeNumber не установлен).
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/docs/guides/ldap-password-change-admin.md b/docs/guides/ldap-password-change-admin.md
new file mode 100644
index 00000000..1d2fd743
--- /dev/null
+++ b/docs/guides/ldap-password-change-admin.md
@@ -0,0 +1,195 @@
+# Смена паролей LDAP через админку
+
+## Возможности
+
+Теперь можно менять пароли пользователей LDAP прямо через Django админ-панель!
+
+### Добавлено в LdapUser модель:
+
+1. **Поле `unicode_pwd`** (write-only) - для записи паролей в AD
+2. **Метод `set_password(new_password)`** - установка пароля администратором
+3. **Метод `change_password(old_password, new_password)`** - смена пароля пользователем
+
+### Добавлено в админку:
+
+1. **Кнопка "🔑 Изменить пароль"** в карточке пользователя
+2. **Action "🔑 Изменить пароль выбранных пользователей"** в списке
+
+## Как использовать
+
+### Вариант 1: Через карточку пользователя (РЕКОМЕНДУЕТСЯ ⭐)
+
+1. Откройте `/admin/employees/ldapuser/`
+2. **Кликните** на нужном пользователе (откроется карточка)
+3. Прокрутите до секции **"⚙️ Управление учетной записью"**
+4. Нажмите кнопку **"🔑 Изменить пароль"**
+5. Введите новый пароль (минимум 7 символов)
+6. Подтвердите пароль
+7. Нажмите **"Изменить пароль"**
+
+**Результат:**
+- Пароль изменён в LDAP (Active Directory)
+- Пароль автоматически синхронизирован в Django БД (если есть связь через `employee_number`)
+- Вы вернётесь на страницу редактирования пользователя
+
+**Преимущества:**
+- ✅ Удобнее - не нужно искать пользователя в списке и выбирать action
+- ✅ Быстрее - сразу видно, какого пользователя редактируем
+- ✅ Безопаснее - меньше шансов ошибиться с выбором пользователя
+
+### Вариант 2: Через action в списке (массовая операция)
+
+1. Откройте `/admin/employees/ldapuser/`
+2. Выберите одного или нескольких пользователей (чекбоксы)
+3. В dropdown "Действие" выберите **"🔑 Изменить пароль выбранных пользователей"**
+4. Нажмите "Выполнить"
+5. Введите новый пароль (минимум 7 символов)
+6. Подтвердите пароль
+7. Нажмите "Изменить пароль"
+
+**Результат:**
+- Пароль изменён в LDAP (Active Directory)
+- Пароль автоматически синхронизирован в Django БД (если есть связь через `employee_number`)
+- Вы получите сообщение об успехе или ошибке
+
+**Когда использовать:**
+- При необходимости сменить пароль сразу нескольким пользователям
+- Для массовых операций (например, сброс паролей всем новым сотрудникам)
+
+### Вариант 3: Программно через ORM
+
+```python
+from employees.ldap.orm_models import LdapUser
+
+# Найти пользователя
+user = LdapUser.objects.get(sam_account_name='ivanov_i')
+
+# Установить новый пароль (админ)
+user.set_password('NewSecurePass123!')
+
+# Или сменить пароль (требует старый пароль)
+user.change_password('OldPass123', 'NewSecurePass123!')
+4
+# НЕ нужно вызывать .save() — пароль уже изменён в AD!
+```
+
+### Вариант 3: Через Django Shell
+
+```bash
+cd /home/lizerk/Dev/EUSRR/backend
+.venv/bin/python manage.py shell
+```
+
+```python
+from employees.ldap.orm_models import LdapUser
+from employees.models import Employee
+
+# Найти LDAP пользователя
+ldap_user = LdapUser.objects.get(sam_account_name='ivanov_i')
+
+# Установить пароль
+ldap_user.set_password('NewPass123!')
+
+# Синхронизировать с Django (опционально)
+if ldap_user.employee_number:
+ emp = Employee.objects.get(pk=int(ldap_user.employee_number))
+ emp.set_password('NewPass123!')
+ emp.save(update_fields=['password'])
+ print(f"✅ Пароль синхронизирован для {emp.email}")
+```
+
+## Требования к паролю
+
+Active Directory политика по умолчанию:
+- ✅ Минимум **7 символов** (обычно 8+)
+- ✅ Буквы **верхнего и нижнего** регистра
+- ✅ **Цифры**
+- ✅ **Специальные символы** (рекомендуется)
+- ❌ НЕ должен содержать имя пользователя
+- ❌ НЕ должен быть в истории паролей
+
+## Обработка ошибок
+
+### ValueError
+```
+Пароль не соответствует политике сложности Active Directory
+```
+**Решение:** Используйте более сложный пароль (комбинация букв, цифр, спецсимволов)
+
+### ValueError
+```
+Пароль слишком короткий
+```
+**Решение:** Увеличьте длину пароля (минимум 7-8 символов)
+
+### DirectoryLdapError
+```
+Не удалось установить пароль: {...}
+```
+**Причины:**
+- Отсутствует SSL/TLS соединение с AD
+- Недостаточно прав у сервисной учётки
+- Проблемы с подключением к LDAP
+
+## Безопасность
+
+⚠️ **Важно:**
+- Пароли передаются через защищённое LDAP соединение (SSL/TLS)
+- В БД Django хранится только хэш (не plaintext)
+- В LDAP пароль хранится в защищённом виде (невозможно прочитать)
+- Логируются только попытки смены пароля (без самого пароля)
+
+## Интеграция с существующим функционалом
+
+Эта функция **дополняет**, а не заменяет:
+- `UserService.update_user(emp, changes={'password': '...'})` - работает как и раньше
+- Django admin кнопка "Изменить пароль" для Employee - работает, но **НЕ синхронизирует** с LDAP автоматически
+
+Для **автоматической синхронизации** при смене пароля через стандартную админку Employee нужно добавить переопределение метода (см. предыдущее обсуждение).
+
+## Техническая информация
+
+### Как это реализовано
+
+1. **LdapUser.set_password()** вызывает `UserPasswordService.set_password()`
+2. Сервис использует `conn.extend.microsoft.modify_password()` (AD extended operation)
+3. Это более надёжно, чем прямая запись в атрибут `unicodePwd`
+4. Политика паролей AD проверяется автоматически
+5. При ошибках возвращаются понятные сообщения
+
+### Поле unicode_pwd
+
+- **Тип:** `CharField` (но пустое по умолчанию)
+- **Write-only:** можно записать, нельзя прочитать
+- **Кодировка:** UTF-16-LE с кавычками (обрабатывается автоматически)
+- **Использование:** Не рекомендуется напрямую, используйте `.set_password()`
+
+## Тестирование
+
+```python
+# В Django shell
+from employees.ldap.orm_models import LdapUser
+
+# Создать тестового пользователя (если есть)
+user = LdapUser.objects.get(sam_account_name='test_user')
+
+# Попробовать сменить пароль
+try:
+ user.set_password('TestPass123!')
+ print("✅ Пароль успешно изменён")
+except ValueError as e:
+ print(f"❌ Ошибка валидации: {e}")
+except Exception as e:
+ print(f"❌ Ошибка LDAP: {e}")
+```
+
+## История изменений
+
+**23.03.2026:**
+- ✅ Добавлено поле `unicode_pwd` в `LdapUser`
+- ✅ Добавлены методы `set_password()` и `change_password()`
+- ✅ Добавлен action "🔑 Изменить пароль" в список пользователей
+- ✅ **Добавлена кнопка "🔑 Изменить пароль" в карточку пользователя** ⭐
+- ✅ Создан шаблон `admin/ldap_change_password.html` для массовой смены
+- ✅ Создан шаблон `admin/ldap_user_password_change.html` для индивидуальной смены
+- ✅ Автоматическая синхронизация с Django Employee при смене пароля