# 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[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[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, } )