diff --git a/backend/api/v1/schedule/serializers.py b/backend/api/v1/schedule/serializers.py index bfc6c1a6..580a1691 100644 --- a/backend/api/v1/schedule/serializers.py +++ b/backend/api/v1/schedule/serializers.py @@ -4,21 +4,72 @@ Serializers для django-scheduler. Чистые, проверенные временем модели. """ from rest_framework import serializers -from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation +from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation, CalendarRelation +from django.contrib.contenttypes.models import ContentType import pytz +class CalendarRelationSerializer(serializers.ModelSerializer): + """Сериализатор для CalendarRelation (участники календаря).""" + + user_id = serializers.IntegerField(write_only=True, required=False) + user = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = CalendarRelation + fields = ['id', 'calendar', 'user_id', 'user', 'distinction', 'inheritable'] + read_only_fields = ['id'] + + def get_user(self, obj): + """Информация о связанном пользователе.""" + if obj.content_object: + return { + 'id': obj.content_object.id, + 'username': getattr(obj.content_object, 'username', str(obj.content_object)), + 'first_name': getattr(obj.content_object, 'first_name', ''), + 'last_name': getattr(obj.content_object, 'last_name', ''), + } + return None + + def create(self, validated_data): + """Создать связь календаря с пользователем.""" + from django.contrib.auth import get_user_model + User = get_user_model() + + user_id = validated_data.pop('user_id') + calendar = validated_data['calendar'] + distinction = validated_data.get('distinction', 'viewer') + inheritable = validated_data.get('inheritable', True) + + user = User.objects.get(id=user_id) + + # Используем встроенный метод create_relation + calendar.create_relation(user, distinction=distinction, inheritable=inheritable) + + # Возвращаем созданную relation + ct = ContentType.objects.get_for_model(User) + return CalendarRelation.objects.get( + calendar=calendar, + content_type=ct, + object_id=user_id, + distinction=distinction + ) + + class CalendarSerializer(serializers.ModelSerializer): """Сериализатор для Calendar (django-scheduler) с совместимостью EUSRR.""" # Совместимость с EUSRR API - принимаем title, конвертируем в name title = serializers.CharField(source='name', required=False) events_count = serializers.SerializerMethodField() + owner = serializers.SerializerMethodField() + participants = serializers.SerializerMethodField() + user_role = serializers.SerializerMethodField() class Meta: model = Calendar - fields = ['id', 'name', 'title', 'slug', 'events_count'] - read_only_fields = ['id'] + fields = ['id', 'name', 'title', 'slug', 'events_count', 'owner', 'participants', 'user_role'] + read_only_fields = ['id', 'owner', 'participants', 'user_role'] extra_kwargs = { 'slug': {'required': False}, # Сделаем auto-generated если не указан } @@ -27,6 +78,69 @@ class CalendarSerializer(serializers.ModelSerializer): """Количество событий в календаре.""" return obj.event_set.count() + def get_owner(self, obj): + """Получить владельца календаря.""" + try: + 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=obj, + content_type=ct, + distinction='owner' + ).first() + + if owner_relation and owner_relation.content_object: + return { + 'id': owner_relation.content_object.id, + 'username': getattr(owner_relation.content_object, 'username', str(owner_relation.content_object)), + 'full_name': str(owner_relation.content_object), + } + except Exception: + pass + return None + + def get_participants(self, obj): + """Список всех участников календаря.""" + from django.contrib.auth import get_user_model + User = get_user_model() + ct = ContentType.objects.get_for_model(User) + + relations = CalendarRelation.objects.filter( + calendar=obj, + content_type=ct + ).select_related('content_type') + + participants = [] + for rel in relations: + if rel.content_object: + participants.append({ + 'id': rel.content_object.id, + 'username': getattr(rel.content_object, 'username', str(rel.content_object)), + 'full_name': str(rel.content_object), + 'role': rel.distinction, + }) + return participants + + def get_user_role(self, obj): + """Роль текущего пользователя для этого календаря.""" + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return None + + from django.contrib.auth import get_user_model + User = get_user_model() + ct = ContentType.objects.get_for_model(User) + + relation = CalendarRelation.objects.filter( + calendar=obj, + content_type=ct, + object_id=request.user.id + ).first() + + return relation.distinction if relation else None + def create(self, validated_data): """Создать календарь с автогенерацией slug из name/title.""" from django.utils.text import slugify @@ -53,10 +167,39 @@ class CalendarSerializer(serializers.ModelSerializer): class RuleSerializer(serializers.ModelSerializer): """Сериализатор для Rule (rrule RFC 5545).""" + # Удобные поля для frontend + frequency_display = serializers.CharField(source='get_frequency_display', read_only=True) + class Meta: model = Rule - fields = ['id', 'name', 'description', 'frequency', 'params'] - read_only_fields = ['id'] + fields = ['id', 'name', 'description', 'frequency', 'frequency_display', 'params'] + read_only_fields = ['id', 'frequency_display'] + + def to_representation(self, instance): + """Десериализация params из JSON-строки в dict.""" + import json + data = super().to_representation(instance) + + # Парсим params из строки в объект для frontend + if data.get('params'): + try: + if isinstance(data['params'], str): + data['params'] = json.loads(data['params']) + except (json.JSONDecodeError, TypeError): + data['params'] = {} + else: + data['params'] = {} + + return data + + def validate_frequency(self, value): + """Валидация frequency.""" + valid_frequencies = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY'] + if value not in valid_frequencies: + raise serializers.ValidationError( + f'Invalid frequency. Must be one of: {", ".join(valid_frequencies)}' + ) + return value class EventSerializer(serializers.ModelSerializer): @@ -105,11 +248,14 @@ class EventListSerializer(EventSerializer): fields = [ 'id', 'title', + 'description', 'start', 'end', 'calendar', 'calendar_name', 'color_event', + 'rule', + 'end_recurring_period', ] @@ -144,6 +290,10 @@ class EventRelationSerializer(serializers.ModelSerializer): event_title = serializers.CharField(source='event.title', read_only=True) user_name = serializers.SerializerMethodField() + content_type = serializers.PrimaryKeyRelatedField( + queryset=ContentType.objects.all(), + required=False # Делаем необязательным + ) class Meta: model = EventRelation @@ -153,15 +303,21 @@ class EventRelationSerializer(serializers.ModelSerializer): 'event_title', 'content_type', 'object_id', - 'content_object', - 'user_name', 'distinction', - 'created_on', + 'user_name', ] - read_only_fields = ['id', 'created_on'] + read_only_fields = ['id'] def get_user_name(self, obj): """Имя связанного объекта.""" if obj.content_object: return str(obj.content_object) return None + + def create(self, validated_data): + """Автоматически устанавливает content_type для User.""" + if 'content_type' not in validated_data: + from django.contrib.auth import get_user_model + User = get_user_model() + validated_data['content_type'] = ContentType.objects.get_for_model(User) + return super().create(validated_data) diff --git a/backend/api/v1/schedule/views.py b/backend/api/v1/schedule/views.py index 0f4a07c5..001352d7 100644 --- a/backend/api/v1/schedule/views.py +++ b/backend/api/v1/schedule/views.py @@ -8,15 +8,20 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.renderers import JSONRenderer +from django.http import HttpResponse +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q -from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation +from schedule.models import Calendar, Event, Rule, Occurrence, EventRelation, CalendarRelation from schedule.periods import Period +from schedule.feeds import CalendarICalendar # Правильный импорт from datetime import datetime from django.utils.dateparse import parse_datetime from .serializers import ( CalendarSerializer, + CalendarRelationSerializer, EventSerializer, EventListSerializer, RuleSerializer, @@ -26,27 +31,47 @@ from .serializers import ( class ScheduleCalendarViewSet(viewsets.ModelViewSet): - """CRUD для django-scheduler Calendar. + """CRUD для django-scheduler Calendar с системой прав доступа. Endpoints: - - GET /api/v1/schedule/calendars/ - список - - POST /api/v1/schedule/calendars/ - создать + - 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}/ - удалить - Note: Использует только IsAuthenticated, без DjangoModelPermissions, - так как все авторизованные пользователи могут управлять календарями. + Дополнительные 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.all() serializer_class = CalendarSerializer - permission_classes = [IsAuthenticated] # Переопределяем глобальные настройки + permission_classes = [IsAuthenticated] renderer_classes = [JSONRenderer] def get_queryset(self): - """Фильтрация календарей.""" + """Фильтрация календарей по доступу пользователя.""" + from django.contrib.auth import get_user_model + User = get_user_model() + qs = super().get_queryset() + user = self.request.user + + # Админы видят все + if user.is_staff or user.is_superuser: + return qs.order_by('name') + + # Остальные видят только календари, где они участники + ct = ContentType.objects.get_for_model(User) + calendar_ids = CalendarRelation.objects.filter( + content_type=ct, + object_id=user.id + ).values_list('calendar_id', flat=True) + + qs = qs.filter(id__in=calendar_ids) # Фильтр по имени name = self.request.query_params.get('name') @@ -55,12 +80,221 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet): return qs.order_by('name') - def destroy(self, request, *args, **kwargs): - """Удаление календаря с логированием.""" - print(f"[DELETE Calendar] User: {request.user}, Calendar ID: {kwargs.get('pk')}") - print(f"[DELETE Calendar] Is authenticated: {request.user.is_authenticated}") - print(f"[DELETE Calendar] Auth header: {request.META.get('HTTP_AUTHORIZATION', 'Not present')[:100]}") - return super().destroy(request, *args, **kwargs) + def perform_create(self, serializer): + """При создании календаря автоматически добавляем creator как owner.""" + calendar = serializer.save() + # Создаем CalendarRelation с distinction='owner' + calendar.create_relation(self.request.user, distinction='owner', inheritable=True) + + @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() + + # Проверка прав (только owner может добавлять участников) + if not self._is_owner(calendar, request.user): + 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 not self._is_owner(calendar, request.user): + 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=['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 + + @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 + from django.utils import timezone + + calendar = self.get_object() + + # Проверка, что пользователь - владелец + if not self._is_owner(calendar, request.user): + 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: + # Парсим iCalendar файл + ical = ICalendar.from_ical(ics_file.read()) + + imported_count = 0 + skipped_count = 0 + errors = [] + + # Проходим по всем событиям в файле + for component in ical.walk(): + if component.name == "VEVENT": + try: + # Извлекаем данные события + summary = str(component.get('summary', '')) + description = str(component.get('description', '')) + dtstart = component.get('dtstart') + dtend = component.get('dtend') + + if not dtstart: + skipped_count += 1 + continue + + # Преобразуем даты + start_dt = dtstart.dt + if not timezone.is_aware(start_dt): + start_dt = timezone.make_aware(start_dt) + + if dtend: + end_dt = dtend.dt + if not timezone.is_aware(end_dt): + end_dt = timezone.make_aware(end_dt) + else: + # Если нет времени окончания, делаем событие длиной 1 час + from datetime import timedelta + end_dt = start_dt + timedelta(hours=1) + + # Создаем событие + Event.objects.create( + calendar=calendar, + title=summary or 'Без названия', + description=description, + start=start_dt, + end=end_dt, + creator=request.user + ) + imported_count += 1 + + except Exception as e: + errors.append(f"Ошибка импорта события '{summary}': {str(e)}") + skipped_count += 1 + + return Response({ + 'success': True, + 'imported': imported_count, + 'skipped': skipped_count, + 'errors': errors if errors else None + }) + + except Exception as e: + return Response( + {'error': f'Ошибка парсинга файла: {str(e)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + def _is_owner(self, calendar, user): + """Проверка, является ли пользователь владельцем календаря.""" + if user.is_staff or user.is_superuser: + return True + + from django.contrib.auth import get_user_model + User = get_user_model() + ct = ContentType.objects.get_for_model(User) + + return CalendarRelation.objects.filter( + calendar=calendar, + content_type=ct, + object_id=user.id, + distinction='owner' + ).exists() class ScheduleEventViewSet(viewsets.ModelViewSet): @@ -155,7 +389,7 @@ class ScheduleEventViewSet(viewsets.ModelViewSet): ) # Используем schedule.periods.Period для получения вхождений - events = calendar.event_set.all() + events = Event.objects.filter(calendar=calendar) period = Period(events, start_dt, end_dt) # Результат @@ -164,11 +398,14 @@ class ScheduleEventViewSet(viewsets.ModelViewSet): occurrences.append({ 'id': occurrence.event.id, 'title': occurrence.title or occurrence.event.title, + 'description': occurrence.event.description, 'start': occurrence.start.isoformat(), 'end': occurrence.end.isoformat(), 'color_event': occurrence.event.color_event, 'event_id': occurrence.event.id, 'is_recurring': occurrence.event.rule_id is not None, + 'rule': occurrence.event.rule_id, + 'end_recurring_period': occurrence.event.end_recurring_period.isoformat() if occurrence.event.end_recurring_period else None, }) return Response(occurrences) diff --git a/backend/employees/management/commands/create_test_calendar_users.py b/backend/employees/management/commands/create_test_calendar_users.py index 522f9c01..6f6224ac 100644 --- a/backend/employees/management/commands/create_test_calendar_users.py +++ b/backend/employees/management/commands/create_test_calendar_users.py @@ -14,7 +14,61 @@ User = get_user_model() class Command(BaseCommand): help = 'Создание тестовых пользователей и календарей для системы schedule' + def add_arguments(self, parser): + parser.add_argument( + '--clean', + action='store_true', + help='Удалить существующих тестовых пользователей и календари перед созданием новых', + ) + def handle(self, *args, **options): + # Удаление существующих данных если указан флаг --clean + if options['clean']: + self.stdout.write(self.style.WARNING('\nУдаление существующих тестовых данных...\n')) + + # Список тестовых пользователей для удаления + test_usernames = ['anna_ivanova', 'petr_petrov', 'maria_sidorova'] + + for username in test_usernames: + try: + user = User.objects.get(username=username) + # Сначала удаляем события в календарях пользователя + from schedule.models import CalendarRelation + from django.contrib.contenttypes.models import ContentType + + ct = ContentType.objects.get_for_model(User) + calendar_relations = CalendarRelation.objects.filter( + content_type=ct, + object_id=user.id, + distinction='owner' + ) + + for rel in calendar_relations: + calendar = rel.calendar + events_count = calendar.event_set.count() + calendar.event_set.all().delete() + calendar.delete() + self.stdout.write(f" Удален календарь: {calendar.name} ({events_count} событий)") + + user.delete() + self.stdout.write(self.style.SUCCESS(f" Удален пользователь: {username}")) + except User.DoesNotExist: + self.stdout.write(f" Пользователь {username} не найден (пропуск)") + + # Удаляем календари по slug (если они остались) + calendar_slugs = ['anna-work', 'anna-personal', 'petr-projects', 'petr-meetings', 'maria-calendar'] + for slug in calendar_slugs: + try: + calendar = Calendar.objects.get(slug=slug) + events_count = calendar.event_set.count() + calendar.event_set.all().delete() + calendar.delete() + self.stdout.write(f" Удален календарь: {slug} ({events_count} событий)") + except Calendar.DoesNotExist: + pass + + self.stdout.write(self.style.SUCCESS('\nОчистка завершена!\n')) + self.stdout.write('Создание тестовых пользователей и календарей...\n') # Создание пользователей @@ -58,6 +112,7 @@ class Command(BaseCommand): ) if created: user.set_password(user_data['password']) + user.email_verified = True # Автоматически подтверждаем email для тестовых пользователей user.save() self.stdout.write(self.style.SUCCESS( f"Создан пользователь: {user.get_full_name()} ({user.username})" @@ -68,13 +123,13 @@ class Command(BaseCommand): )) created_users.append(user) - # Создание календарей + # Создание календарей с привязкой к владельцам calendar_data = [ - {'name': 'Рабочий календарь Анны', 'slug': 'anna-work'}, - {'name': 'Личные дела Анны', 'slug': 'anna-personal'}, - {'name': 'Проекты Петра', 'slug': 'petr-projects'}, - {'name': 'Встречи Петра', 'slug': 'petr-meetings'}, - {'name': 'Календарь Марии', 'slug': 'maria-calendar'}, + {'name': 'Рабочий календарь Анны', 'slug': 'anna-work', 'owner_idx': 0}, # anna_ivanova + {'name': 'Личные дела Анны', 'slug': 'anna-personal', 'owner_idx': 0}, + {'name': 'Проекты Петра', 'slug': 'petr-projects', 'owner_idx': 1}, # petr_petrov + {'name': 'Встречи Петра', 'slug': 'petr-meetings', 'owner_idx': 1}, + {'name': 'Календарь Марии', 'slug': 'maria-calendar', 'owner_idx': 2}, # maria_sidorova ] created_calendars = [] @@ -85,6 +140,10 @@ class Command(BaseCommand): ) if created: self.stdout.write(self.style.SUCCESS(f"Создан календарь: {calendar.name}")) + # Создаем CalendarRelation с owner + owner = created_users[cal_data['owner_idx']] + calendar.create_relation(owner, distinction='owner', inheritable=True) + self.stdout.write(f" Владелец: {owner.get_full_name()}") else: self.stdout.write(self.style.WARNING(f"Календарь уже существует: {calendar.name}")) created_calendars.append(calendar) diff --git a/backend/tests/test_schedule_permissions.py b/backend/tests/test_schedule_permissions.py new file mode 100644 index 00000000..296e5e60 --- /dev/null +++ b/backend/tests/test_schedule_permissions.py @@ -0,0 +1,510 @@ +# backend/tests/test_schedule_permissions.py +""" +Тесты для системы прав доступа календарей django-scheduler. +""" +import pytest +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status +from schedule.models import Calendar, CalendarRelation, Event, Rule + +User = get_user_model() + + +@pytest.fixture +def api_client(): + """API клиент для тестов.""" + return APIClient() + + +@pytest.fixture +def user1(db): + """Первый тестовый пользователь.""" + return User.objects.create_user( + username='user1', + email='user1@example.com', + password='testpass123', + first_name='User', + last_name='One', + phone_number='+79991234567' # Правильное имя параметра + ) + + +@pytest.fixture +def user2(db): + """Второй тестовый пользователь.""" + return User.objects.create_user( + username='user2', + email='user2@example.com', + password='testpass123', + first_name='User', + last_name='Two', + phone_number='+79991234568' + ) + + +@pytest.fixture +def user3(db): + """Третий тестовый пользователь.""" + return User.objects.create_user( + username='user3', + email='user3@example.com', + password='testpass123', + first_name='User', + last_name='Three', + phone_number='+79991234569' + ) + + +@pytest.mark.django_db +class TestCalendarPermissions: + """Тесты прав доступа к календарям.""" + + def test_create_calendar_creates_owner_relation(self, api_client, user1): + """При создании календаря автоматически создается owner relation.""" + api_client.force_authenticate(user=user1) + + response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + + assert response.status_code == status.HTTP_201_CREATED + calendar_id = response.data['id'] + + # Проверяем, что создался CalendarRelation с owner + ct = ContentType.objects.get_for_model(User) + relation = CalendarRelation.objects.get( + calendar_id=calendar_id, + content_type=ct, + object_id=user1.id + ) + + assert relation.distinction == 'owner' + assert relation.inheritable is True + + def test_get_calendars_shows_only_accessible(self, api_client, user1, user2): + """Пользователь видит только свои календари.""" + api_client.force_authenticate(user=user1) + + # user1 создает календарь + response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'User1 Calendar', + }) + assert response.status_code == status.HTTP_201_CREATED + + # user2 создает свой календарь + api_client.force_authenticate(user=user2) + response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'User2 Calendar', + }) + assert response.status_code == status.HTTP_201_CREATED + + # user1 видит только свой календарь + api_client.force_authenticate(user=user1) + response = api_client.get('/api/v1/schedule/calendars/') + assert response.status_code == status.HTTP_200_OK + + if isinstance(response.data, dict) and 'results' in response.data: + calendars = response.data['results'] + else: + calendars = response.data + + assert len(calendars) == 1 + assert calendars[0]['name'] == 'User1 Calendar' + + def test_add_participant_to_calendar(self, api_client, user1, user2): + """Owner может добавить участника к календарю.""" + api_client.force_authenticate(user=user1) + + # Создаем календарь + response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Shared Calendar', + }) + calendar_id = response.data['id'] + + # Добавляем user2 как viewer + response = api_client.post( + f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', + {'user_id': user2.id, 'role': 'viewer'} + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['user_info']['id'] == user2.id + assert response.data['distinction'] == 'viewer' + + # Теперь user2 должен видеть календарь + api_client.force_authenticate(user=user2) + response = api_client.get('/api/v1/schedule/calendars/') + + if isinstance(response.data, dict) and 'results' in response.data: + calendars = response.data['results'] + else: + calendars = response.data + + assert len(calendars) == 1 + assert calendars[0]['id'] == calendar_id + + def test_non_owner_cannot_add_participant(self, api_client, user1, user2, user3): + """Не-owner не может добавлять участников.""" + api_client.force_authenticate(user=user1) + + # user1 создает календарь + response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + calendar_id = response.data['id'] + + # Добавляем user2 как editor + api_client.post( + f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', + {'user_id': user2.id, 'role': 'editor'} + ) + + # user2 пытается добавить user3 - должно быть запрещено + api_client.force_authenticate(user=user2) + response = api_client.post( + f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', + {'user_id': user3.id, 'role': 'viewer'} + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_remove_participant_from_calendar(self, api_client, user1, user2): + """Owner может удалить участника из календаря.""" + api_client.force_authenticate(user=user1) + + # Создаем календарь и добавляем user2 + response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + calendar_id = response.data['id'] + + api_client.post( + f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', + {'user_id': user2.id, 'role': 'viewer'} + ) + + # Удаляем user2 + response = api_client.delete( + f'/api/v1/schedule/calendars/{calendar_id}/remove-participant/{user2.id}/' + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + # user2 больше не видит календарь + api_client.force_authenticate(user=user2) + response = api_client.get('/api/v1/schedule/calendars/') + + if isinstance(response.data, dict) and 'results' in response.data: + calendars = response.data['results'] + else: + calendars = response.data + + assert len(calendars) == 0 + + def test_cannot_remove_owner(self, api_client, user1): + """Нельзя удалить owner из календаря.""" + api_client.force_authenticate(user=user1) + + # Создаем календарь + response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + calendar_id = response.data['id'] + + # Пытаемся удалить себя (owner) + response = api_client.delete( + f'/api/v1/schedule/calendars/{calendar_id}/remove-participant/{user1.id}/' + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_list_participants(self, api_client, user1, user2, user3): + """Получение списка участников календаря.""" + api_client.force_authenticate(user=user1) + + # Создаем календарь + response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + calendar_id = response.data['id'] + + # Добавляем участников + api_client.post( + f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', + {'user_id': user2.id, 'role': 'editor'} + ) + api_client.post( + f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', + {'user_id': user3.id, 'role': 'viewer'} + ) + + # Получаем список участников + response = api_client.get( + f'/api/v1/schedule/calendars/{calendar_id}/participants/' + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 3 # owner + 2 участника + + # Проверяем роли + roles = {p['user_info']['id']: p['distinction'] for p in response.data} + assert roles[user1.id] == 'owner' + assert roles[user2.id] == 'editor' + assert roles[user3.id] == 'viewer' + + +@pytest.mark.django_db +class TestRecurringEvents: + """Тесты повторяющихся событий.""" + + def test_create_rule(self, api_client, user1): + """Создание правила повторения.""" + api_client.force_authenticate(user=user1) + + response = api_client.post('/api/v1/schedule/rules/', { + 'name': 'Weekly', + 'description': 'Every week on Monday', + 'frequency': 'WEEKLY', + 'params': 'byweekday:0' # Monday + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['frequency'] == 'WEEKLY' + assert response.data['frequency_display'] == 'Еженедельно' # Django локализация + + def test_create_recurring_event(self, api_client, user1): + """Создание события с повторением.""" + api_client.force_authenticate(user=user1) + + # Создаем календарь + cal_response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + calendar_id = cal_response.data['id'] + + # Создаем правило + rule_response = api_client.post('/api/v1/schedule/rules/', { + 'name': 'Daily', + 'description': 'Every day', + 'frequency': 'DAILY', + 'params': '' + }) + rule_id = rule_response.data['id'] + + # Создаем событие с правилом + from datetime import datetime, timedelta + from django.utils import timezone + + start = timezone.now() + end = start + timedelta(hours=1) + end_recurring = start + timedelta(days=30) + + response = api_client.post('/api/v1/schedule/events/', { + 'title': 'Daily Meeting', + 'description': 'Team standup', + 'start': start.isoformat(), + 'end': end.isoformat(), + 'calendar': calendar_id, + 'rule': rule_id, + 'end_recurring_period': end_recurring.isoformat(), + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['rule'] == rule_id + assert response.data['end_recurring_period'] is not None + + def test_get_occurrences(self, api_client, user1): + """Получение материализованных вхождений для recurring события.""" + api_client.force_authenticate(user=user1) + + # Создаем календарь + cal_response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + calendar_id = cal_response.data['id'] + + # Создаем правило - еженедельно + rule_response = api_client.post('/api/v1/schedule/rules/', { + 'name': 'Weekly', + 'description': 'Every week', + 'frequency': 'WEEKLY', + 'params': '' + }) + rule_id = rule_response.data['id'] + + # Создаем событие + from datetime import datetime, timedelta + from django.utils import timezone + + start = timezone.now() + end = start + timedelta(hours=1) + end_recurring = start + timedelta(weeks=4) + + api_client.post('/api/v1/schedule/events/', { + 'title': 'Weekly Meeting', + 'start': start.isoformat(), + 'end': end.isoformat(), + 'calendar': calendar_id, + 'rule': rule_id, + 'end_recurring_period': end_recurring.isoformat(), + }) + + # Получаем occurrences + response = api_client.get( + '/api/v1/schedule/events/occurrences/', + { + 'calendar': calendar_id, + 'start': start.isoformat(), + 'end': (start + timedelta(weeks=5)).isoformat(), + } + ) + + assert response.status_code == status.HTTP_200_OK + # Должно быть ~4 вхождения (еженедельно в течение 4 недель) + assert len(response.data) >= 4 + + +@pytest.mark.django_db +class TestiCalendarExport: + """Тесты экспорта в iCalendar формат.""" + + def test_export_calendar_to_ics(self, api_client, user1): + """Экспорт календаря в .ics файл.""" + api_client.force_authenticate(user=user1) + + # Создаем календарь + cal_response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + calendar_id = cal_response.data['id'] + + # Добавляем событие + from datetime import datetime, timedelta + from django.utils import timezone + + start = timezone.now() + end = start + timedelta(hours=2) + + api_client.post('/api/v1/schedule/events/', { + 'title': 'Test Event', + 'description': 'Test Description', + 'start': start.isoformat(), + 'end': end.isoformat(), + 'calendar': calendar_id, + }) + + # Экспортируем календарь + response = api_client.get( + f'/api/v1/schedule/calendars/{calendar_id}/export-ical/' + ) + + assert response.status_code == status.HTTP_200_OK + assert response['Content-Type'] == 'text/calendar' + assert 'Content-Disposition' in response + assert '.ics' in response['Content-Disposition'] + + # Проверяем содержимое + content = response.content.decode('utf-8') + assert 'BEGIN:VCALENDAR' in content + assert 'BEGIN:VEVENT' in content + assert 'Test Event' in content + assert 'END:VEVENT' in content + assert 'END:VCALENDAR' in content + + +@pytest.mark.django_db +class TestEventRelations: + """Тесты участников событий.""" + + def test_add_participant_to_event(self, api_client, user1, user2): + """Добавление участника к событию.""" + api_client.force_authenticate(user=user1) + + # Создаем календарь + cal_response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + calendar_id = cal_response.data['id'] + + # Создаем событие + from datetime import datetime, timedelta + from django.utils import timezone + + start = timezone.now() + end = start + timedelta(hours=1) + + event_response = api_client.post('/api/v1/schedule/events/', { + 'title': 'Meeting', + 'start': start.isoformat(), + 'end': end.isoformat(), + 'calendar': calendar_id, + }) + event_id = event_response.data['id'] + + # Добавляем user2 как участника + ct = ContentType.objects.get_for_model(User) + response = api_client.post('/api/v1/schedule/relations/', { + 'event': event_id, + 'content_type': ct.id, + 'object_id': user2.id, + 'distinction': 'attendee', + }) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['event'] == event_id + + def test_list_event_participants(self, api_client, user1, user2, user3): + """Получение списка участников события.""" + api_client.force_authenticate(user=user1) + + # Создаем календарь и событие + cal_response = api_client.post('/api/v1/schedule/calendars/', { + 'name': 'Test Calendar', + }) + calendar_id = cal_response.data['id'] + + from datetime import datetime, timedelta + from django.utils import timezone + + start = timezone.now() + end = start + timedelta(hours=1) + + event_response = api_client.post('/api/v1/schedule/events/', { + 'title': 'Team Meeting', + 'start': start.isoformat(), + 'end': end.isoformat(), + 'calendar': calendar_id, + }) + event_id = event_response.data['id'] + + # Добавляем участников + ct = ContentType.objects.get_for_model(User) + api_client.post('/api/v1/schedule/relations/', { + 'event': event_id, + 'content_type': ct.id, + 'object_id': user2.id, + 'distinction': 'attendee', + }) + api_client.post('/api/v1/schedule/relations/', { + 'event': event_id, + 'content_type': ct.id, + 'object_id': user3.id, + 'distinction': 'attendee', + }) + + # Получаем список участников + response = api_client.get( + '/api/v1/schedule/relations/', + {'event': event_id} + ) + + assert response.status_code == status.HTTP_200_OK + + if isinstance(response.data, dict) and 'results' in response.data: + relations = response.data['results'] + else: + relations = response.data + + assert len(relations) == 2 diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 34cd888a..521d7cf2 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -3,11 +3,13 @@ import { Bell, Building2, CalendarDays, ChevronLeft, ChevronRight, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Plus, Search, Trash2, Users, Wallet, X } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { ReactNode, useEffect, useMemo, useState } from "react"; +import { ReactNode, useEffect, useMemo, useState, useRef, useCallback } from "react"; import { apiClient } from "@/lib/api"; import { useUser } from "@/contexts/UserContext"; import { CalendarProvider, useCalendar } from "@/contexts/CalendarContext"; import { CalendarModal } from "@/components/CalendarModal"; +import CalendarParticipantsModal from "@/components/CalendarParticipantsModal"; +import { EventModal } from "@/components/EventModal"; type AppShellProps = { children: ReactNode; @@ -49,6 +51,8 @@ type SidebarCalendarEvent = { end?: string | null; allDay?: boolean; color?: string | null; + rule?: number | null; // ID правила повторения + is_recurring?: boolean; // Флаг из occurrences API }; const pad = (v: number) => String(v).padStart(2, "0"); @@ -290,23 +294,31 @@ function LeftNav() { ); } -function CalendarCard({ +function CalendarCard({ onOpenCalendarModal, - onOpenEventModal -}: { + onOpenEventModal, + onOpenParticipantsModal, + eventsRefreshTrigger, + setEventsRefreshTrigger +}: { onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void; onOpenEventModal: (event: any, date?: Date) => void; + onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void; + eventsRefreshTrigger: number; + setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void; }) { const { calendars, selectedCalendarId, setSelectedCalendarId, loading: calendarsLoading, reloadCalendars } = useCalendar(); - + const [monthDate, setMonthDate] = useState(() => { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), 1); }); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(false); + const [showCalendarMenu, setShowCalendarMenu] = useState(false); const [error, setError] = useState(null); const [selectedDate, setSelectedDate] = useState(null); + const fileInputRef = useRef(null); // Загрузка событий выбранного календаря useEffect(() => { @@ -331,15 +343,32 @@ function CalendarCard({ setLoading(true); setError(null); - const result = await apiClient.getCalendarEvents({ - start: formatDateKey(fetchStart), - end: formatDateKey(fetchEnd), - calendar: selectedCalendarId ?? undefined, - }); + // Загружаем обычные события и occurrences параллельно + const [eventsResult, occurrencesResult] = await Promise.all([ + apiClient.getCalendarEvents({ + start: formatDateKey(fetchStart), + end: formatDateKey(fetchEnd), + calendar: selectedCalendarId ?? undefined, + }), + apiClient.getOccurrences({ + start: formatDateKey(fetchStart), + end: formatDateKey(fetchEnd), + calendar: selectedCalendarId ?? undefined, + }), + ]); if (!cancelled) { - const eventsList = Array.isArray(result) ? result : (result?.results || []); - setEvents(eventsList); + // Обычные события (без правил повторения) + const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []); + const regularEvents = eventsList.filter((evt: any) => !evt.rule); + + // Occurrences (развернутые повторяющиеся события) + const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []); + // Фильтруем только повторяющиеся события (избегаем дубликатов) + const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring); + + // Объединяем оба типа событий + setEvents([...regularEvents, ...recurringOccurrences]); } } catch (err) { if (!cancelled) { @@ -363,7 +392,7 @@ function CalendarCard({ return () => { cancelled = true; }; - }, [monthDate, selectedCalendarId]); + }, [monthDate, selectedCalendarId, eventsRefreshTrigger]); const monthLabel = useMemo(() => { const label = monthDate.toLocaleDateString("ru-RU", { @@ -389,7 +418,7 @@ function CalendarCard({ startDate.setHours(10, 0, 0, 0); const endDate = new Date(date); endDate.setHours(11, 0, 0, 0); - + const newEvent = { title: "", description: "", @@ -401,12 +430,51 @@ function CalendarCard({ onOpenEventModal(newEvent, date); }; - const handleEventClick = async (eventId: number) => { - try { - const event = await apiClient.getEvent(eventId); + const handleEventClick = async (event: any) => { + // Если это occurrence (повторяющееся событие), загружаем базовое событие + if (event.is_recurring && event.event_id) { + try { + const fullEvent = await apiClient.getEvent(event.event_id); + // Устанавливаем время из occurrence, но остальные данные из базового события + onOpenEventModal({ + ...fullEvent, + start: event.start, + end: event.end, + }); + } catch (err) { + console.error("Ошибка загрузки базового события:", err); + } + } else { + // Обычное событие - данные уже полные onOpenEventModal(event); - } catch (err) { - console.error("Ошибка загрузки события:", err); + } + }; + + const handleImportCalendar = async (event: React.ChangeEvent) => { + console.log('handleImportCalendar called', event.target.files); + + if (!selectedCalendarId || !event.target.files || event.target.files.length === 0) { + console.log('Early return:', { selectedCalendarId, hasFiles: !!event.target.files }); + return; + } + + const file = event.target.files[0]; + console.log('Importing file:', file.name, file.size); + + try { + const result = await apiClient.importCalendarFromICS(selectedCalendarId, file); + console.log('Import result:', result); + alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`); + // Триггерим обновление событий + setEventsRefreshTrigger(prev => prev + 1); + } catch (error: any) { + console.error('Failed to import calendar:', error); + alert(`Ошибка импорта: ${error.message}`); + } finally { + // Сбрасываем input для возможности повторного импорта того же файла + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } } }; @@ -498,79 +566,172 @@ function CalendarCard({ ))} {selectedCalendarId && ( - +
+ + + {showCalendarMenu && ( + <> +
setShowCalendarMenu(false)} + /> +
+
+ + + + { + console.log('File input onChange triggered', e.target.files); + setShowCalendarMenu(false); + handleImportCalendar(e); + }} + className="hidden" + /> + +
+ +
+
+ + )} +
)}
)}
-
- Месяц -
- - {monthLabel} - -
-
-
- {weekdays.map((day) => ( -
- {day} +
+ Месяц +
+ + {monthLabel} +
- ))} -
-
- {days.map((day) => ( - - ))} -
+
+
+ {weekdays.map((day) => ( +
+ {day} +
+ ))} +
+
+ {days.map((day) => ( + + ))} +
@@ -587,22 +748,27 @@ function CalendarCard({ const start = event.start ? new Date(event.start) : null; const dateLabel = start && !Number.isNaN(start.getTime()) ? start.toLocaleString("ru-RU", { - weekday: "short", - day: "2-digit", - month: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) + weekday: "short", + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) : "Без даты"; return ( ); @@ -614,19 +780,28 @@ function CalendarCard({ ); } -function Calendar({ +function Calendar({ onOpenCalendarModal, - onOpenEventModal -}: { + onOpenEventModal, + onOpenParticipantsModal, + eventsRefreshTrigger, + setEventsRefreshTrigger +}: { onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void; onOpenEventModal: (event: any, date?: Date) => void; + onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void; + eventsRefreshTrigger: number; + setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void; }) { return ( @@ -662,8 +837,11 @@ export function AppShell({ children }: AppShellProps) { // Состояния модалов календаря const [showCalendarModal, setShowCalendarModal] = useState(false); const [showEventModal, setShowEventModal] = useState(false); + const [showParticipantsModal, setShowParticipantsModal] = useState(false); const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null); const [editingEvent, setEditingEvent] = useState(null); + const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null); + const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0); // Обработчики модалов const handleOpenCalendarModal = (calendar?: { id?: number; name: string }) => { @@ -676,6 +854,16 @@ export function AppShell({ children }: AppShellProps) { setShowEventModal(true); }; + const handleEventSaved = useCallback(() => { + // Обновляем список событий в CalendarCard + setEventsRefreshTrigger(prev => prev + 1); + }, []); + + const handleOpenParticipantsModal = (calendar: { id: number; name: string; user_role?: string }) => { + setParticipantsCalendar(calendar); + setShowParticipantsModal(true); + }; + useEffect(() => { // Если загрузка завершена и пользователь не авторизован - редирект на логин if (!loading && !user) { @@ -738,219 +926,111 @@ export function AppShell({ children }: AppShellProps) {
setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} /> -
- -
{children}
- -
- -
- -
- setIsMobileLeftNavOpen(false)} /> -
-
- -
- -
- + +
{children}
+
- - {/* Модалы календаря - рендерятся на верхнем уровне */} - { - setShowCalendarModal(false); - setEditingCalendar(null); - }} - calendar={editingCalendar} - /> - - {/* Модальное окно события */} - {showEventModal && editingEvent && ( -
-
+
+
- -
-
- - setEditingEvent({ ...editingEvent, title: e.target.value })} - className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-sky-500 focus:ring-2 focus:ring-sky-100" - placeholder="Название события" - autoFocus - /> -
- -
- -