feat(ldap): добавлено кэширование LDAP логина с возможностью обновления

- Добавлено поле Employee.username для кэширования LDAP логина
- API endpoint обновлен: поддержка кэша и force_refresh параметра
- Новый дизайн: убран blur, добавлена иконка обновления
- Автоматическая загрузка при инициализации (из кэша или LDAP)
- Визуальная индикация источника данных (cached/fresh)
- Доступно всем пользователям без ограничений
This commit is contained in:
admin i 2026-02-06 15:33:48 +03:00
parent 10f7287ae5
commit 3e4137daa4
9 changed files with 224 additions and 175 deletions

View File

@ -2261,28 +2261,41 @@ class EmployeeViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["get"], url_path="ldap-info")
def ldap_info(self, request, pk=None):
"""
GET /api/v1/employees/{id}/ldap-info/
GET /api/v1/employees/{id}/ldap-info/?force_refresh=true
Получает LDAP информацию о сотруднике из Active Directory.
Получает LDAP информацию о сотруднике (с кэшированием).
Доступ (через DRF permissions: IsSelfOrStaff | AdminOrActionOrModelPerms):
- Сам пользователь может видеть свой LDAP логин
- Staff/superuser могут видеть любые LDAP логины
- Пользователи с правом employees.view_ldap_info могут видеть любые
Query параметры:
- force_refresh: если true, принудительно обновляет кэш из LDAP
Доступ:
- Любой авторизованный пользователь может видеть LDAP логин
- Кэшируется в поле Employee.username для быстрого доступа
- При force_refresh=true - сбрасывает кэш и запрашивает из LDAP
Возвращает:
- sAMAccountName: LDAP логин пользователя
- userPrincipalName: полный UPN
- displayName: отображаемое имя из LDAP
- sAMAccountName: LDAP логин пользователя (из кэша или LDAP)
- cached: true если данные из кэша, false если запрошены из LDAP
Примечания:
- Данные запрашиваются напрямую из LDAP, не из базы Django
- Требуется активная связь с Active Directory
- Проверка прав выполняется автоматически через get_permissions()
- Кэшируется в Employee.username для быстрого доступа
- При отсутствии кэша - запрашивается из LDAP и сохраняется
- force_refresh=true сбрасывает кэш и обновляет из LDAP
"""
from employees.ldap.repositories.ldap_repository import LdapRepository
emp = self.get_object() # DRF автоматически проверит права через get_permissions()
emp = self.get_object()
force_refresh = request.query_params.get('force_refresh', '').lower() == 'true'
# Проверяем кэш (поле username), если не требуется принудительное обновление
if not force_refresh and emp.username:
return Response(
{
"sAMAccountName": emp.username,
"cached": True,
},
status=status.HTTP_200_OK,
)
# Проверяем наличие LDAP связи через LdapSyncState
try:
@ -2307,7 +2320,7 @@ class EmployeeViewSet(viewsets.ModelViewSet):
ldap_repo = LdapRepository(conn)
attrs = ldap_repo.read_attrs(
ldap_sync.ldap_dn,
["sAMAccountName", "userPrincipalName", "displayName"],
["sAMAccountName"],
)
if not attrs or not attrs.get("sAMAccountName"):
@ -2316,11 +2329,15 @@ class EmployeeViewSet(viewsets.ModelViewSet):
status=status.HTTP_404_NOT_FOUND,
)
# Кэшируем результат в username
sam_account_name = attrs.get("sAMAccountName")
emp.username = sam_account_name
emp.save(update_fields=["username"])
return Response(
{
"sAMAccountName": attrs.get("sAMAccountName"),
"userPrincipalName": attrs.get("userPrincipalName"),
"displayName": attrs.get("displayName"),
"sAMAccountName": sam_account_name,
"cached": False,
},
status=status.HTTP_200_OK,
)

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2026-02-06 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0037_alter_employee_options'),
]
operations = [
migrations.AddField(
model_name='employee',
name='username',
field=models.CharField(blank=True, help_text='Кэшированный LDAP логин сотрудника', max_length=150, null=True, verbose_name='LDAP Username (кэш)'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2026-02-06 12:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('employees', '0038_employee_username'),
]
operations = [
migrations.AddIndex(
model_name='employee',
index=models.Index(fields=['username'], name='employees_e_usernam_4d3ddc_idx'),
),
]

View File

@ -157,7 +157,13 @@ class Position(models.Model):
class Employee(AbstractUser):
username = None
username = models.CharField(
"LDAP Username (кэш)",
max_length=150,
blank=True,
null=True,
help_text="Кэшированный LDAP логин сотрудника",
)
gender = models.PositiveSmallIntegerField(
"Пол", choices=GENDER_CHOICES, default=0, blank=True
@ -208,6 +214,7 @@ class Employee(AbstractUser):
models.Index(fields=["last_name", "first_name"]),
models.Index(fields=["email"]),
models.Index(fields=["phone_number"]),
models.Index(fields=["username"]),
models.Index(Lower("email"), name="employee_email_lower_idx"),
]
permissions = [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,72 +1,62 @@
/**
* LDAP Login Field Component
* =============================================================================
* Компонент для безопасного отображения LDAP логина пользователя
* - Blur-эффект по умолчанию
* - Раскрытие по клику (только для админов/HR)
* - AJAX запрос к API для получения реального sAMAccountName
* Компонент для отображения LDAP логина с кэшированием
* - Автоматическая загрузка при инициализации (из кэша или LDAP)
* - Кнопка обновления для принудительного запроса из LDAP
* - Визуальная индикация источника данных (cached/fresh)
*/
class LdapLoginField {
constructor(fieldElement) {
this.field = fieldElement;
this.button = this.field.querySelector(".ldap-login-toggle");
this.textSpan = this.field.querySelector(".ldap-login-text");
this.icon = this.field.querySelector(".ldap-icon");
this.container = this.field.querySelector(".ldap-login-container");
this.valueSpan = this.field.querySelector(".ldap-login-value");
this.refreshBtn = this.field.querySelector(".ldap-refresh-btn");
this.employeeId = this.field.dataset.employeeId;
this.isRevealed = false;
this.ldapLogin = null;
this.isCached = false;
this.init();
}
init() {
if (!this.button || !this.textSpan || !this.icon) {
if (!this.container || !this.valueSpan || !this.refreshBtn) {
console.error("LDAP Login Field: Required elements not found");
return;
}
this.button.addEventListener("click", () => this.toggleReveal());
// Загрузить данные при инициализации
this.loadLdapInfo();
// Обработчик кнопки обновления
this.refreshBtn.addEventListener("click", () => this.refreshLdapInfo());
}
async toggleReveal() {
if (this.isRevealed) {
this.hide();
} else {
await this.reveal();
}
}
async reveal() {
async loadLdapInfo(forceRefresh = false) {
if (!this.employeeId) {
this.showError("ID сотрудника не указан");
return;
}
// Уже загружено
if (this.ldapLogin) {
this.showLogin(this.ldapLogin);
return;
}
// Показать состояние загрузки
this.field.classList.add("loading");
this.icon.classList.remove("bi-lock-fill");
this.icon.classList.add("bi-hourglass");
this.valueSpan.textContent = "Загрузка...";
try {
const response = await fetch(
`/api/v1/employees/${this.employeeId}/ldap-info/`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
credentials: "same-origin",
const url = forceRefresh
? `/api/v1/employees/${this.employeeId}/ldap-info/?force_refresh=true`
: `/api/v1/employees/${this.employeeId}/ldap-info/`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
);
credentials: "same-origin",
});
if (!response.ok) {
if (response.status === 403) {
@ -85,7 +75,8 @@ class LdapLoginField {
}
this.ldapLogin = data.sAMAccountName;
this.showLogin(this.ldapLogin);
this.isCached = data.cached || false;
this.showLogin(this.ldapLogin, this.isCached);
} catch (error) {
console.error("LDAP Login Field Error:", error);
this.showError(error.message || "Не удалось загрузить LDAP логин");
@ -94,63 +85,49 @@ class LdapLoginField {
}
}
showLogin(login) {
this.textSpan.textContent = login;
this.textSpan.classList.remove("blurred");
this.textSpan.classList.add("revealed");
async refreshLdapInfo() {
await this.loadLdapInfo(true);
this.icon.classList.remove("bi-lock-fill", "bi-hourglass");
this.icon.classList.add("bi-unlock-fill");
this.field.classList.add("revealed");
this.isRevealed = true;
// Обновить tooltip
this.button.setAttribute("title", "Нажмите, чтобы скрыть LDAP логин");
if (bootstrap && bootstrap.Tooltip) {
const tooltip = bootstrap.Tooltip.getInstance(this.button);
if (tooltip) {
tooltip.dispose();
}
// Показать уведомление об обновлении
if (this.ldapLogin && !this.field.classList.contains("error")) {
this.showToast("LDAP логин обновлен из Active Directory", "success");
}
}
hide() {
this.textSpan.textContent = "••••••••";
this.textSpan.classList.remove("revealed");
this.textSpan.classList.add("blurred");
showLogin(login, cached = false) {
this.valueSpan.textContent = login;
this.field.classList.remove("error");
this.icon.classList.remove("bi-unlock-fill");
this.icon.classList.add("bi-lock-fill");
this.field.classList.remove("revealed");
this.isRevealed = false;
// Обновить tooltip
this.button.setAttribute("title", "Нажмите, чтобы показать LDAP логин");
// Визуальная индикация источника
if (cached) {
this.container.classList.add("cached");
this.updateRefreshTooltip("Данные из кэша. Нажмите для обновления из AD");
} else {
this.container.classList.remove("cached");
this.updateRefreshTooltip("Данные из AD. Нажмите для повторного обновления");
}
}
showError(message) {
this.valueSpan.textContent = "Ошибка";
this.field.classList.add("error");
this.icon.classList.remove("bi-lock-fill", "bi-hourglass");
this.icon.classList.add("bi-exclamation-triangle-fill");
this.container.classList.remove("cached");
// Показать сообщение об ошибке через tooltip или alert
if (typeof bootstrap !== "undefined" && bootstrap.Toast) {
this.showToast("Ошибка", message, "danger");
} else {
alert(message);
}
// Убрать состояние ошибки через 3 секунды
setTimeout(() => {
this.field.classList.remove("error");
this.icon.classList.remove("bi-exclamation-triangle-fill");
this.icon.classList.add("bi-lock-fill");
}, 3000);
this.showToast(message, "error");
}
showToast(title, message, type = "info") {
updateRefreshTooltip(text) {
this.refreshBtn.setAttribute("title", text);
if (typeof bootstrap !== "undefined" && bootstrap.Tooltip) {
const tooltip = bootstrap.Tooltip.getInstance(this.refreshBtn);
if (tooltip) {
tooltip.dispose();
}
new bootstrap.Tooltip(this.refreshBtn);
}
}
showToast(message, type = "info") {
const toastContainer =
document.querySelector(".toast-container") || this.createToastContainer();
@ -161,23 +138,25 @@ class LdapLoginField {
toastEl.setAttribute("aria-atomic", "true");
toastEl.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<strong>${title}:</strong> ${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
toastContainer.appendChild(toastEl);
const toast = new bootstrap.Toast(toastEl, { autohide: true, delay: 5000 });
toast.show();
if (typeof bootstrap !== "undefined" && bootstrap.Toast) {
const toast = new bootstrap.Toast(toastEl, { autohide: true, delay: 5000 });
toast.show();
toastEl.addEventListener("hidden.bs.toast", () => {
toastEl.remove();
});
toastEl.addEventListener("hidden.bs.toast", () => {
toastEl.remove();
});
}
}
createToastContainer() {

View File

@ -1,17 +1,16 @@
// =============================================================================
// LDAP Login Field Component
// =============================================================================
// Скрытое поле с LDAP логином для админов/HR
// Использует blur-эффект и раскрывается по клику
// Поле с LDAP логином с кэшированием и возможностью обновления
.ldap-login-field {
margin: var(--space-2) 0;
text-align: center;
.ldap-login-toggle {
.ldap-login-container {
display: inline-flex;
align-items: center;
gap: var(--space-1);
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border);
@ -19,74 +18,78 @@
color: var(--color-text-secondary);
font-size: 0.875rem;
font-family: var(--font-mono, 'Courier New', monospace);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: var(--color-surface-tertiary);
border-color: var(--color-primary);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
}
.ldap-icon {
font-size: 1rem;
transition: transform 0.3s ease;
}
.ldap-login-text {
letter-spacing: 2px;
transition: filter 0.5s ease, letter-spacing 0.5s ease;
display: flex;
align-items: center;
gap: var(--space-1);
color: var(--color-primary);
font-weight: 500;
&.blurred {
filter: blur(6px);
user-select: none;
.ldap-icon {
font-size: 1rem;
color: var(--color-text-secondary);
}
}
.ldap-refresh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
transform: rotate(90deg);
}
&.revealed {
filter: blur(0);
letter-spacing: normal;
color: var(--color-primary);
font-weight: 500;
&:active {
transform: rotate(180deg);
}
.refresh-icon {
font-size: 1rem;
}
}
&.cached {
border-color: var(--color-success);
background: rgba(var(--bs-success-rgb), 0.05);
}
}
// Состояние загрузки
&.loading {
.ldap-login-toggle {
.ldap-login-container {
pointer-events: none;
opacity: 0.7;
.ldap-icon {
animation: spin 1s linear infinite;
.ldap-refresh-btn {
.refresh-icon {
animation: spin 1s linear infinite;
}
}
}
}
// Состояние ошибки
&.error {
.ldap-login-toggle {
.ldap-login-container {
border-color: var(--color-danger);
background: rgba(var(--bs-danger-rgb), 0.1);
}
}
// Раскрытое состояние
&.revealed {
.ldap-login-toggle {
border-color: var(--color-success);
background: rgba(var(--bs-success-rgb), 0.05);
.ldap-icon {
transform: scale(1.1);
}
}
}
}
// Анимация вращения для загрузки
@ -102,13 +105,17 @@
// Темная тема
@media (prefers-color-scheme: dark) {
.ldap-login-field {
.ldap-login-toggle {
.ldap-login-container {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
&:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--color-primary);
.ldap-refresh-btn {
border-color: rgba(255, 255, 255, 0.2);
&:hover {
background: var(--color-primary);
border-color: var(--color-primary);
}
}
}
}

View File

@ -66,16 +66,19 @@
{% endif %}
</div>
{# LDAP Login (для себя или админов/HR) #}
{% if is_me or request.user.is_staff or perms.employees.view_ldap_info %}
<div class="ldap-login-field" id="ldapLoginField" data-employee-id="{{ emp.id }}">
<button type="button" class="ldap-login-toggle" data-bs-toggle="tooltip"
data-bs-placement="top" title="Нажмите, чтобы показать LDAP логин">
<i class="bi-lock-fill ldap-icon"></i>
<span class="ldap-login-text blurred">••••••••</span>
{# LDAP Login (кэшируемый, всегда доступен) #}
<div class="ldap-login-field" id="ldapLoginField" data-employee-id="{{ emp.id }}">
<div class="ldap-login-container">
<div class="ldap-login-text">
<i class="bi-person-badge ldap-icon"></i>
<span class="ldap-login-value">Загрузка...</span>
</div>
<button type="button" class="ldap-refresh-btn" data-bs-toggle="tooltip"
data-bs-placement="top" title="Обновить LDAP логин из Active Directory">
<i class="bi-arrow-repeat refresh-icon"></i>
</button>
</div>
{% endif %}
</div>
<div class="ios-role">
{% if emp.position.name %}