feat(calendar): add API layer for optional calendars

Phase 2: API implementation
- Updated CalendarEventWriteSerializer to support calendar_id parameter
- Added CalendarSerializer and CalendarWriteSerializer for Calendar CRUD
- Added CalendarSubscriptionSerializer for subscription management
- Created CalendarViewSet with subscribe/unsubscribe actions
- Created CalendarSubscriptionViewSet for managing subscriptions
- Updated CalendarEventsViewSet to support calendar_id filtering
- Added CalendarManager with get_available_for_user() method
- Added permission methods to Calendar model (is_owner, can_user_view, can_user_edit, can_user_manage)
- Registered new ViewSets in URL routing

API endpoints:
- GET /api/v1/calendar/calendars/ - list available calendars
- POST /api/v1/calendar/calendars/ - create new calendar
- GET /api/v1/calendar/calendars/{id}/ - retrieve calendar details
- PATCH /api/v1/calendar/calendars/{id}/ - update calendar
- DELETE /api/v1/calendar/calendars/{id}/ - delete calendar
- POST /api/v1/calendar/calendars/{id}/subscribe/ - subscribe to calendar
- POST /api/v1/calendar/calendars/{id}/unsubscribe/ - unsubscribe from calendar
- GET /api/v1/calendar/calendars/my-calendars/ - list user's calendars
- GET /api/v1/calendar/subscriptions/ - list user's subscriptions
- POST /api/v1/calendar/subscriptions/ - create subscription
- GET /api/v1/calendar/events/?calendar_id={id} - filter events by calendar

Backward compatibility: all legacy endpoints continue to work
This commit is contained in:
admin i 2026-02-11 08:18:47 +03:00
parent ef734f19c0
commit 870a08ee66
4 changed files with 647 additions and 9 deletions

View File

