diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 3d8f2015..953d3109 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -1,6 +1,5 @@ # flake8: noqa from __future__ import annotations -# backend/api/v1/employees/views.py import logging import traceback @@ -10,6 +9,9 @@ from typing import Any, Dict, List, Optional from common.emails import send_templated_mail from django.conf import settings +# backend/api/v1/employees/views.py + + logger = logging.getLogger(__name__) from django.contrib.auth import get_user_model from django.contrib.auth.models import Group, Permission @@ -69,11 +71,11 @@ from employees.utils import ( ) from rest_framework import filters, status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework.exceptions import ValidationError from ..permissions import ( AdminOrActionOrModelPerms, @@ -1554,6 +1556,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): required_perms_by_action = { "add_skill": "employees.manage_employee_skills", "remove_skill": "employees.manage_employee_skills", + "ldap_info": "employees.view_ldap_info", # Для просмотра чужих LDAP логинов } def get_permissions(self): @@ -1568,6 +1571,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): "destroy", "add_skill", "remove_skill", + "ldap_info", # Свой LDAP логин может видеть сам пользователь }: return [IsAuthenticated(), (IsSelfOrStaff | AdminOrActionOrModelPerms)()] @@ -2173,8 +2177,8 @@ class EmployeeViewSet(viewsets.ModelViewSet): # Сброс email_verified при изменении email if email_changed: logger.info("[ME PATCH] Step 13: Processing email change...") - from django.utils.crypto import get_random_string from common.emails import send_templated_mail + from django.utils.crypto import get_random_string instance.email_verified = False instance.email_activation_code = get_random_string(6, "0123456789") @@ -2260,68 +2264,70 @@ class EmployeeViewSet(viewsets.ModelViewSet): GET /api/v1/employees/{id}/ldap-info/ Получает LDAP информацию о сотруднике из Active Directory. - - Доступ: только staff или пользователи с правом employees.view_ldap_info - + + Доступ (через DRF permissions: IsSelfOrStaff | AdminOrActionOrModelPerms): + - Сам пользователь может видеть свой LDAP логин + - Staff/superuser могут видеть любые LDAP логины + - Пользователи с правом employees.view_ldap_info могут видеть любые + Возвращает: - sAMAccountName: LDAP логин пользователя - userPrincipalName: полный UPN - displayName: отображаемое имя из LDAP - + Примечания: - Данные запрашиваются напрямую из LDAP, не из базы Django - Требуется активная связь с Active Directory + - Проверка прав выполняется автоматически через get_permissions() """ from employees.ldap.repositories.ldap_repository import LdapRepository - - # Проверка прав доступа - if not (request.user.is_staff or request.user.has_perm('employees.view_ldap_info')): - return Response( - {"detail": "У вас нет прав для просмотра LDAP информации"}, - status=status.HTTP_403_FORBIDDEN - ) - - emp = self.get_object() - + + emp = self.get_object() # DRF автоматически проверит права через get_permissions() + # Проверяем наличие LDAP связи try: ldap_sync = emp.ldap_sync_state if not ldap_sync or not ldap_sync.ldap_dn: return Response( {"detail": "У этого сотрудника нет связи с LDAP"}, - status=status.HTTP_404_NOT_FOUND + status=status.HTTP_404_NOT_FOUND, ) except LdapSyncState.DoesNotExist: return Response( {"detail": "У этого сотрудника нет связи с LDAP"}, - status=status.HTTP_404_NOT_FOUND + status=status.HTTP_404_NOT_FOUND, ) - + # Запрашиваем данные из LDAP try: ldap_repo = LdapRepository() attrs = ldap_repo.read_attrs( ldap_sync.ldap_dn, - ['sAMAccountName', 'userPrincipalName', 'displayName'] + ["sAMAccountName", "userPrincipalName", "displayName"], ) - - if not attrs or not attrs.get('sAMAccountName'): + + if not attrs or not attrs.get("sAMAccountName"): return Response( {"detail": "Не удалось получить LDAP информацию"}, - status=status.HTTP_404_NOT_FOUND + status=status.HTTP_404_NOT_FOUND, ) - - return Response({ - "sAMAccountName": attrs.get('sAMAccountName'), - "userPrincipalName": attrs.get('userPrincipalName'), - "displayName": attrs.get('displayName'), - }, status=status.HTTP_200_OK) - + + return Response( + { + "sAMAccountName": attrs.get("sAMAccountName"), + "userPrincipalName": attrs.get("userPrincipalName"), + "displayName": attrs.get("displayName"), + }, + status=status.HTTP_200_OK, + ) + except Exception as e: - logger.error(f"Error fetching LDAP info for employee {emp.id}: {e}", exc_info=True) + logger.error( + f"Error fetching LDAP info for employee {emp.id}: {e}", exc_info=True + ) return Response( {"detail": f"Ошибка при запросе LDAP информации: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @action(detail=False, methods=["get"], url_path="export-excel") @@ -2337,11 +2343,12 @@ class EmployeeViewSet(viewsets.ModelViewSet): Возвращает: - Excel файл (.xlsx) с данными сотрудников """ - from openpyxl import Workbook - from openpyxl.styles import Font, Alignment, PatternFill - from django.http import HttpResponse from datetime import datetime + from django.http import HttpResponse + from openpyxl import Workbook + from openpyxl.styles import Alignment, Font, PatternFill + # Получаем отфильтрованный queryset queryset = self.filter_queryset(self.get_queryset()) queryset = ( @@ -2413,7 +2420,7 @@ class EmployeeViewSet(viewsets.ModelViewSet): if not phone_field: return "" try: - from phonenumbers import format_number, PhoneNumberFormat + from phonenumbers import PhoneNumberFormat, format_number return format_number(phone_field, PhoneNumberFormat.INTERNATIONAL) except Exception: @@ -2705,8 +2712,8 @@ class EmployeeViewSet(viewsets.ModelViewSet): # Сброс email_verified при изменении email if email_changed: - from django.utils.crypto import get_random_string from common.emails import send_templated_mail + from django.utils.crypto import get_random_string emp.email_verified = False emp.email_activation_code = get_random_string(6, "0123456789") @@ -3557,15 +3564,15 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): # Если сотрудник не состоял ни в одном отделе, вручную перемещаем в OU=Dismissed if not departments_processed: try: - from employees.ldap.utils.ldap_utils import ( - get_base_dn_for_employee, - ) from employees.ldap.infrastructure.connections import ( _ldap, ) from employees.ldap.repositories.ldap_repository import ( ensure_container_exists, ) + from employees.ldap.utils.ldap_utils import ( + get_base_dn_for_employee, + ) from employees.models import LdapSyncState sync_state = LdapSyncState.objects.filter( diff --git a/backend/employees/models.py b/backend/employees/models.py index 428b999a..82d744f1 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -179,7 +179,7 @@ class Employee(AbstractUser): "Telegram Chat ID", max_length=100, blank=True, - help_text="Ваш Telegram Chat ID для получения уведомлений (найдите через @userinfobot)" + help_text="Ваш Telegram Chat ID для получения уведомлений (найдите через @userinfobot)", ) whatsapp = PhoneNumberField("WhatsApp", blank=True) wechat = models.CharField("WeChat", max_length=100, blank=True) @@ -516,10 +516,10 @@ class EmployeeDepartment(DateRangeMixin, models.Model): class RoleAssignment(models.Model): """Назначение роли сотруднику (не обязательно члену отдела). - + Позволяет назначать роли любым сотрудникам компании, независимо от их членства в отделе. - + Attributes: employee: Сотрудник, которому назначена роль. role: Назначенная роль отдела. @@ -527,7 +527,7 @@ class RoleAssignment(models.Model): assigned_at: Дата/время назначения. is_active: Активно ли назначение. """ - + employee = models.ForeignKey( "Employee", on_delete=models.CASCADE, diff --git a/backend/static/js/components/ldapLoginField.js b/backend/static/js/components/ldapLoginField.js index 37c4908d..3c82900b 100644 --- a/backend/static/js/components/ldapLoginField.js +++ b/backend/static/js/components/ldapLoginField.js @@ -8,200 +8,202 @@ */ 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.employeeId = this.field.dataset.employeeId; - - this.isRevealed = false; - this.ldapLogin = null; - - this.init(); + 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.employeeId = this.field.dataset.employeeId; + + this.isRevealed = false; + this.ldapLogin = null; + + this.init(); + } + + init() { + if (!this.button || !this.textSpan || !this.icon) { + console.error("LDAP Login Field: Required elements not found"); + return; } - - init() { - if (!this.button || !this.textSpan || !this.icon) { - console.error('LDAP Login Field: Required elements not found'); - return; - } - - this.button.addEventListener('click', () => this.toggleReveal()); + + this.button.addEventListener("click", () => this.toggleReveal()); + } + + async toggleReveal() { + if (this.isRevealed) { + this.hide(); + } else { + await this.reveal(); } - - async toggleReveal() { - if (this.isRevealed) { - this.hide(); + } + + async reveal() { + 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"); + + 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", + }, + ); + + if (!response.ok) { + if (response.status === 403) { + throw new Error("У вас нет прав для просмотра LDAP информации"); + } else if (response.status === 404) { + throw new Error("LDAP информация не найдена"); } else { - await this.reveal(); + throw new Error("Ошибка загрузки LDAP информации"); } + } + + const data = await response.json(); + + if (!data.sAMAccountName) { + throw new Error("LDAP логин не найден"); + } + + this.ldapLogin = data.sAMAccountName; + this.showLogin(this.ldapLogin); + } catch (error) { + console.error("LDAP Login Field Error:", error); + this.showError(error.message || "Не удалось загрузить LDAP логин"); + } finally { + this.field.classList.remove("loading"); } - - async reveal() { - 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'); - - 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' - }); - - if (!response.ok) { - if (response.status === 403) { - throw new Error('У вас нет прав для просмотра LDAP информации'); - } else if (response.status === 404) { - throw new Error('LDAP информация не найдена'); - } else { - throw new Error('Ошибка загрузки LDAP информации'); - } - } - - const data = await response.json(); - - if (!data.sAMAccountName) { - throw new Error('LDAP логин не найден'); - } - - this.ldapLogin = data.sAMAccountName; - this.showLogin(this.ldapLogin); - - } catch (error) { - console.error('LDAP Login Field Error:', error); - this.showError(error.message || 'Не удалось загрузить LDAP логин'); - } finally { - this.field.classList.remove('loading'); - } + } + + showLogin(login) { + this.textSpan.textContent = login; + this.textSpan.classList.remove("blurred"); + this.textSpan.classList.add("revealed"); + + 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(); + } } - - showLogin(login) { - this.textSpan.textContent = login; - this.textSpan.classList.remove('blurred'); - this.textSpan.classList.add('revealed'); - - 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(); - } - } + } + + hide() { + this.textSpan.textContent = "••••••••"; + this.textSpan.classList.remove("revealed"); + this.textSpan.classList.add("blurred"); + + 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 логин"); + } + + showError(message) { + this.field.classList.add("error"); + this.icon.classList.remove("bi-lock-fill", "bi-hourglass"); + this.icon.classList.add("bi-exclamation-triangle-fill"); + + // Показать сообщение об ошибке через tooltip или alert + if (typeof bootstrap !== "undefined" && bootstrap.Toast) { + this.showToast("Ошибка", message, "danger"); + } else { + alert(message); } - - hide() { - this.textSpan.textContent = '••••••••'; - this.textSpan.classList.remove('revealed'); - this.textSpan.classList.add('blurred'); - - 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 логин'); - } - - showError(message) { - this.field.classList.add('error'); - this.icon.classList.remove('bi-lock-fill', 'bi-hourglass'); - this.icon.classList.add('bi-exclamation-triangle-fill'); - - // Показать сообщение об ошибке через 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); - } - - showToast(title, message, type = 'info') { - const toastContainer = document.querySelector('.toast-container') || - this.createToastContainer(); - - const toastEl = document.createElement('div'); - toastEl.className = `toast align-items-center text-bg-${type} border-0`; - toastEl.setAttribute('role', 'alert'); - toastEl.setAttribute('aria-live', 'assertive'); - toastEl.setAttribute('aria-atomic', 'true'); - - toastEl.innerHTML = ` + + // Убрать состояние ошибки через 3 секунды + setTimeout(() => { + this.field.classList.remove("error"); + this.icon.classList.remove("bi-exclamation-triangle-fill"); + this.icon.classList.add("bi-lock-fill"); + }, 3000); + } + + showToast(title, message, type = "info") { + const toastContainer = + document.querySelector(".toast-container") || this.createToastContainer(); + + const toastEl = document.createElement("div"); + toastEl.className = `toast align-items-center text-bg-${type} border-0`; + toastEl.setAttribute("role", "alert"); + toastEl.setAttribute("aria-live", "assertive"); + toastEl.setAttribute("aria-atomic", "true"); + + toastEl.innerHTML = `
${title}: ${message}
-
`; - - toastContainer.appendChild(toastEl); - - const toast = new bootstrap.Toast(toastEl, { autohide: true, delay: 5000 }); - toast.show(); - - toastEl.addEventListener('hidden.bs.toast', () => { - toastEl.remove(); - }); - } - - createToastContainer() { - const container = document.createElement('div'); - container.className = 'toast-container position-fixed top-0 end-0 p-3'; - container.style.zIndex = '1056'; - document.body.appendChild(container); - return container; - } + + toastContainer.appendChild(toastEl); + + const toast = new bootstrap.Toast(toastEl, { autohide: true, delay: 5000 }); + toast.show(); + + toastEl.addEventListener("hidden.bs.toast", () => { + toastEl.remove(); + }); + } + + createToastContainer() { + const container = document.createElement("div"); + container.className = "toast-container position-fixed top-0 end-0 p-3"; + container.style.zIndex = "1056"; + document.body.appendChild(container); + return container; + } } /** * Инициализация всех LDAP Login полей на странице */ function initLdapLoginFields() { - const fields = document.querySelectorAll('.ldap-login-field'); - fields.forEach(field => { - new LdapLoginField(field); - }); + const fields = document.querySelectorAll(".ldap-login-field"); + fields.forEach((field) => { + new LdapLoginField(field); + }); } // Инициализация при загрузке DOM -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initLdapLoginFields); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initLdapLoginFields); } else { - initLdapLoginFields(); + initLdapLoginFields(); } // Экспорт для использования в других модулях diff --git a/backend/static/scss/components/_ldap-login.scss b/backend/static/scss/components/_ldap-login.scss index 93ab42da..8f73cc29 100644 --- a/backend/static/scss/components/_ldap-login.scss +++ b/backend/static/scss/components/_ldap-login.scss @@ -7,7 +7,7 @@ .ldap-login-field { margin: var(--space-2) 0; text-align: center; - + .ldap-login-toggle { display: inline-flex; align-items: center; @@ -21,32 +21,32 @@ 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; - + &.blurred { filter: blur(6px); user-select: none; } - + &.revealed { filter: blur(0); letter-spacing: normal; @@ -55,19 +55,19 @@ } } } - + // Состояние загрузки &.loading { .ldap-login-toggle { pointer-events: none; opacity: 0.7; - + .ldap-icon { animation: spin 1s linear infinite; } } } - + // Состояние ошибки &.error { .ldap-login-toggle { @@ -75,13 +75,13 @@ 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); } @@ -105,7 +105,7 @@ .ldap-login-toggle { 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); diff --git a/backend/templates/employees/components/employee_detail_scripts.html b/backend/templates/employees/components/employee_detail_scripts.html index b42bd6db..eb8cfcc0 100644 --- a/backend/templates/employees/components/employee_detail_scripts.html +++ b/backend/templates/employees/components/employee_detail_scripts.html @@ -45,7 +45,7 @@ const editWrap = document.getElementById('editFormWrap'); if (editWrap) { const isMobile = () => window.matchMedia('(max-width: 767.98px)').matches; - + function getHeaderOffset() { const sticky = document.querySelector('.ios-header'); return (sticky?.offsetHeight || 0) + 8; @@ -114,7 +114,7 @@ // Отправка формы actionForm.addEventListener('submit', async (e) => { e.preventDefault(); - + // Скрываем предыдущие сообщения if (actionSuccess) actionSuccess.classList.add('d-none'); if (actionError) actionError.classList.add('d-none'); @@ -144,7 +144,7 @@ if (response.ok) { const result = await response.json(); - + // Показываем сообщение об успехе if (actionSuccess) { const actionNames = { @@ -178,7 +178,7 @@ } else { const error = await response.json(); let errorMsg = 'Ошибка при создании события.'; - + if (error.detail) { errorMsg = error.detail; } else if (error.error) { @@ -186,7 +186,7 @@ } else if (typeof error === 'object') { errorMsg = Object.values(error).flat().join('. '); } - + if (actionError) { actionError.textContent = errorMsg; actionError.classList.remove('d-none'); diff --git a/backend/templates/employees/employee_detail.html b/backend/templates/employees/employee_detail.html index 2d6cf716..9f50a591 100644 --- a/backend/templates/employees/employee_detail.html +++ b/backend/templates/employees/employee_detail.html @@ -66,8 +66,8 @@ {% endif %} - {# LDAP Login (только для админов/HR) #} - {% if request.user.is_staff or perms.employees.view_ldap_info %} + {# LDAP Login (для себя или админов/HR) #} + {% if is_me or request.user.is_staff or perms.employees.view_ldap_info %}