Игорь Надеин 281f0b2bbc feat: современное отображение событий календаря с множественным выбором
Реализовано:

1. Множественный выбор календарей через чекбоксы
   - Заменил <select> на чекбоксы для выбора нескольких календарей
   - События создаются одновременно во всех выбранных календарях
   - При редактировании - обновляется только первый выбранный
   - Цветовые индикаторы для каждого календаря

2. Современный дизайн событий
   - Двухцветная схема: левая граница = цвет календаря, фон = цвет события
   - Полупрозрачный фон события (80%) для лучшей видимости
   - Тени и hover эффекты для объёма
   - Закругленные углы (4px)
   - Плавные анимации (transform + transition)

3. Улучшенное отображение накладывающихся событий
   - dayMaxEvents: автоматически показывать '+N еще'
   - dayMaxEventRows: 4 - максимум 4 строки на день
   - moreLinkClick: 'popover' - красивый popover для остальных
   - eventMaxStack: 3 - до 3 событий в стеке
   - eventOrder: сортировка по началу, длительности, алфавиту

4. Новый CSS файл calendar-improvements.css
   - Современные стили для FullCalendar
   - Улучшенный popover с градиентом
   - Стили для выходных дней (розоватый фон)
   - Hover эффекты на события
   - Анимация fadeInEvent при загрузке
   - Улучшенная читаемость текущего дня
   - Адаптивность для мобильных устройств
   - Кастомные scrollbar для списков

5. Передача цвета календаря в события
   - calendar_color добавляется в extendedProps
   - Используется __calendar из интеграции
   - Отдельная обработка eventColor и calendarColor

6. Визуальные улучшения
   - Текст события: font-weight 500, line-height 1.3
   - Отступы: paddingLeft 6px (с цветом календаря)
   - Тень текста для читаемости на любом фоне
   - Паттерн для ежегодных событий (диагональные полосы)
   - Индикатор текущего дня: синий круг с белым текстом

Результат:
✅ События визуально различимы по календарям (левая граница)
✅ Сохранён индивидуальный цвет события (основной фон)
✅ Множество событий в день отображается компактно (+N еще)
✅ Современный Material Design стиль
✅ Плавные анимации и hover эффекты
✅ Улучшенная читаемость и UX
2026-02-11 12:52:25 +03:00

686 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.