@ -5,7 +5,7 @@ import re
from datetime import date, datetime, time, timedelta
from typing import Any, Dict, List, Optional
from calendar_app.models import CalendarEvent, Recurrence
from calendar_app.models import Calendar, CalendarEvent, CalendarSubscription, Recurrence
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
@ -21,6 +21,7 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
write_only=True,
help_text="Список дней недели для WEEKLY (0=Mon..6=Sun).",
)
calendar_id = serializers.IntegerField(required=False, allow_null=True)
department_id = serializers.IntegerField(required=False, allow_null=True)
employee_id = serializers.IntegerField(required=False, allow_null=True)
@ -175,10 +176,86 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
def create(self, validated_data: Dict[str, Any]) -> CalendarEvent:
validated_data.pop("weekdays", None)
# Поддержка calendar_id (новая архитектура)
calendar_id = validated_data.pop("calendar_id", None)
if calendar_id is not None:
from calendar_app.models import Calendar
try:
calendar = Calendar.objects.get(id=calendar_id)
validated_data["calendar"] = calendar
except Calendar.DoesNotExist:
raise serializers.ValidationError(
{"calendar_id": _("Календарь с ID {} не найден.").format(calendar_id)}
)
# Legacy поддержка department_id и employee_id
department_id = validated_data.pop("department_id", None)
employee_id = validated_data.pop("employee_id", None)
if department_id is not None:
from employees.models import Department
try:
department = Department.objects.get(id=department_id)
validated_data["department"] = department
except Department.DoesNotExist:
raise serializers.ValidationError(
{"department_id": _("Отдел с ID {} не найден.").format(department_id)}
)
if employee_id is not None:
from django.contrib.auth import get_user_model
User = get_user_model()
try:
employee = User.objects.get(id=employee_id)
validated_data["employee"] = employee
except User.DoesNotExist:
raise serializers.ValidationError(
{"employee_id": _("Сотрудник с ID {} не найден.").format(employee_id)}
)
return CalendarEvent.objects.create(**validated_data)
def update(self, instance: CalendarEvent, validated_data: Dict[str, Any]) -> CalendarEvent:
validated_data.pop("weekdays", None)
# Поддержка calendar_id (новая архитектура)
calendar_id = validated_data.pop("calendar_id", None)
if calendar_id is not None:
from calendar_app.models import Calendar
try:
calendar = Calendar.objects.get(id=calendar_id)
validated_data["calendar"] = calendar
except Calendar.DoesNotExist:
raise serializers.ValidationError(
{"calendar_id": _("Календарь с ID {} не найден.").format(calendar_id)}
)
# Legacy поддержка department_id и employee_id
department_id = validated_data.pop("department_id", None)
employee_id = validated_data.pop("employee_id", None)
if department_id is not None:
from employees.models import Department
try:
department = Department.objects.get(id=department_id)
validated_data["department"] = department
except Department.DoesNotExist:
raise serializers.ValidationError(
{"department_id": _("Отдел с ID {} не найден.").format(department_id)}
)
if employee_id is not None:
from django.contrib.auth import get_user_model
User = get_user_model()
try:
employee = User.objects.get(id=employee_id)
validated_data["employee"] = employee
except User.DoesNotExist:
raise serializers.ValidationError(
{"employee_id": _("Сотрудник с ID {} не найден.").format(employee_id)}
)
for f, v in validated_data.items():
setattr(instance, f, v)
instance.save()
@ -204,3 +281,183 @@ class EventOccurrenceSerializer(serializers.Serializer):
color = serializers.CharField(allow_null=True, required=False)
recurrence = serializers.CharField()
department_id = serializers.IntegerField(allow_null=True, required=False)
# ===== Сериализаторы для новых моделей Calendar и CalendarSubscription =====
class CalendarSerializer(serializers.ModelSerializer):
"""Сериализатор для календаря (чтение)."""
owner_user_name = serializers.SerializerMethodField()
owner_department_name = serializers.SerializerMethodField()
event_count = serializers.IntegerField(read_only=True)
subscriber_count = serializers.IntegerField(read_only=True)
is_subscribed = serializers.SerializerMethodField()
user_can_edit = serializers.SerializerMethodField()
class Meta:
model = Calendar
fields = (
"id",
"title",
"description",
"color",
"icon",
"visibility",
"owner_user",
"owner_user_name",
"owner_department",
"owner_department_name",
"auto_subscribe_new_users",
"auto_subscribe_department_members",
"is_active",
"created_at",
"updated_at",
"event_count",
"subscriber_count",
"is_subscribed",
"user_can_edit",
)
read_only_fields = ("id", "created_at", "updated_at")
def get_owner_user_name(self, obj):
"""Возвращает имя владельца-пользователя."""
if obj.owner_user:
return obj.owner_user.get_full_name() or obj.owner_user.username
return None
def get_owner_department_name(self, obj):
"""Возвращает название отдела-владельца."""
return obj.owner_department.name if obj.owner_department else None
def get_is_subscribed(self, obj):
"""Проверяет, подписан ли текущий пользователь."""
request = self.context.get("request")
if request and request.user.is_authenticated:
return obj.subscriptions.filter(user=request.user).exists()
return False
def get_user_can_edit(self, obj):
"""Проверяет, может ли текущий пользователь редактировать календарь."""
request = self.context.get("request")
if not request or not request.user.is_authenticated:
return False
user = request.user
# Админы могут всё
if user.is_superuser or user.is_staff:
return True
# Владелец может редактировать
if obj.owner_user_id == user.id:
return True
# Проверяем подписку с правом редактирования
subscription = obj.subscriptions.filter(user=user).first()
if subscription and (subscription.can_edit or subscription.can_manage):
return True
return False
class CalendarWriteSerializer(serializers.ModelSerializer):
"""Сериализатор для создания/обновления календаря."""
class Meta:
model = Calendar
fields = (
"id",
"title",
"description",
"color",
"icon",
"visibility",
"owner_user",
"owner_department",
"auto_subscribe_new_users",
"auto_subscribe_department_members",
"is_active",
)
read_only_fields = ("id",)
def validate(self, attrs):
"""Валидация: нельзя одновременно указывать owner_user и owner_department."""
owner_user = attrs.get("owner_user")
owner_department = attrs.get("owner_department")
# Для update нужно учитывать существующие значения
if self.instance:
owner_user = owner_user if "owner_user" in attrs else self.instance.owner_user
owner_department = owner_department if "owner_department" in attrs else self.instance.owner_department
if owner_user and owner_department:
raise serializers.ValidationError(
_("Календарь не может одновременно принадлежать пользователю и отделу.")
)
return attrs
class CalendarSubscriptionSerializer(serializers.ModelSerializer):
"""Сериализатор для подписки на календарь (чтение)."""
calendar_title = serializers.CharField(source="calendar.title", read_only=True)
calendar_color = serializers.CharField(source="calendar.color", read_only=True)
user_name = serializers.SerializerMethodField()
class Meta:
model = CalendarSubscription
fields = (
"id",
"calendar",
"calendar_title",
"calendar_color",
"user",
"user_name",
"is_visible",
"color_override",
"can_edit",
"can_manage",
"notify_on_new_event",
"notify_on_event_change",
"subscribed_at",
)
read_only_fields = ("id", "subscribed_at")
def get_user_name(self, obj):
"""Возвращает имя пользователя."""
return obj.user.get_full_name() or obj.user.username
class CalendarSubscriptionWriteSerializer(serializers.ModelSerializer):
"""Сериализатор для создания/обновления подписки."""
class Meta:
model = CalendarSubscription
fields = (
"id",
"calendar",
"user",
"is_visible",
"color_override",
"can_edit",
"can_manage",
"notify_on_new_event",
"notify_on_event_change",
)
read_only_fields = ("id",)
def validate(self, attrs):
"""Валидация подписки."""
calendar = attrs.get("calendar") or (self.instance.calendar if self.instance else None)
user = attrs.get("user") or (self.instance.user if self.instance else None)
# Проверяем, что пользователь не подписывается на свой личный календарь дважды
if calendar and user and calendar.owner_user_id == user.id:
# Владелец может иметь подписку, но это optional
pass
return attrs

