fix(profile): stabilize directory login and expand ldap coverage
This commit is contained in:
parent
b98a82176c
commit
097a3015e8
@ -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):
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user