from __future__ import annotations
import logging
from datetime import date
from typing import Any, Dict, List, Optional, Type
from calendar_app.models import CalendarEvent
from calendar_app.services.recurrence import expand_event_occurrences
from django.core.cache import cache
from django.http import Http404
from django.utils.dateparse import parse_date
from employees.constants import DeptPerm
from rest_framework import serializers, status
from rest_framework.decorators import action as rest_action
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 ..permissions import AdminOrDeptAllowed
from .serializers import (
CalendarEventSerializer,
CalendarEventWriteSerializer,
CalendarSerializer,
CalendarSubscriptionSerializer,
CalendarSubscriptionWriteSerializer,
CalendarWriteSerializer,
)
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
# Новая архитектура: calendar_id имеет приоритет
calendar_id = self.request.query_params.get("calendar_id")
if calendar_id is not None:
try:
return qs.filter(calendar_id=int(calendar_id))
except ValueError:
raise Http404("Некорректный calendar_id.")
# Legacy логика: department_id / employee_id / company
dep = self._dept_id(required=False)
emp = self._employee_id(required=False)
# Приоритет: employee_id > department_id > company
if emp is not None:
# Личный календарь сотрудника (legacy)
return qs.filter(
employee_id=emp, department__isnull=True, calendar__isnull=True
)
elif dep is not None:
# Календарь отдела (legacy)
return qs.filter(
department_id=dep, employee__isnull=True, calendar__isnull=True
)
else:
# Календарь компании (legacy)
return qs.filter(
department__isnull=True, employee__isnull=True, calendar__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}
)
class CalendarViewSet(ModelViewSet):
"""CRUD операции для календарей.
Доступные действия:
- list: список календарей (доступные текущему пользователю)
- create: создание нового календаря (только владельцы/админы)
- retrieve: просмотр календаря
- update/partial_update: обновление календаря (только владелец)
- destroy: удаление календаря (только владелец)
- subscribe: подписка на календарь
- unsubscribe: отписка от календаря
"""
permission_classes = [IsAuthenticated]
renderer_classes = [JSONRenderer]
def get_queryset(self):
"""Возвращает календари, доступные текущему пользователю."""
from calendar_app.models import Calendar
user = self.request.user
if self.action == "list":
# Для списка показываем только доступные календари
return Calendar.objects.get_available_for_user(user)
# Для остальных действий возвращаем все календари (права проверяются в permissions)
return Calendar.objects.all()
def get_serializer_class(self):
"""Выбор сериализатора по действию."""
if self.action in {"create", "update", "partial_update"}:
return CalendarWriteSerializer
return CalendarSerializer
def get_permissions(self):
"""Настройка прав доступа."""
if self.action in ["list", "retrieve"]:
return [IsAuthenticated()]
elif self.action in ["create"]:
# Создавать календари могут все авторизованные пользователи
return [IsAuthenticated()]
elif self.action in ["update", "partial_update", "destroy"]:
# Изменять/удалять могут только владельцы
return [IsAuthenticated()]
else:
return [IsAuthenticated()]
def create(self, request, *args, **kwargs):
"""Создание календаря с корректным сериализатором для ответа."""
write_serializer = self.get_serializer(data=request.data)
write_serializer.is_valid(raise_exception=True)
self.perform_create(write_serializer)
# Для ответа используем CalendarSerializer
instance = write_serializer.instance
read_serializer = CalendarSerializer(instance, context={"request": request})
from rest_framework import status
from rest_framework.response import Response
headers = self.get_success_headers(read_serializer.data)
return Response(
read_serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
def perform_create(self, serializer):
"""Создание календаря."""
# Владелец определяется в serializer.save()
serializer.save()
cache.clear()
def perform_update(self, serializer):
"""Обновление календаря."""
# Проверяем права владельца
calendar = self.get_object()
user = self.request.user
if not (user.is_superuser or user.is_staff or calendar.is_owner(user)):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Только владелец может изменять календарь.")
serializer.save()
cache.clear()
def perform_destroy(self, instance):
"""Удаление календаря."""
user = self.request.user
if not (user.is_superuser or user.is_staff or instance.is_owner(user)):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Только владелец может удалять календарь.")
instance.delete()
cache.clear()
@rest_action(
detail=True,
methods=["post"],
permission_classes=[IsAuthenticated],
url_path="subscribe",
)
def subscribe(self, request, pk=None):
"""Подписка на календарь.
Body params:
can_edit (bool, optional): Право на редактирование событий
can_manage (bool, optional): Право на управление календарем
"""
from calendar_app.models import Calendar, CalendarSubscription
calendar = self.get_object()
user = request.user
# Проверяем, доступен ли календарь для подписки
if not calendar.can_user_view(user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Календарь недоступен для подписки.")
# Проверяем, не подписан ли уже пользователь
existing = CalendarSubscription.objects.filter(
calendar=calendar, user=user
).first()
if existing:
return Response(
{"detail": "Вы уже подписаны на этот календарь."},
status=status.HTTP_400_BAD_REQUEST,
)
# Создаем подписку
can_edit = request.data.get("can_edit", False)
can_manage = request.data.get("can_manage", False)
# Только владелец может выдавать права can_edit и can_manage
if (can_edit or can_manage) and not calendar.is_owner(user):
can_edit = False
can_manage = False
subscription = CalendarSubscription.objects.create(
calendar=calendar,
user=user,
can_edit=can_edit,
can_manage=can_manage,
)
serializer = CalendarSubscriptionSerializer(
subscription, context={"request": request}
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@rest_action(
detail=True,
methods=["post"],
permission_classes=[IsAuthenticated],
url_path="unsubscribe",
)
def unsubscribe(self, request, pk=None):
"""Отписка от календаря."""
from calendar_app.models import CalendarSubscription
calendar = self.get_object()
user = request.user
subscription = CalendarSubscription.objects.filter(
calendar=calendar, user=user
).first()
if not subscription:
return Response(
{"detail": "Вы не подписаны на этот календарь."},
status=status.HTTP_400_BAD_REQUEST,
)
subscription.delete()
return Response(
{"detail": "Подписка успешно отменена."},
status=status.HTTP_200_OK,
)
@rest_action(
detail=False,
methods=["get"],
permission_classes=[IsAuthenticated],
url_path="my-calendars",
)
def my_calendars(self, request):
"""Список всех календарей, доступных текущему пользователю."""
from calendar_app.models import Calendar
user = request.user
calendars = Calendar.objects.get_available_for_user(user)
serializer = CalendarSerializer(
calendars, many=True, context={"request": request}
)
return Response(serializer.data, status=status.HTTP_200_OK)
class CalendarSubscriptionViewSet(ModelViewSet):
"""CRUD операции для подписок на календари.
Доступные действия:
- list: список подписок текущего пользователя
- create: создание новой подписки
- retrieve: просмотр подписки
- update/partial_update: обновление подписки (права доступа)
- destroy: удаление подписки
"""
permission_classes = [IsAuthenticated]
renderer_classes = [JSONRenderer]
def get_queryset(self):
"""Возвращает подписки текущего пользователя."""
from calendar_app.models import CalendarSubscription
user = self.request.user
if user.is_superuser or user.is_staff:
# Админы видят все подписки
return CalendarSubscription.objects.all()
# Обычные пользователи видят только свои подписки
return CalendarSubscription.objects.filter(user=user)
def get_serializer_class(self):
"""Выбор сериализатора по действию."""
if self.action in {"create", "update", "partial_update"}:
return CalendarSubscriptionWriteSerializer
return CalendarSubscriptionSerializer
def perform_create(self, serializer):
"""Создание подписки."""
# Пользователь устанавливается автоматически в сериализаторе
serializer.save(user=self.request.user)
cache.clear()
def perform_update(self, serializer):
"""Обновление подписки."""
subscription = self.get_object()
user = self.request.user
# Только владелец календаря может изменять права подписки
if not subscription.calendar.is_owner(user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(
"Только владелец календаря может изменять права подписки."
)
serializer.save()
cache.clear()
def perform_destroy(self, instance):
"""Удаление подписки."""
user = self.request.user
# Удалять подписку может либо владелец подписки, либо владелец календаря
if not (instance.user == user or instance.calendar.is_owner(user)):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Вы не можете удалить эту подписку.")
instance.delete()
cache.clear()