391 lines
16 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.

from __future__ import annotations
from datetime import date
from typing import Optional, Type, List, Dict, Any
from django.core.cache import cache
from django.http import Http404
from django.utils.dateparse import parse_date
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.decorators import action as rest_action
from calendar_app.models import CalendarEvent
from .serializers import (
CalendarEventSerializer,
CalendarEventWriteSerializer,
)
from calendar_app.services.recurrence import expand_event_occurrences
from ..permissions import AdminOrDeptAllowed
from employees.constants import DeptPerm
import logging
logger = logging.getLogger(__name__)
class CalendarEventsViewSet(ModelViewSet):
"""Единый CRUD по событиям календаря (компания/отдел) с развёрткой повторяемости.
list:
Возвращает МАТЕРИАЛИЗОВАННЫЕ вхождения в окне [start, end).
Если указан department_id — события отдела, иначе — компании.
retrieve/create/update/partial_update/destroy:
Работают с объектом CalendarEvent.
"""
queryset = CalendarEvent.objects.select_related("department").order_by(
"start_date", "start_time"
)
renderer_classes = [JSONRenderer]
# ВАЖНО: Переопределяем глобальные DEFAULT_PERMISSION_CLASSES
# Используем только get_permissions() для динамического определения прав
permission_classes = []
# ---------- Внутренний пермишен для отделов ----------
class ManageCalendarPerm(AdminOrDeptAllowed):
"""Право на управление календарём отдела (для компании молчит)."""
required_code = DeptPerm.MANAGE_CALENDAR
# ---------- Подключение пермишенов ----------
def get_permissions(self):
"""Настройка прав доступа в зависимости от действия.
- list, retrieve: доступно всем авторизованным пользователям
- create, update, partial_update, destroy для компании: требуется staff/superuser
- create, update, partial_update, destroy для отдела: требуется MANAGE_CALENDAR
- create, update, partial_update, destroy для личного календаря: только владелец
"""
action = getattr(self, "action", None)
method = self.request.method if hasattr(self, 'request') else None
# Если action ещё не установлен, определяем по методу
if action is None:
if method in ['GET', 'HEAD', 'OPTIONS']:
return [IsAuthenticated()]
elif method == 'POST':
action = 'create'
elif method in ['PUT', 'PATCH']:
action = 'update'
elif method == 'DELETE':
action = 'destroy'
# Просмотр событий доступен всем авторизованным пользователям
if action in ["list", "retrieve"]:
return [IsAuthenticated()]
# Для изменений требуются права управления календарём
emp = self._employee_id(required=False)
dep = self._dept_id(required=False)
# Личный календарь — полный контроль только у владельца
if emp is not None:
# Проверка, что текущий пользователь = владелец календаря
if self.request.user.id == emp:
return [IsAuthenticated()]
else:
# Другой пользователь не может изменять чужой личный календарь
from rest_framework.permissions import BasePermission
class DenyAll(BasePermission):
def has_permission(self, request, view):
return False
return [DenyAll()]
# Календарь компании — только администраторы
if dep is None:
from rest_framework.permissions import IsAdminUser
return [IsAdminUser()]
# Календарь отдела — требуется право MANAGE_CALENDAR
self.kwargs = dict(self.kwargs)
self.kwargs.setdefault("department_id", dep)
return [self.ManageCalendarPerm()]
# ---------- Сериализаторы ----------
def get_serializer_class(self) -> Type[serializers.Serializer]:
"""Выбор сериализатора по действию.
Returns:
Type[serializers.Serializer]: Класс сериализатора.
"""
if self.action in {"create", "update", "partial_update"}:
return CalendarEventWriteSerializer
return CalendarEventSerializer
# ---------- Утилиты ----------
def _dept_id(self, *, required: bool = False) -> Optional[int]:
"""Читает department_id из kwargs/query/body.
ВАЖНО: *не* путать pk события с department_id.
Args:
required (bool): Если True — бросить 404 при отсутствии/ошибке.
Raises:
Http404: При некорректном или отсутствующем department_id (когда required=True).
Returns:
Optional[int]: PK отдела или None (компания или личный календарь).
"""
# nested-url вида: /departments/{department_id}/events/
for key in ("department_pk", "department_id"):
if key in self.kwargs:
try:
return int(self.kwargs[key])
except Exception:
raise Http404("Некорректный department_id в URL.")
req = getattr(self, "request", None)
if req is not None:
for key in ("department_id", "department"):
val = req.query_params.get(key)
if val is not None:
try:
return int(val)
except Exception:
raise Http404("Некорректный department_id в query.")
data = getattr(req, "data", {}) if req is not None else {}
if isinstance(data, dict):
for key in ("department_id", "department"):
if key in data and data[key] is not None:
try:
return int(data[key])
except Exception:
raise Http404("Некорректный department_id в теле запроса.")
if required:
raise Http404("Не указан department_id.")
return None
def _employee_id(self, *, required: bool = False) -> Optional[int]:
"""Читает employee_id из kwargs/query/body для личного календаря.
Args:
required (bool): Если True — бросить 404 при отсутствии/ошибке.
Raises:
Http404: При некорректном или отсутствующем employee_id (когда required=True).
Returns:
Optional[int]: PK сотрудника или None (компания/отдел).
"""
# nested-url вида: /employees/{employee_id}/events/
for key in ("employee_pk", "employee_id"):
if key in self.kwargs:
try:
return int(self.kwargs[key])
except Exception:
raise Http404("Некорректный employee_id в URL.")
req = getattr(self, "request", None)
if req is not None:
for key in ("employee_id", "employee"):
val = req.query_params.get(key)
if val is not None:
try:
return int(val)
except Exception:
raise Http404("Некорректный employee_id в query.")
data = getattr(req, "data", {}) if req is not None else {}
if isinstance(data, dict):
for key in ("employee_id", "employee"):
if key in data and data[key] is not None:
try:
return int(data[key])
except Exception:
raise Http404("Некорректный employee_id в теле запроса.")
if required:
raise Http404("Не указан employee_id.")
return None
# ---------- QuerySet ----------
def get_queryset(self):
"""QS для list фильтруем по компании/отделу/сотруднику, для detail — не фильтруем.
Returns:
QuerySet: Набор для текущего действия.
"""
qs = super().get_queryset()
if getattr(self, "action", None) != "list":
# detail — нужен полный QS, чтобы по pk находить любой объект
return qs
dep = self._dept_id(required=False)
emp = self._employee_id(required=False)
# Приоритет: employee_id > department_id > company
if emp is not None:
# Личный календарь сотрудника
return qs.filter(employee_id=emp, department__isnull=True)
elif dep is not None:
# Календарь отдела
return qs.filter(department_id=dep, employee__isnull=True)
else:
# Календарь компании
return qs.filter(department__isnull=True, employee__isnull=True)
# ---------- LIST: материализация повторов ----------
def list(self, request, *args, **kwargs):
"""Возвращает материализованные вхождения в окне [start, end).
Query params:
start (YYYY-MM-DD) — левая граница (включительно).
end (YYYY-MM-DD) — правая граница (исключительно).
Returns:
Response: Массив объектов для FullCalendar.
"""
start_str = request.query_params.get("start")
end_str = request.query_params.get("end")
if not start_str or not end_str:
return Response([], status=status.HTTP_200_OK)
r_start: Optional[date] = parse_date(start_str[:10])
r_end: Optional[date] = parse_date(end_str[:10])
if not r_start or not r_end:
return Response(
{"detail": "Некорректные параметры start/end."},
status=status.HTTP_400_BAD_REQUEST,
)
if r_start >= r_end:
return Response([], status=status.HTTP_200_OK)
items: List[Dict[str, Any]] = []
for ev in self.get_queryset():
for oc in expand_event_occurrences(
ev, range_start=r_start, range_end=r_end, max_instances=1000
):
items.append(
{
"id": ev.id, # идентификатор «родителя»; можно заменить на oc.id, если нужен уникальный на каждое вхождение
"title": ev.title,
"start": oc.start.isoformat(),
"end": oc.end.isoformat(),
"allDay": oc.all_day,
"color": ev.color or None,
"recurrence": ev.recurrence,
"department_id": ev.department_id,
}
)
return Response(items, status=status.HTTP_200_OK)
# ---------- Создание/изменение/удаление ----------
def perform_create(self, serializer: CalendarEventWriteSerializer) -> None:
"""Сохраняет объект, подставляя department_id/employee_id из контекста и автора.
Raises:
ValidationError: Если сериализатор невалиден (поднимет сам DRF).
"""
dep = self._dept_id(required=False)
emp = self._employee_id(required=False)
# Приоритет: employee_id > department_id
if emp is not None:
serializer.save(
employee_id=emp,
department_id=None,
created_by=self.request.user,
)
else:
serializer.save(
department_id=dep if dep is not None else serializer.validated_data.get("department_id"),
employee_id=None,
created_by=self.request.user,
)
cache.clear()
def perform_update(self, serializer: CalendarEventWriteSerializer) -> None:
"""Обновляет событие и чистит кеш."""
serializer.save()
cache.clear()
def perform_destroy(self, instance: CalendarEvent) -> None:
"""Удаляет событие и чистит кеш."""
instance.delete()
cache.clear()
# ---------- Проверка прав ----------
@rest_action(
detail=True,
methods=["get"],
permission_classes=[IsAuthenticated],
url_path="permissions",
)
def permissions(self, request, pk=None):
"""Проверяет права пользователя на редактирование/удаление события.
Returns:
Response: {
"can_edit": bool,
"can_delete": bool,
"can_view": bool (всегда True для авторизованных)
}
"""
event = self.get_object()
user = request.user
# Просмотр доступен всем авторизованным
can_view = True
# Права на редактирование и удаление
can_edit = False
can_delete = False
# Проверка прав
if user.is_superuser or user.is_staff:
# Админы могут всё
can_edit = True
can_delete = True
elif event.employee_id is not None:
# Личное событие — редактировать может только владелец
if event.employee_id == user.id:
can_edit = True
can_delete = True
elif event.department_id is None:
# Событие компании — только админы (уже проверено выше)
pass
else:
# Событие отдела — проверяем право MANAGE_CALENDAR
try:
from employees.models import EmployeeDepartment
# Проверяем, является ли пользователь руководителем отдела
if hasattr(event.department, "head_id"):
if event.department.head_id == user.id:
can_edit = True
can_delete = True
# Проверяем наличие права MANAGE_CALENDAR
if not can_edit:
has_perm = EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=event.department_id,
is_active=True,
role__scoped_permissions__code=DeptPerm.MANAGE_CALENDAR,
).exists()
if has_perm:
can_edit = True
can_delete = True
except Exception as e:
logger.error(f"Error checking permissions: {e}")
return Response(
{"can_view": can_view, "can_edit": can_edit, "can_delete": can_delete}
)