diff --git a/backend/api/v1/directory/services.py b/backend/api/v1/directory/services.py index cd5c0046..836c6951 100644 --- a/backend/api/v1/directory/services.py +++ b/backend/api/v1/directory/services.py @@ -20,10 +20,6 @@ def _find_ldap_user(employee): from employees.ldap.orm_models import LdapUser employee_number = str(employee.pk) - ldap_user = LdapUser.objects.filter(employee_number=employee_number).first() - if ldap_user: - return ldap_user - sync_state = LdapSyncState.objects.filter( model="employee", object_pk=employee_number ).first() @@ -31,9 +27,34 @@ def _find_ldap_user(employee): try: return LdapUser.objects.get(dn=sync_state.ldap_dn) except LdapUser.DoesNotExist: - return None + pass - return None + candidates = list( + LdapUser.objects.filter(employee_number=employee_number) + ) + if not candidates: + return None + if len(candidates) == 1: + return candidates[0] + + employee_email = (employee.email or "").strip().lower() + if employee_email: + for candidate in candidates: + if (getattr(candidate, "mail", "") or "").strip().lower() == employee_email: + return candidate + if ( + getattr(candidate, "user_principal_name", "") or "" + ).strip().lower() == employee_email: + return candidate + + candidates.sort( + key=lambda item: ( + 0 if (getattr(item, "mail", "") or "").strip().lower() == employee_email else 1, + 0 if (getattr(item, "user_principal_name", "") or "").strip().lower() == employee_email else 1, + str(getattr(item, "dn", "")).lower(), + ) + ) + return candidates[0] def resolve_directory_login(employee, *, force_refresh: bool = False): diff --git a/backend/api/v1/employees/serializers/employee.py b/backend/api/v1/employees/serializers/employee.py index f7a8c298..cf9f9392 100644 --- a/backend/api/v1/employees/serializers/employee.py +++ b/backend/api/v1/employees/serializers/employee.py @@ -68,6 +68,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "actions", "id", "username", + "is_ldap_managed", "email", "last_name", "first_name", @@ -92,6 +93,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "auth", ) read_only_fields = ( + "is_ldap_managed", "is_active", "email_verified", "created_at", diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index 94baf3fd..3c4f422d 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -216,6 +216,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): """Обновление сотрудника.""" instance = serializer.instance old_email = instance.email + avatar_payload = serializer.validated_data.get("avatar") # Передаем данные для синхронизации с LDAP (через сигналы) # DRF request.data может быть dict (JSON) или QueryDict (form data) @@ -225,6 +226,8 @@ class EmployeeViewSet(viewsets.ModelViewSet): ) if "avatar" in self.request.FILES: instance._ldap_avatar = self.request.FILES["avatar"] + elif avatar_payload is not None: + instance._ldap_avatar = avatar_payload serializer.save() diff --git a/backend/employees/admin.py b/backend/employees/admin.py index 1b6b3890..38df75d2 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -23,6 +23,17 @@ from .models import ( LdapSyncState, ) + +def _resolve_ldap_user_for_employee(employee): + """Возвращает наиболее вероятную LDAP-модель сотрудника. + + Идет через общий resolver: сначала sync-state DN, затем employee_number, + затем email/UPN fallback среди кандидатов. + """ + from api.v1.directory.services import _find_ldap_user + + return _find_ldap_user(employee) + # ========================= # Формы для кастомного User (Employee) # ========================= @@ -628,9 +639,7 @@ class EmployeeAdmin(DjangoUserAdmin): employee.pk }: поиск в LDAP по employee_number={employee.pk}" ) - ldap_user = LdapUser.objects.filter( - employee_number=str(employee.pk) - ).first() + ldap_user = _resolve_ldap_user_for_employee(employee) if ldap_user: logger.info( f"[sync_from_django_to_ldap] Employee { @@ -770,9 +779,11 @@ class EmployeeAdmin(DjangoUserAdmin): # Перечитываем пользователя, чтобы получить # актуальный DN. try: - ldap_user_fresh = LdapUser.objects.get( - employee_number=str(employee.pk) + ldap_user_fresh = _resolve_ldap_user_for_employee( + employee ) + if ldap_user_fresh is None: + raise LdapUser.DoesNotExist fresh_dn = ldap_user_fresh.dn except LdapUser.DoesNotExist: fresh_dn = ldap_user.dn @@ -980,9 +991,7 @@ class EmployeeAdmin(DjangoUserAdmin): try: # Ищем LDAP пользователя - ldap_user = LdapUser.objects.filter( - employee_number=str(employee.pk) - ).first() + ldap_user = _resolve_ldap_user_for_employee(employee) if not ldap_user: # Попробуем по email @@ -1084,9 +1093,7 @@ class EmployeeAdmin(DjangoUserAdmin): try: # Ищем LDAP пользователя - ldap_user = LdapUser.objects.filter( - employee_number=str(employee.pk) - ).first() + ldap_user = _resolve_ldap_user_for_employee(employee) if not ldap_user: ldap_user = LdapUser.objects.filter( diff --git a/backend/tests/api/v1/test_directory_login.py b/backend/tests/api/v1/test_directory_login.py index d279c4e5..f72d973b 100644 --- a/backend/tests/api/v1/test_directory_login.py +++ b/backend/tests/api/v1/test_directory_login.py @@ -27,6 +27,20 @@ def make_user(**overrides): return user +def make_ldap_user(**overrides): + return SimpleNamespace( + dn=overrides.pop( + "dn", "CN=User,OU=Users,OU=company,DC=robotail,DC=local" + ), + sam_account_name=overrides.pop("sam_account_name", "ldap.login"), + mail=overrides.pop("mail", "dir@example.com"), + user_principal_name=overrides.pop( + "user_principal_name", "dir@example.com" + ), + **overrides, + ) + + @pytest.mark.django_db def test_employees_me_returns_cached_username(): client = APIClient() @@ -148,3 +162,73 @@ def test_directory_login_refresh_forces_ldap_lookup(settings): user.refresh_from_db() assert user.username == "new.login" + + +@pytest.mark.django_db +def test_find_ldap_user_prefers_sync_state_dn(settings): + settings.LDAP_ENABLED = True + user = make_user(username="", is_ldap_managed=True) + + from employees.models import LdapSyncState + + preferred = make_ldap_user( + dn="CN=Preferred,OU=Users,OU=company,DC=robotail,DC=local", + sam_account_name="preferred.login", + ) + other = make_ldap_user( + dn="CN=Other,OU=Users,OU=company,DC=robotail,DC=local", + sam_account_name="other.login", + ) + + LdapSyncState.objects.create( + model="employee", + object_pk=str(user.pk), + ldap_dn=preferred.dn, + ) + + with patch("employees.ldap.orm_models.LdapUser.objects.get") as get_mock, patch( + "employees.ldap.orm_models.LdapUser.objects.filter" + ) as filter_mock: + get_mock.return_value = preferred + filter_mock.return_value = [other] + + from api.v1.directory.services import _find_ldap_user + + resolved = _find_ldap_user(user) + + assert resolved is preferred + get_mock.assert_called_once_with(dn=preferred.dn) + + +@pytest.mark.django_db +def test_find_ldap_user_prefers_email_match_among_multiple_candidates(settings): + settings.LDAP_ENABLED = True + user = make_user( + username="", + is_ldap_managed=True, + email="match@example.com", + ) + + preferred = make_ldap_user( + dn="CN=Preferred,OU=Users,OU=company,DC=robotail,DC=local", + sam_account_name="preferred.login", + mail="match@example.com", + user_principal_name="preferred@robotail.local", + ) + other = make_ldap_user( + dn="CN=Other,OU=Users,OU=company,DC=robotail,DC=local", + sam_account_name="other.login", + mail="other@example.com", + user_principal_name="other@robotail.local", + ) + + with patch( + "employees.ldap.orm_models.LdapUser.objects.filter" + ) as filter_mock: + filter_mock.return_value = [other, preferred] + + from api.v1.directory.services import _find_ldap_user + + resolved = _find_ldap_user(user) + + assert resolved is preferred diff --git a/backend/tests/manual/ldap_integration/test_entities_roundtrip.py b/backend/tests/manual/ldap_integration/test_entities_roundtrip.py index 70445da6..f74c5dd5 100644 --- a/backend/tests/manual/ldap_integration/test_entities_roundtrip.py +++ b/backend/tests/manual/ldap_integration/test_entities_roundtrip.py @@ -30,6 +30,11 @@ pytestmark = [ pytest.mark.django_db(transaction=True, databases=["default", "ldap"]), ] +TINY_PNG_BASE64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" + "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" +) + def _employee_dn(employee) -> str: return LdapSyncState.objects.get( @@ -65,7 +70,10 @@ def test_employee_profile_patch_updates_live_ldap( "/api/v1/employees/me/", { "first_name": "Updated", + "last_name": "Renamed", + "email": f"updated-{user.pk}@example.com", "phone_number": "+79995554433", + "avatar": f"data:image/png;base64,{TINY_PNG_BASE64}", }, format="json", ) @@ -74,7 +82,10 @@ def test_employee_profile_patch_updates_live_ldap( ldap_user = LdapUser.objects.get(dn=_employee_dn(user)) assert ldap_user.given_name == "Updated" + assert ldap_user.sn == "Renamed" + assert ldap_user.mail == f"updated-{user.pk}@example.com" assert ldap_user.telephone_number == "+79995554433" + assert bool(ldap_user.thumbnail_photo) is True def test_department_api_roundtrip_syncs_ou_head_and_membership( @@ -133,12 +144,25 @@ def test_department_api_roundtrip_syncs_ou_head_and_membership( ) assert rename_response.status_code == 200, rename_response.content + old_dept_dn = f"OU={dept_name},{settings.LDAP_DEPARTMENTS_BASE}" department.refresh_from_db() renamed_dn = _department_dn(department) ldap_cleanup(renamed_dn) renamed_ou = LdapOrganizationalUnit.objects.get(dn=renamed_dn) assert renamed_ou.ou == f"{dept_name}-renamed" assert renamed_ou.description == "Renamed department" + assert department.ldap_group_dn == ( + f"CN=DEP_{dept_name}-renamed,{renamed_dn}" + ) + assert not LdapOrganizationalUnitGroup.objects.filter( + dn=f"CN=DEP_{dept_name},{old_dept_dn}" + ).exists() + renamed_group = LdapOrganizationalUnitGroup.objects.get( + dn=department.ldap_group_dn + ) + assert renamed_group.cn == f"DEP_{dept_name}-renamed" + renamed_member_dns = {_norm_dn(dn) for dn in list(renamed_group.member or [])} + assert _norm_dn(_employee_dn(member)) in renamed_member_dns remove_member = superuser_client.post( f"/api/v1/departments/{department.pk}/remove_member/", @@ -147,7 +171,9 @@ def test_department_api_roundtrip_syncs_ou_head_and_membership( ) assert remove_member.status_code == 200, remove_member.content - renamed_group = LdapOrganizationalUnitGroup.objects.get(dn=department.ldap_group_dn) + renamed_group = LdapOrganizationalUnitGroup.objects.get( + dn=department.ldap_group_dn + ) renamed_member_dns = {_norm_dn(dn) for dn in list(renamed_group.member or [])} assert _norm_dn(_employee_dn(member)) not in renamed_member_dns diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index f0f7040d..aaab253c 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -13,6 +13,7 @@ import { Mail, Monitor, Moon, + RefreshCw, Save, Smartphone, Sun, @@ -27,7 +28,7 @@ import { useWebPush } from "@/hooks/useWebPush"; import { apiClient } from "@/lib/api"; import type { ThemePreference } from "@/lib/theme"; import { resolveMediaUrl } from "@/lib/url"; -import type { AuthSession } from "@/types/api"; +import type { AuthSession, DirectoryLoginResult } from "@/types/api"; import { getVerbName } from "@/lib/verbTranslations"; type NotificationPreferences = { @@ -238,6 +239,10 @@ export default function SettingsPage() { next: false, confirm: false, }); + const [directoryLogin, setDirectoryLogin] = useState(null); + const [directoryLoginLoading, setDirectoryLoginLoading] = useState(false); + const [directoryLoginRefreshing, setDirectoryLoginRefreshing] = useState(false); + const [directoryLoginError, setDirectoryLoginError] = useState(null); const { isSupported, @@ -263,8 +268,44 @@ export default function SettingsPage() { whatsapp: user.whatsapp || "", wechat: user.wechat || "", }); + setDirectoryLogin(user.username?.trim() || null); + setDirectoryLoginError(null); }, [user]); + useEffect(() => { + let mounted = true; + + async function loadDirectoryLogin() { + if (!user?.is_ldap_managed || user.username?.trim()) { + return; + } + + try { + setDirectoryLoginLoading(true); + setDirectoryLoginError(null); + const response = (await apiClient.getDirectoryLogin()) as DirectoryLoginResult; + if (!mounted) return; + setDirectoryLogin(response.username?.trim() || null); + } catch (error) { + console.error(error); + if (!mounted) return; + setDirectoryLoginError( + error instanceof Error ? error.message : "Не удалось получить логин из каталога", + ); + } finally { + if (mounted) { + setDirectoryLoginLoading(false); + } + } + } + + void loadDirectoryLogin(); + + return () => { + mounted = false; + }; + }, [user?.id, user?.is_ldap_managed, user?.username]); + useEffect(() => { const section = new URLSearchParams(window.location.search).get("section"); if (!section) return; @@ -614,6 +655,32 @@ export default function SettingsPage() { } }; + const handleRefreshDirectoryLogin = async () => { + try { + setDirectoryLoginRefreshing(true); + setDirectoryLoginError(null); + const response = + (await apiClient.refreshDirectoryLogin()) as DirectoryLoginResult; + setDirectoryLogin(response.username?.trim() || null); + await refreshUser(); + toast.success( + response.username + ? "Логин в каталоге обновлен" + : "Логин в каталоге не найден", + ); + } catch (error) { + console.error(error); + setDirectoryLoginError( + error instanceof Error + ? error.message + : "Не удалось обновить логин из каталога", + ); + toast.error("Не удалось обновить логин из каталога"); + } finally { + setDirectoryLoginRefreshing(false); + } + }; + if (loading || !user) { return ( @@ -708,6 +775,47 @@ export default function SettingsPage() {

{fullName}

{user.email || "Почта не указана"}

+
+
+
+

+ Логин в каталоге +

+

+ {!user.is_ldap_managed + ? "Не используется" + : directoryLoginLoading + ? "Загружаем..." + : directoryLogin || "Не найден"} +

+ {directoryLoginError ? ( +

+ {directoryLoginError} +

+ ) : user.is_ldap_managed ? ( +

+ Берется из кэша БД и при необходимости обновляется из LDAP. +

+ ) : ( +

+ Для локального аккаунта логин каталога не используется. +

+ )} +
+ +
+