Corp/backend/api/v1/employees/views/employees.py

738 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""EmployeeViewSet — CRUD сотрудников, профиль, навыки, LDAP-info, Excel-экспорт."""
from __future__ import annotations
import logging
import traceback
from common.emails import send_templated_mail
from common.external_sync_mixin import ExternalSystemSyncMixin
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import transaction
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.crypto import get_random_string
from employees.constants import ACTION_DISMISSED
from employees.ldap.directory_service import DirectoryService, DirectoryUserDTO
from employees.ldap.errors import (DirectoryDbError, DirectoryLdapError,
DirectoryServiceError)
from employees.ldap.infrastructure.connections import _conn
from employees.models import (Department, EmployeeAction, EmployeeDepartment,
LdapSyncState, Position, RoleAssignment, Skill)
from employees.utils import _to_bool
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ...permissions import AdminOrActionOrModelPerms, IsSelfOrStaff
from ..serializers import (EmployeeListSerializer, EmployeeSerializer,
SkillSerializer)
from ._helpers import Employee, _is_ldap_enabled
logger = logging.getLogger(__name__)
class EmployeeViewSet(ExternalSystemSyncMixin, viewsets.ModelViewSet):
"""
/api/v1/employees/
Доступ:
- GET list/retrieve — только аутентифицированные пользователи.
- POST (create) — только staff/superuser.
- PATCH/PUT/DELETE {id} — staff/superuser ИЛИ пользователи с модельными правами.
- GET/PATCH /employees/me/ — только аутентифицированные; PATCH правит профиль текущего пользователя.
- POST {id}/add_skill|remove_skill — требуется employees.manage_employee_skills.
Поиск: last_name, first_name, patronymic, email, phone_number
Использует ExternalSystemSyncMixin для автоматической синхронизации с LDAP.
"""
serializer_class = EmployeeSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["last_name", "first_name",
"patronymic", "email", "phone_number"]
ordering_fields = ["last_name", "first_name", "created_at", "id"]
ordering = ["last_name", "first_name"]
required_perms_by_action = {
"add_skill": "employees.manage_employee_skills",
"remove_skill": "employees.manage_employee_skills",
"ldap_info": "employees.view_ldap_info",
}
# ExternalSystemSyncMixin configuration
external_sync_service = None # Инициализируется в __init__ для каждого инстанса
external_sync_enabled = True # Можно отключить через settings или для тестов
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Создаем DirectoryService для каждого инстанса ViewSet
if _is_ldap_enabled():
self.external_sync_service = DirectoryService()
self.external_sync_enabled = True
else:
self.external_sync_enabled = False
def get_external_sync_method(self, action):
"""Маппинг ViewSet action -> метод DirectoryService."""
if not _is_ldap_enabled():
return None
return {
'create': 'create_user_in_ldap_only',
'update': 'update_user_in_ldap_only',
'partial_update': 'update_user_in_ldap_only',
'destroy': 'delete_user_in_ldap_only',
}.get(action)
def prepare_external_data(self, instance, action):
"""Подготовка данных для передачи в LDAP сервис."""
if action == 'create':
# При создании передаем DTO с полными данными
password = self.request.data.get('password')
avatar_file = self.request.data.get('avatar')
avatar_bytes = None
if avatar_file and hasattr(avatar_file, 'read'):
try:
if hasattr(avatar_file, 'seek'):
avatar_file.seek(0)
avatar_bytes = avatar_file.read()
except Exception:
pass
dto = DirectoryUserDTO(
username=instance.username if instance.username else None,
first_name=instance.first_name,
last_name=instance.last_name,
email=instance.email,
phone_e164=str(
instance.phone_number) if instance.phone_number else '',
department_dn=self.request.data.get('department_dn'),
group_cns=self.request.data.get('group_cns', []) or [],
initial_password=password,
avatar_bytes=avatar_bytes,
is_active=instance.is_active,
)
return {'dto': dto, 'password': password}
elif action in ['update', 'partial_update']:
# При обновлении передаем инстанс и изменения
changes = {}
request_data = dict(self.request.data)
# Поля которые идут в LDAP
ldap_fields = {'first_name', 'last_name', 'email',
'phone_number', 'is_active', 'password'}
for field in ldap_fields:
if field in request_data:
changes[field] = request_data[field]
# Аватар -> avatar_bytes
avatar_file = request_data.get('avatar')
if avatar_file and hasattr(avatar_file, 'read'):
try:
if hasattr(avatar_file, 'seek'):
avatar_file.seek(0)
changes['avatar_bytes'] = avatar_file.read()
except Exception:
pass
# Должность
if 'position' in request_data or 'position_id' in request_data:
changes['position'] = request_data.get(
'position') or request_data.get('position_id')
# Отдел (для перемещения) - резолвим DN здесь в ViewSet
move_to_department_dn = None
if 'department' in request_data or 'department_id' in request_data:
dept_val = request_data.get(
'department') or request_data.get('department_id')
if dept_val:
# Если передан ID - получаем инстанс из БД
if isinstance(dept_val, int):
from employees.models import Department
dept = Department.objects.filter(id=dept_val).first()
else:
dept = dept_val
# Резолвим DN отдела
if dept:
try:
from employees.ldap.services.department_service import \
DepartmentService
dept_svc = DepartmentService()
move_to_department_dn = dept_svc._get_department_dn(
dept)
except Exception as e:
logger.warning(
f"Failed to resolve department DN: {e}")
# Группы - берем готовый список CN если передан
group_cns = request_data.get('group_cns')
return {
'instance': instance,
'changes': changes,
'move_to_department_dn': move_to_department_dn,
'group_cns': group_cns
}
elif action == 'destroy':
# При удалении передаем только инстанс
return {'instance': instance}
return {}
def handle_external_sync_error(self, error, instance, action):
"""Обработка ошибок синхронизации с LDAP."""
logger.error(
f"LDAP sync failed for {action} on Employee {instance.id}: {error}",
exc_info=True
)
# Возвращаем понятную ошибку пользователю
if isinstance(error, (DirectoryLdapError, DirectoryServiceError)):
return Response(
{'detail': f'Ошибка синхронизации с LDAP: {str(error)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Для остальных ошибок - стандартная обработка
return Response(
{'detail': f'Ошибка внешней системы: {str(error)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def handle_external_sync_result(self, instance, result, action):
"""Обработка успешного результата синхронизации с LDAP.
Вызывается миксином после успешной синхронизации.
"""
if action != 'create' or not isinstance(result, dict):
return
dn = result.get('dn')
guid = result.get('guid')
if not dn:
return
# Сохраняем sync state
st, _ = LdapSyncState.objects.get_or_create(
model="employee", object_pk=str(instance.pk)
)
st.touch(
ldap_dn=dn,
ldap_guid=guid,
sync_dir="ldap",
last_django_modify_ts=timezone.now(),
)
# Записываем Django PK в LDAP employeeNumber для обратной связки
try:
from employees.ldap.infrastructure.connections import _ldap
from employees.ldap.repositories.ldap_repository import \
modify_user_attrs
employee_id_attr = getattr(
settings, "LDAP_EMPLOYEE_ID_ATTR", "employeeNumber")
with _ldap() as conn:
modify_user_attrs(
conn, dn,
{employee_id_attr: str(instance.pk)},
do_write=True
)
except Exception as e:
logger.warning(f"Failed to set employeeNumber in LDAP: {e}")
# Назначаем должность если указана
if instance.position_id:
try:
svc = self.external_sync_service or DirectoryService()
svc.assign_position(instance, instance.position)
except Exception as e:
logger.warning(f"Failed to assign position in LDAP: {e}")
def get_permissions(self):
if self.action == "create":
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
if self.action in {
"update",
"partial_update",
"destroy",
"add_skill",
"remove_skill",
"ldap_info",
}:
return [IsAuthenticated(), (IsSelfOrStaff | AdminOrActionOrModelPerms)()]
return [IsAuthenticated()]
def get_queryset(self):
last_action_code_sq = Subquery(
EmployeeAction.objects.filter(employee_id=OuterRef("pk"))
.order_by("-date")
.values("action")[:1]
)
dep_links_prefetch = Prefetch(
"departments_links",
queryset=EmployeeDepartment.objects.filter(is_active=True).select_related(
"department", "role"
),
to_attr="dept_links",
)
prefetches = [
"skills",
dep_links_prefetch,
Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")),
]
qs = (
Employee.objects.select_related("position")
.prefetch_related(*prefetches)
.annotate(last_action_code=last_action_code_sq)
.order_by(*self.ordering)
)
qp = self.request.query_params
# по отделу
dep = qp.get("department")
if dep:
try:
dep_id = int(dep)
except (TypeError, ValueError):
dep_id = None
if dep_id:
member_ids = EmployeeDepartment.objects.filter(
department_id=dep_id
).values("employee_id")
head_ids = Department.objects.filter(
id=dep_id).values("head_id")
role_assignment_ids = RoleAssignment.objects.filter(
role__department_id=dep_id, is_active=True
).values("employee_id")
qs = qs.filter(
Q(id__in=member_ids)
| Q(id__in=head_ids)
| Q(id__in=role_assignment_ids)
).distinct()
qs = qs.annotate(
_is_dept_member=Exists(
EmployeeDepartment.objects.filter(
employee_id=OuterRef("pk"),
department_id=dep_id,
is_active=True,
)
),
_is_dept_head=Exists(
Department.objects.filter(
id=dep_id, head_id=OuterRef("pk"))
),
_has_role_assignment=Exists(
RoleAssignment.objects.filter(
employee_id=OuterRef("pk"),
role__department_id=dep_id,
is_active=True,
)
),
)
self.request._department_filter_id = dep_id
# по должности
position = qp.get("position")
if position:
qs = qs.filter(position_id=position)
# по навыкам (any-of)
skill_ids = qp.getlist("skill")
if skill_ids:
qs = qs.filter(skills__in=skill_ids).distinct()
# статусы
email_verified = _to_bool(qp.get("email_verified"))
if email_verified is not None:
qs = qs.filter(email_verified=email_verified)
active = _to_bool(qp.get("active"))
if active is not None:
qs = qs.filter(is_active=active)
actually = _to_bool(qp.get("actually_active"))
if actually is True:
qs = qs.filter(
Q(email_verified=True)
& (
Q(last_action_code__isnull=True, is_active=True)
| ~Q(last_action_code=ACTION_DISMISSED)
)
)
elif actually is False:
qs = qs.exclude(
Q(email_verified=True)
& (
Q(last_action_code__isnull=True, is_active=True)
| ~Q(last_action_code=ACTION_DISMISSED)
)
)
created_at_gte = qp.get("created_at__gte")
if created_at_gte:
qs = qs.filter(created_at__gte=created_at_gte)
return qs
def get_serializer_class(self):
if self.action == "list":
return EmployeeListSerializer
return EmployeeSerializer
def perform_create(self, serializer):
"""Кастомная логика создания: unusable password для LDAP пользователей.
Миксин автоматически обернет в транзакцию и вызовет LDAP sync.
"""
password = self.request.data.get("password")
ldap_enabled = _is_ldap_enabled()
# Создаем пользователя в БД
if ldap_enabled:
# LDAP-managed пользователь с unusable password
instance = serializer.save(is_ldap_managed=True)
if hasattr(instance, "set_unusable_password"):
instance.set_unusable_password()
instance.save(update_fields=["password"])
else:
# Обычный пользователь с паролем в БД
instance = serializer.save(is_ldap_managed=False)
instance.set_password(password)
instance.save(update_fields=["password"])
# Навыки (если есть)
skills_ids = self.request.data.get("skills_ids", [])
if skills_ids:
instance.skills.set(skills_ids)
def perform_update(self, serializer):
"""Обработка изменения email для верификации.
Миксин автоматически обернет в транзакцию и вызовет LDAP sync.
"""
instance = serializer.instance
old_email = instance.email
# Стандартное сохранение (миксин добавит LDAP sync)
serializer.save()
# Если email изменился - сбрасываем верификацию
new_email = instance.email
if new_email and new_email.lower() != old_email.lower():
instance.email_verified = False
instance.email_activation_code = get_random_string(6, "0123456789")
instance.save(
update_fields=["email_verified", "email_activation_code"])
try:
send_templated_mail(
subject="Подтверждение нового email",
to=[instance.email],
template_base="emails/registration_verify_code",
context={"code": instance.email_activation_code,
"user": instance},
)
except Exception:
pass
@action(detail=False, methods=["get", "patch"])
def me(self, request):
"""GET — профиль текущего пользователя; PATCH — частичное обновление."""
instance: Employee = request.user # type: ignore
if request.method == "GET":
instance = (
Employee.objects.select_related("position")
.prefetch_related(
"skills",
Prefetch(
"departments_links",
queryset=EmployeeDepartment.objects.filter(
is_active=True
).select_related("department", "role"),
to_attr="dept_links",
),
Prefetch(
"actions", queryset=EmployeeAction.objects.order_by("-date")
),
)
.get(pk=request.user.pk)
)
ctx = self.get_serializer_context()
ctx["include_actions"] = True
ctx["include_action_history"] = True
data = self.get_serializer(instance, context=ctx).data
return Response(data, status=200)
# PATCH — используем стандартный механизм (миксин управляет LDAP sync)
serializer = self.get_serializer(
instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
return Response(serializer.data, status=200)
@action(detail=True, methods=["post"])
def add_skill(self, request, pk=None):
"""body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }"""
emp = self.get_object()
sid = request.data.get("skill_id")
sname = (request.data.get("skill_name") or "").strip()
sk = None
if sid:
sk = Skill.objects.filter(pk=sid).first()
if not sk and sname:
sk = Skill.objects.filter(
name__iexact=sname
).first() or Skill.objects.create(name=sname)
if not sk:
return Response({"detail": "Навык не найден/не указан"}, status=400)
emp.skills.add(sk)
return Response(
{"ok": True, "skill": {"id": sk.id, "name": sk.name}}, status=200
)
@action(detail=True, methods=["post"])
def remove_skill(self, request, pk=None):
"""body: { "skill_id": 3 } ИЛИ { "skill_name": "Python" }"""
emp = self.get_object()
sid = request.data.get("skill_id")
sname = (request.data.get("skill_name") or "").strip()
sk = None
if sid:
sk = Skill.objects.filter(pk=sid).first()
if not sk and sname:
sk = Skill.objects.filter(name__iexact=sname).first()
if not sk:
return Response({"detail": "Навык не найден"}, status=404)
emp.skills.remove(sk)
return Response(
{"ok": True, "removed": {"id": sk.id, "name": sk.name}}, status=200
)
@action(detail=True, methods=["get"], url_path="ldap-info")
def ldap_info(self, request, pk=None):
"""GET /api/v1/employees/{id}/ldap-info/ — LDAP информация о сотруднике."""
from employees.ldap.repositories.ldap_repository import LdapRepository
emp = self.get_object()
force_refresh = request.query_params.get(
'force_refresh', '').lower() == 'true'
if not force_refresh and emp.username:
return Response(
{
"sAMAccountName": emp.username,
"cached": True,
},
status=status.HTTP_200_OK,
)
try:
ldap_sync = LdapSyncState.objects.get(
model='employee',
object_pk=str(emp.pk)
)
if not ldap_sync.ldap_dn:
return Response(
{"detail": "У этого сотрудника нет связи с LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
except LdapSyncState.DoesNotExist:
return Response(
{"detail": "У этого сотрудника нет связи с LDAP"},
status=status.HTTP_404_NOT_FOUND,
)
try:
conn = _conn()
ldap_repo = LdapRepository(conn)
attrs = ldap_repo.read_attrs(
ldap_sync.ldap_dn,
["sAMAccountName"],
)
if not attrs or not attrs.get("sAMAccountName"):
return Response(
{"detail": "Не удалось получить LDAP информацию"},
status=status.HTTP_404_NOT_FOUND,
)
sam_account_name = attrs.get("sAMAccountName")
emp.username = sam_account_name
emp.save(update_fields=["username"])
return Response(
{
"sAMAccountName": sam_account_name,
"cached": False,
},
status=status.HTTP_200_OK,
)
except Exception as e:
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,
)
@action(detail=False, methods=["get"], url_path="export-excel")
def export_excel(self, request):
"""GET /api/v1/employees/export-excel/ — экспорт в Excel."""
from datetime import datetime
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
queryset = self.filter_queryset(self.get_queryset())
queryset = (
queryset.select_related("position")
.prefetch_related(
"skills", "departments_links__department", "departments_links__role"
)
.order_by("last_name", "first_name")
)
wb = Workbook()
ws = wb.active
ws.title = "Сотрудники"
headers = [
"ID",
"Фамилия",
"Имя",
"Отчество",
"Email",
"Телефон",
"Должность",
"Отделы",
"Дата рождения",
"Дата регистрации",
"Активен",
"Email подтвержден",
"Навыки",
"Telegram",
"WhatsApp",
"WeChat",
]
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(
start_color="4472C4", end_color="4472C4", fill_type="solid"
)
header_alignment = Alignment(
horizontal="center", vertical="center", wrap_text=True
)
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
for row_num, emp in enumerate(queryset, 2):
departments = emp.departments_links.filter(is_active=True)
dept_names = ", ".join(
[
f"{d.department.name}" +
(f" ({d.role.name})" if d.role else "")
for d in departments
]
)
skills = ", ".join([s.name for s in emp.skills.all()])
def safe_phone_str(phone_field):
"""Безопасная конвертация PhoneNumber в строку"""
if not phone_field:
return ""
try:
from phonenumbers import PhoneNumberFormat, format_number
return format_number(phone_field, PhoneNumberFormat.INTERNATIONAL)
except Exception:
try:
return str(phone_field)
except Exception:
return ""
phone_str = safe_phone_str(emp.phone_number)
whatsapp_str = safe_phone_str(emp.whatsapp)
row_data = [
emp.id,
emp.last_name or "",
emp.first_name or "",
emp.patronymic or "",
emp.email or "",
phone_str,
emp.position.name if emp.position else "",
dept_names,
emp.birth_date.strftime("%d.%m.%Y") if emp.birth_date else "",
emp.created_at.strftime(
"%d.%m.%Y %H:%M") if emp.created_at else "",
"Да" if emp.is_active else "Нет",
"Да" if emp.email_verified else "Нет",
skills,
emp.telegram or "",
whatsapp_str,
emp.wechat or "",
]
for col_num, value in enumerate(row_data, 1):
cell = ws.cell(row=row_num, column=col_num, value=value)
cell.alignment = Alignment(vertical="top", wrap_text=True)
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except Exception:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column].width = adjusted_width
ws.freeze_panes = "A2"
response = HttpResponse(
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
filename = f"employees_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
wb.save(response)
return response
def get_serializer_context(self):
ctx = super().get_serializer_context()
if getattr(self, "action", None) in {"retrieve", "me"}:
ctx["include_actions"] = True
ctx["include_action_history"] = True
return ctx