fix(profile): stabilize directory login and expand ldap coverage

This commit is contained in:
lizerk 2026-04-12 04:13:32 +03:00
parent b98a82176c
commit 097a3015e8
9 changed files with 282 additions and 19 deletions

View File

@ -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):

View File

@ -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",

View File

@ -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()

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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<string | null>(null);
const [directoryLoginLoading, setDirectoryLoginLoading] = useState(false);
const [directoryLoginRefreshing, setDirectoryLoginRefreshing] = useState(false);
const [directoryLoginError, setDirectoryLoginError] = useState<string | null>(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 (
<AppShell>
@ -708,6 +775,47 @@ export default function SettingsPage() {
<p className="text-sm font-semibold text-[var(--foreground)]">{fullName}</p>
<p className="app-text-muted mt-1 text-sm">{user.email || "Почта не указана"}</p>
</div>
<div className="app-surface-muted rounded-2xl p-4 md:col-span-2">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="app-text-muted text-xs uppercase tracking-[0.16em]">
Логин в каталоге
</p>
<p className="mt-2 text-sm font-semibold text-[var(--foreground)]">
{!user.is_ldap_managed
? "Не используется"
: directoryLoginLoading
? "Загружаем..."
: directoryLogin || "Не найден"}
</p>
{directoryLoginError ? (
<p className="mt-1 text-sm text-[var(--danger-foreground)]">
{directoryLoginError}
</p>
) : user.is_ldap_managed ? (
<p className="app-text-muted mt-1 text-sm">
Берется из кэша БД и при необходимости обновляется из LDAP.
</p>
) : (
<p className="app-text-muted mt-1 text-sm">
Для локального аккаунта логин каталога не используется.
</p>
)}
</div>
<button
type="button"
onClick={() => void handleRefreshDirectoryLogin()}
disabled={!user.is_ldap_managed || directoryLoginRefreshing}
className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
>
<RefreshCw
size={16}
className={directoryLoginRefreshing ? "animate-spin" : ""}
/>
Обновить
</button>
</div>
</div>
<label className="block">
<span className="app-text-muted mb-2 block text-sm">Имя</span>
<input

View File

@ -3,6 +3,10 @@ import { type RequestFn } from './utils';
export function createEmployeesApi(request: RequestFn) {
return {
getDirectoryLogin: () =>
request('/api/v1/directory/me/login/'),
refreshDirectoryLogin: () =>
request('/api/v1/directory/me/login/refresh/', { method: 'POST' }),
getEmployees: (params?: { search?: string; department?: string; page?: number; limit?: number; is_active?: boolean }) => {
const qp = new URLSearchParams();
if (params?.search) qp.append('search', params.search);

View File

@ -29,6 +29,7 @@ export interface EmployeeDepartment {
export interface User {
id: number;
username?: string;
email: string;
phone_number?: string;
first_name: string;
@ -72,6 +73,13 @@ export interface AuthSession {
revoked_at?: string | null;
}
export interface DirectoryLoginResult {
username: string | null;
source: "db" | "ldap" | "none" | "ldap_not_found";
is_cached: boolean;
is_ldap_managed: boolean;
}
export interface SessionBulkActionResult {
revoked: number;
}