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 = `