feat(ldap): добавлено кэширование LDAP логина с возможностью обновления
- Добавлено поле Employee.username для кэширования LDAP логина - API endpoint обновлен: поддержка кэша и force_refresh параметра - Новый дизайн: убран blur, добавлена иконка обновления - Автоматическая загрузка при инициализации (из кэша или LDAP) - Визуальная индикация источника данных (cached/fresh) - Доступно всем пользователям без ограничений
This commit is contained in:
parent
10f7287ae5
commit
3e4137daa4
@ -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,
|
||||
)
|
||||
|
||||
18
backend/employees/migrations/0038_employee_username.py
Normal file
18
backend/employees/migrations/0038_employee_username.py
Normal 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 (кэш)'),
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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
@ -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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user