2026-09-07 19:43:48 +03:00

499 lines
19 KiB
Python
Raw Permalink 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.

# backend/api/v1/schedule/views.py
"""
ViewSets для django-scheduler (чистый, проверенный код).
Параллельно работают со старым calendar_app.
"""
from datetime import date, datetime, time, timedelta
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import DatabaseError
from django.db.models import Q
from django.utils import timezone
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from schedule.feeds import CalendarICalendar # Правильный импорт
from schedule.models import (
Calendar,
CalendarRelation,
Event,
)
from scheduling.models import CalendarBinding
from scheduling.services import (
get_calendar_binding,
user_can_edit_calendar,
user_can_manage_calendar_notifications,
user_can_manage_participants,
)
from ..permissions import (
IsOwnerOfCalendar,
)
from ..serializers import (
CalendarRelationSerializer,
CalendarSerializer,
)
from .common import _admin_requested_all_scope, _user_accessible_calendar_ids
class ScheduleCalendarViewSet(viewsets.ModelViewSet):
"""CRUD для django-scheduler Calendar с системой прав доступа.
Endpoints:
- GET /api/v1/schedule/calendars/ - список календарей
(только доступные пользователю)
- POST /api/v1/schedule/calendars/ - создать (автоматически owner)
- GET /api/v1/schedule/calendars/{id}/ - детали
- PUT/PATCH /api/v1/schedule/calendars/{id}/ - обновить
- DELETE /api/v1/schedule/calendars/{id}/ - удалить
Дополнительные actions:
- POST /api/v1/schedule/calendars/{id}/add_participant/ - добавить участника
- DELETE /api/v1/schedule/calendars/{id}/remove_participant/
- удалить участника
- GET /api/v1/schedule/calendars/{id}/participants/ - список участников
- GET /api/v1/schedule/calendars/{id}/export_ical/ - экспорт в .ics
"""
queryset = Calendar.objects.select_related(
"binding",
"binding__context_content_type",
).all()
serializer_class = CalendarSerializer
permission_classes = [IsAuthenticated, IsOwnerOfCalendar]
renderer_classes = [JSONRenderer]
pagination_class = None # Отключаем пагинацию - календарей немного, нужны все
NOTIFICATION_FIELDS = (
"notify_event_created",
"notify_event_changed",
"notify_event_cancelled",
)
def get_queryset(self):
"""Фильтрация календарей по доступу пользователя."""
qs = (
super()
.get_queryset()
.select_related(
"binding",
"binding__context_content_type",
)
)
user = self.request.user
show_all_scope = _admin_requested_all_scope(self.request)
# Для detail/admin actions доступ админа остаётся полным.
if (user.is_staff or user.is_superuser) and (
self.action != "list" or show_all_scope
):
filtered = qs
else:
calendar_ids = _user_accessible_calendar_ids(user)
visibility_filter = Q(id__in=calendar_ids)
if self.action not in {"list", "create"}:
visibility_filter |= Q(
binding__type=CalendarBinding.BindingType.DEPARTMENT
)
filtered = qs.filter(visibility_filter)
# Фильтр по имени
name = self.request.query_params.get("name")
if name:
filtered = filtered.filter(name__icontains=name)
return filtered.distinct().order_by("name")
def perform_create(self, serializer):
"""При создании календаря автоматически добавляем creator как owner."""
calendar = serializer.save()
# Создаем CalendarRelation с distinction='owner'
calendar.create_relation(
self.request.user, distinction="owner", inheritable=True
)
CalendarBinding.objects.get_or_create(
calendar=calendar,
defaults={"type": CalendarBinding.BindingType.DEFAULT},
)
@action(detail=True, methods=["patch"], url_path="access-scope")
def access_scope(self, request, pk=None):
"""Изменить общий режим доступа к календарю."""
calendar = self.get_object()
binding, _ = CalendarBinding.objects.get_or_create(
calendar=calendar,
defaults={"type": CalendarBinding.BindingType.DEFAULT},
)
if binding.type == CalendarBinding.BindingType.DEPARTMENT:
return Response(
{"detail": "Доступ к календарю отдела управляется автоматически."},
status=status.HTTP_400_BAD_REQUEST,
)
if not user_can_manage_participants(request.user, calendar):
return Response(
{"detail": "Только владелец может изменять доступ к календарю."},
status=status.HTTP_403_FORBIDDEN,
)
access_scope = request.data.get("access_scope")
allowed_scopes = {
CalendarBinding.AccessScope.ALL,
CalendarBinding.AccessScope.RESTRICTED,
}
if access_scope not in allowed_scopes:
return Response(
{"access_scope": "Выберите доступ для всей компании или участников."},
status=status.HTTP_400_BAD_REQUEST,
)
if binding.access_scope != access_scope:
binding.access_scope = access_scope
binding.save(update_fields=["access_scope", "updated_at"])
calendar.refresh_from_db()
return Response(self.get_serializer(calendar).data)
@action(
detail=True,
methods=["get", "patch"],
url_path="notification-settings",
)
def notification_settings(self, request, pk=None):
"""Получить или изменить общие правила уведомлений календаря."""
calendar = self.get_object()
binding, _ = CalendarBinding.objects.get_or_create(
calendar=calendar,
defaults={"type": CalendarBinding.BindingType.DEFAULT},
)
can_manage = user_can_manage_calendar_notifications(request.user, calendar)
if request.method == "PATCH":
if not can_manage:
return Response(
{"detail": "Недостаточно прав для настройки уведомлений."},
status=status.HTTP_403_FORBIDDEN,
)
changed_fields = []
for field in self.NOTIFICATION_FIELDS:
if field not in request.data:
continue
value = request.data[field]
if not isinstance(value, bool):
return Response(
{field: "Значение должно быть логическим."},
status=status.HTTP_400_BAD_REQUEST,
)
if getattr(binding, field) != value:
setattr(binding, field, value)
changed_fields.append(field)
if changed_fields:
binding.save(update_fields=[*changed_fields, "updated_at"])
payload = {field: getattr(binding, field) for field in self.NOTIFICATION_FIELDS}
payload["can_manage"] = can_manage
return Response(payload)
@action(detail=True, methods=["post"], url_path="add-participant")
def add_participant(self, request, pk=None):
"""Добавить участника к календарю.
Body:
{
"user_id": 123,
"role": "viewer" # owner, editor, viewer
}
"""
calendar = self.get_object()
if (
get_calendar_binding(calendar)
and get_calendar_binding(calendar).type
== CalendarBinding.BindingType.DEPARTMENT
):
return Response(
{
"detail": (
"Участники календаря отдела синхронизируются автоматически."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
# Проверка прав (только owner может добавлять участников)
if not user_can_manage_participants(request.user, calendar):
return Response(
{"detail": "Только владелец может добавлять участников"},
status=status.HTTP_403_FORBIDDEN,
)
serializer = CalendarRelationSerializer(
data={
"calendar": calendar.id,
"user_id": request.data.get("user_id"),
"distinction": request.data.get("role", "viewer"),
"inheritable": True,
}
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(
detail=True,
methods=["delete"],
url_path="remove-participant/(?P<user_id>[0-9]+)",
)
def remove_participant(self, request, pk=None, user_id=None):
"""Удалить участника из календаря."""
calendar = self.get_object()
if (
get_calendar_binding(calendar)
and get_calendar_binding(calendar).type
== CalendarBinding.BindingType.DEPARTMENT
):
return Response(
{
"detail": (
"Участники календаря отдела синхронизируются автоматически."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
# Проверка прав
if not user_can_manage_participants(request.user, calendar):
return Response(
{"detail": "Только владелец может удалять участников"},
status=status.HTTP_403_FORBIDDEN,
)
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
# Нельзя удалить owner
relation = CalendarRelation.objects.filter(
calendar=calendar, content_type=ct, object_id=user_id
).first()
if not relation:
return Response(
{"detail": "Участник не найден"},
status=status.HTTP_404_NOT_FOUND,
)
if relation.distinction == "owner":
return Response(
{"detail": "Нельзя удалить владельца календаря"},
status=status.HTTP_400_BAD_REQUEST,
)
relation.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(
detail=True,
methods=["patch"],
url_path="participant-role/(?P<user_id>[0-9]+)",
)
def participant_role(self, request, pk=None, user_id=None):
"""Изменить роль существующего участника календаря."""
calendar = self.get_object()
binding = get_calendar_binding(calendar)
if binding and binding.type == CalendarBinding.BindingType.DEPARTMENT:
return Response(
{"detail": "Роли календаря отдела синхронизируются автоматически."},
status=status.HTTP_400_BAD_REQUEST,
)
if not user_can_manage_participants(request.user, calendar):
return Response(
{"detail": "Только владелец может изменять роли участников."},
status=status.HTTP_403_FORBIDDEN,
)
role = request.data.get("role")
if role not in {"owner", "editor", "viewer"}:
return Response(
{"role": "Выберите роль владельца, редактора или просмотра."},
status=status.HTTP_400_BAD_REQUEST,
)
from django.contrib.auth import get_user_model
user_ct = ContentType.objects.get_for_model(get_user_model())
relations = CalendarRelation.objects.filter(
calendar=calendar,
content_type=user_ct,
object_id=user_id,
).order_by("id")
relation = relations.first()
if relation is None:
return Response(
{"detail": "Участник не найден."},
status=status.HTTP_404_NOT_FOUND,
)
if relation.distinction == "owner" and role != "owner":
other_owners_exist = (
CalendarRelation.objects.filter(
calendar=calendar,
content_type=user_ct,
distinction="owner",
)
.exclude(object_id=user_id)
.exists()
)
if not other_owners_exist:
return Response(
{"role": "У календаря должен остаться хотя бы один владелец."},
status=status.HTTP_400_BAD_REQUEST,
)
relation.distinction = role
relation.inheritable = True
relation.save(update_fields=["distinction", "inheritable"])
relations.exclude(pk=relation.pk).delete()
return Response(CalendarRelationSerializer(relation).data)
@action(detail=True, methods=["get"])
def participants(self, request, pk=None):
"""Получить список всех участников календаря."""
calendar = self.get_object()
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
relations = CalendarRelation.objects.filter(calendar=calendar, content_type=ct)
serializer = CalendarRelationSerializer(relations, many=True)
return Response(serializer.data)
@action(detail=True, methods=["get"], url_path="export-ical")
def export_ical(self, request, pk=None):
"""Экспорт календаря в формат iCalendar (.ics).
Использует встроенный CalendarICalendar из django-scheduler.
"""
calendar = self.get_object()
# Создаем feed для экспорта
feed = CalendarICalendar()
response = feed(request, pk)
# Устанавливает имя файла
response["Content-Disposition"] = f'attachment; filename="{calendar.slug}.ics"'
return response
@staticmethod
def _ical_datetime(value):
if isinstance(value, datetime):
result = value
elif isinstance(value, date):
result = datetime.combine(value, time.min)
else:
raise ValueError("Некорректная дата события")
return timezone.make_aware(result) if timezone.is_naive(result) else result
@classmethod
def _create_ical_event(cls, calendar, component, creator):
start_property = component.get("dtstart")
if not start_property:
return None
start_at = cls._ical_datetime(start_property.dt)
end_property = component.get("dtend")
end_at = (
cls._ical_datetime(end_property.dt)
if end_property
else start_at + timedelta(hours=1)
)
return Event.objects.create(
calendar=calendar,
title=str(component.get("summary", "")) or "Без названия",
description=str(component.get("description", "")),
start=start_at,
end=end_at,
creator=creator,
)
@action(detail=True, methods=["post"], url_path="import-ical")
def import_ical(self, request, pk=None):
"""Импорт событий из файла iCalendar (.ics) в календарь.
Ожидает файл в request.FILES['file'].
"""
from icalendar import Calendar as ICalendar
calendar = self.get_object()
# Проверка, что пользователь - владелец
if not user_can_edit_calendar(request.user, calendar):
return Response(
{"error": ("Только владелец может импортировать события в календарь")},
status=status.HTTP_403_FORBIDDEN,
)
# Проверка наличия файла
if "file" not in request.FILES:
return Response(
{"error": 'Файл не найден. Отправьте файл с ключом "file"'},
status=status.HTTP_400_BAD_REQUEST,
)
ics_file = request.FILES["file"]
try:
ical = ICalendar.from_ical(ics_file.read())
except (OSError, TypeError, UnicodeError, ValueError) as exc:
return Response(
{"error": f"Ошибка парсинга файла: {exc!s}"},
status=status.HTTP_400_BAD_REQUEST,
)
imported_count = 0
skipped_count = 0
errors = []
for component in ical.walk("VEVENT"):
summary = str(component.get("summary", ""))
try:
event = self._create_ical_event(
calendar,
component,
request.user,
)
if event is None:
skipped_count += 1
else:
imported_count += 1
except (
DatabaseError,
DjangoValidationError,
OSError,
TypeError,
ValueError,
) as exc:
errors.append(f"Ошибка импорта события '{summary}': {exc!s}")
skipped_count += 1
return Response(
{
"success": True,
"imported": imported_count,
"skipped": skipped_count,
"errors": errors or None,
}
)