View File

@ -17,6 +17,10 @@ from calendar_app.models import CalendarEvent
from .serializers import (
CalendarEventSerializer,
CalendarEventWriteSerializer,
CalendarSerializer,
CalendarWriteSerializer,
CalendarSubscriptionSerializer,
CalendarSubscriptionWriteSerializer,
)
from calendar_app.services.recurrence import expand_event_occurrences
@ -212,7 +216,7 @@ class CalendarEventsViewSet(ModelViewSet):
# ---------- QuerySet ----------
def get_queryset(self):
"""QS для list фильтруем по компании/отделу/сотруднику, для detail — не фильтруем.
"""QS для list фильтруем по компании/отделу/сотруднику/календарю, для detail — не фильтруем.
Returns:
QuerySet: Набор для текущего действия.
@ -222,19 +226,28 @@ class CalendarEventsViewSet(ModelViewSet):
# 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:
# Личный календарь сотрудника
return qs.filter(employee_id=emp, department__isnull=True)
# Личный календарь сотрудника (legacy)
return qs.filter(employee_id=emp, department__isnull=True, calendar__isnull=True)
elif dep is not None:
# Календарь отдела
return qs.filter(department_id=dep, employee__isnull=True)
# Календарь отдела (legacy)
return qs.filter(department_id=dep, employee__isnull=True, calendar__isnull=True)
else:
# Календарь компании
return qs.filter(department__isnull=True, employee__isnull=True)
# Календарь компании (legacy)
return qs.filter(department__isnull=True, employee__isnull=True, calendar__isnull=True)
# ---------- LIST: материализация повторов ----------
@ -388,3 +401,251 @@ class CalendarEventsViewSet(ModelViewSet):
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 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()

View File

