391 lines
16 KiB
Python
391 lines
16 KiB
Python
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}
|
||
)
|