499 lines
19 KiB
Python
499 lines
19 KiB
Python
# 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,
|
||
}
|
||
)
|