@ -1,7 +1,11 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .calendar.views import CalendarEventsViewSet
from .calendar.views import (
CalendarEventsViewSet,
CalendarViewSet,
CalendarSubscriptionViewSet,
)
from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet,
EmployeeActionViewSet, EmployeeViewSet,
GroupViewSet, PositionViewSet,
@ -19,6 +23,12 @@ app_name = "v1"
router = DefaultRouter()
router.register(r"calendar/events", CalendarEventsViewSet, basename="events")
router.register(r"calendar/calendars", CalendarViewSet, basename="calendars")
router.register(
r"calendar/subscriptions",
CalendarSubscriptionViewSet,
basename="subscriptions",
)
router.register(r"documents", DocumentViewSet, basename="documents")

View File

@ -6,6 +6,7 @@ from datetime import datetime
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -31,6 +32,41 @@ class CalendarVisibility(models.TextChoices):
CUSTOM = "custom", _("Настраиваемый (через права)")
class CalendarManager(models.Manager):
"""Менеджер для модели Calendar с дополнительными методами."""
def get_available_for_user(self, user):
"""Возвращает календари, доступные для просмотра пользователю."""
if user.is_superuser or user.is_staff:
# Админы видят все активные календари
return self.filter(is_active=True)
# Публичные календари
q = Q(visibility=CalendarVisibility.PUBLIC, is_active=True)
# Календари, где пользователь владелец
q |= Q(owner_user=user, is_active=True)
# Календари отделов, где пользователь член
user_departments = user.employee_departments.filter(
is_active=True
).values_list("department_id", flat=True)
q |= Q(
owner_department_id__in=user_departments,
visibility=CalendarVisibility.DEPARTMENT,
is_active=True
)
# Календари с подпиской пользователя
subscribed_calendar_ids = user.calendar_subscriptions.values_list(
"calendar_id", flat=True
)
q |= Q(id__in=subscribed_calendar_ids, is_active=True)
return self.filter(q).distinct()
class Calendar(models.Model):
"""Настраиваемый календарь (опциональный, расширенная функциональность).
@ -111,6 +147,8 @@ class Calendar(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = CalendarManager()
class Meta:
verbose_name = _("Календарь")
verbose_name_plural = _("Календари")
@ -149,6 +187,78 @@ class Calendar(models.Model):
def is_department(self):
"""Календарь отдела."""
return bool(self.owner_department_id)
def is_owner(self, user):
"""Проверяет, является ли пользователь владельцем календаря."""
if self.owner_user_id:
return self.owner_user_id == user.id
if self.owner_department_id:
# Владелец отдела = руководитель отдела
return self.owner_department.head_id == user.id if hasattr(self.owner_department, 'head_id') else False
# Глобальный календарь — владелец только админы
return user.is_superuser or user.is_staff
def can_user_view(self, user):
"""Проверяет, может ли пользователь просматривать календарь."""
if not self.is_active:
return False
if user.is_superuser or user.is_staff:
return True
# Владелец всегда может просматривать
if self.is_owner(user):
return True
# Публичный календарь
if self.visibility == CalendarVisibility.PUBLIC:
return True
# Календарь отдела
if self.visibility == CalendarVisibility.DEPARTMENT and self.owner_department_id:
# Проверяем, является ли пользователь членом отдела
return user.employee_departments.filter(
department_id=self.owner_department_id,
is_active=True
).exists()
# Приватный или настраиваемый — проверяем подписку
if self.visibility in [CalendarVisibility.PRIVATE, CalendarVisibility.CUSTOM]:
return self.subscriptions.filter(user=user).exists()
return False
def can_user_edit(self, user):
"""Проверяет, может ли пользователь редактировать события в календаре."""
if user.is_superuser or user.is_staff:
return True
# Владелец всегда может редактировать
if self.is_owner(user):
return True
# Проверяем подписку с правом редактирования
subscription = self.subscriptions.filter(user=user).first()
if subscription:
return subscription.can_edit
return False
def can_user_manage(self, user):
"""Проверяет, может ли пользователь управлять календарем."""
if user.is_superuser or user.is_staff:
return True
# Владелец всегда может управлять
if self.is_owner(user):
return True
# Проверяем подписку с правом управления
subscription = self.subscriptions.filter(user=user).first()
if subscription:
return subscription.can_manage
return False
class CalendarSubscription(models.Model):