From d53414aedcab2b6972d18e63f3c932642b2ef7c7 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Wed, 25 Feb 2026 01:55:51 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D1=8F=D1=8E=D1=89=D0=B8?= =?UTF-8?q?=D1=85=D1=81=D1=8F=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=81=20django-scheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Исправлен RuleSerializer для корректной конвертации params между форматами: * Frontend отправляет dict/JSON: {"byweekday": [0, 4], "interval": 1} * Backend сохраняет в формате django-scheduler: "byweekday:0,4;interval:1" * Добавлен универсальный парсер _parse_params_input() для всех форматов - Добавлен UX в стиле Apple Calendar для еженедельных событий: * Авто-выбор дня начала при включении WEEKLY * Информационный баннер с датой первого события если начальный день не выбран * Защита от перезаписи выбора пользователя через useRef - Рефакторинг и очистка кода: * Удалены все отладочные console.log() и print() * Улучшены docstrings с примерами * Добавлена константа VALID_FREQUENCIES - Исправлена загрузка событий в режиме "Все события": * Убрана блокировка для selectedCalendarId === null * События и occurrences загружаются корректно для всех календарей Closes: повторяющиеся события теперь генерируются только в выбранные дни недели --- backend/api/v1/schedule/serializers.py | 140 +++++++++++++++--- backend/api/v1/schedule/views.py | 78 ++++++---- frontend/src/components/AppShell.tsx | 34 +---- frontend/src/components/BigCalendar.tsx | 36 ++--- frontend/src/components/EventModal.tsx | 91 +++++++++++- .../src/components/ViewDayEventsModal.tsx | 55 ++++--- .../src/components/ViewEventDetailsModal.tsx | 63 ++++---- 7 files changed, 332 insertions(+), 165 deletions(-) diff --git a/backend/api/v1/schedule/serializers.py b/backend/api/v1/schedule/serializers.py index 580a1691..6629a045 100644 --- a/backend/api/v1/schedule/serializers.py +++ b/backend/api/v1/schedule/serializers.py @@ -165,9 +165,16 @@ class CalendarSerializer(serializers.ModelSerializer): class RuleSerializer(serializers.ModelSerializer): - """Сериализатор для Rule (rrule RFC 5545).""" + """ + Сериализатор для Rule (rrule RFC 5545). + + Преобразует параметры между форматами: + - Frontend: dict {"byweekday": [0, 4], "interval": 1} + - django-scheduler: string "byweekday:0,4;interval:1" + """ + + VALID_FREQUENCIES = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY'] - # Удобные поля для frontend frequency_display = serializers.CharField(source='get_frequency_display', read_only=True) class Meta: @@ -175,29 +182,124 @@ class RuleSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'description', 'frequency', 'frequency_display', 'params'] read_only_fields = ['id', 'frequency_display'] - def to_representation(self, instance): - """Десериализация params из JSON-строки в dict.""" + def _dict_to_params_string(self, params_dict): + """ + Преобразует dict в строковый формат django-scheduler. + + Args: + params_dict: {"byweekday": [0, 3], "interval": 1} + + Returns: + "byweekday:0,3;interval:1" + """ + if not params_dict or not isinstance(params_dict, dict): + return "" + + parts = [] + for key, value in params_dict.items(): + if isinstance(value, (list, tuple)): + value_str = ",".join(str(v) for v in value) + else: + value_str = str(value) + parts.append(f"{key}:{value_str}") + + return ";".join(parts) + + def _params_string_to_dict(self, params_str): + """ + Преобразует строку django-scheduler в dict. + + Args: + params_str: "byweekday:0,3;interval:1" + + Returns: + {"byweekday": [0, 3], "interval": 1} + """ + if not params_str: + return {} + + result = {} + for param_pair in params_str.split(";"): + if ":" not in param_pair: + continue + + key, value_str = param_pair.split(":", 1) + key = key.strip() + + # Если значение содержит запятые - это список + if "," in value_str: + try: + result[key] = [int(v.strip()) for v in value_str.split(",")] + except ValueError: + result[key] = [v.strip() for v in value_str.split(",")] + else: + # Пробуем преобразовать в число + try: + result[key] = int(value_str.strip()) + except ValueError: + result[key] = value_str.strip() + + return result + + def _parse_params_input(self, params): + """ + Парсит params из любого формата в dict. + + Поддерживает: + - dict (прямо от JSON) + - JSON-string (от некоторых клиентов) + - django-scheduler string (для обратной совместимости) + + Args: + params: dict, JSON-string или django-scheduler string + + Returns: + dict с параметрами + """ import json + + # Уже dict - возвращаем как есть + if isinstance(params, dict): + return params + + # Пробуем распарсить как JSON-строку + if isinstance(params, str): + # Если начинается с { - это JSON + if params.strip().startswith('{'): + try: + return json.loads(params) + except (json.JSONDecodeError, TypeError): + pass + + # Иначе пробуем как django-scheduler формат + return self._params_string_to_dict(params) + + return {} + + def to_representation(self, instance): + """Преобразует params из БД (django-scheduler format) в dict для frontend.""" 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'] = {} - + data['params'] = self._params_string_to_dict(data.get('params', '')) return data + def to_internal_value(self, data): + """Преобразует params от frontend (dict/JSON) в django-scheduler format для БД.""" + # Парсим params в dict из любого формата + params_dict = self._parse_params_input(data.get('params')) + + # Вызываем родительский метод для валидации остальных полей + validated_data = super().to_internal_value(data) + + # Конвертируем dict в формат django-scheduler для сохранения в БД + validated_data['params'] = self._dict_to_params_string(params_dict) + + return validated_data + def validate_frequency(self, value): - """Валидация frequency.""" - valid_frequencies = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY'] - if value not in valid_frequencies: + """Валидация поля frequency.""" + if value not in self.VALID_FREQUENCIES: raise serializers.ValidationError( - f'Invalid frequency. Must be one of: {", ".join(valid_frequencies)}' + f'Invalid frequency. Must be one of: {", ".join(self.VALID_FREQUENCIES)}' ) return value diff --git a/backend/api/v1/schedule/views.py b/backend/api/v1/schedule/views.py index c6b7f6db..06206203 100644 --- a/backend/api/v1/schedule/views.py +++ b/backend/api/v1/schedule/views.py @@ -386,7 +386,7 @@ class ScheduleEventViewSet(viewsets.ModelViewSet): """Получить материализованные вхождения в диапазоне. Query params: - - calendar: ID календаря (обязательно) + - calendar: ID календаря (необязательно, если не указан - все доступные календари) - start: начало диапазона (ISO 8601) - end: конец диапазона (ISO 8601) @@ -396,20 +396,12 @@ class ScheduleEventViewSet(viewsets.ModelViewSet): start_str = request.query_params.get('start') end_str = request.query_params.get('end') - if not all([calendar_id, start_str, end_str]): + if not all([start_str, end_str]): return Response( - {'detail': 'Требуются параметры: calendar, start, end'}, + {'detail': 'Требуются параметры: start, end'}, status=status.HTTP_400_BAD_REQUEST ) - try: - calendar = Calendar.objects.get(id=calendar_id) - except Calendar.DoesNotExist: - return Response( - {'detail': 'Календарь не найден'}, - status=status.HTTP_404_NOT_FOUND - ) - start_dt = parse_datetime(start_str) end_dt = parse_datetime(end_str) @@ -419,25 +411,57 @@ class ScheduleEventViewSet(viewsets.ModelViewSet): status=status.HTTP_400_BAD_REQUEST ) - # Используем schedule.periods.Period для получения вхождений - events = Event.objects.filter(calendar=calendar) - period = Period(events, start_dt, end_dt) + # Если указан конкретный календарь - фильтруем по нему + if calendar_id: + try: + calendar = Calendar.objects.get(id=calendar_id) + calendars = [calendar] + except Calendar.DoesNotExist: + return Response( + {'detail': 'Календарь не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + else: + # Если календарь не указан - берем все доступные пользователю календари + from django.contrib.auth import get_user_model + User = get_user_model() + user = request.user + + if user.is_staff or user.is_superuser: + calendars = Calendar.objects.all() + else: + 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) + calendars = Calendar.objects.filter(id__in=calendar_ids) # Результат occurrences = [] - for occurrence in period.get_occurrences(): - 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, - }) + + # Обрабатываем каждый календарь + for calendar in calendars: + events = Event.objects.filter(calendar=calendar) + period = Period(events, start_dt, end_dt) + + for occurrence in period.get_occurrences(): + # Создаем уникальный ID для каждого вхождения (комбинация event_id и start) + unique_id = f"{occurrence.event.id}_{occurrence.start.isoformat()}" + + occurrences.append({ + 'id': unique_id, # Уникальный ID для каждого вхождения + 'title': occurrence.title or occurrence.event.title, + 'description': occurrence.event.description, + 'start': occurrence.start.isoformat(), + 'end': occurrence.end.isoformat(), + 'calendar': calendar.id, # Добавляем ID календаря + 'color_event': occurrence.event.color_event, + 'event_id': occurrence.event.id, # 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/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1c4d5207..fc036754 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -326,11 +326,6 @@ function CalendarCard({ // Загрузка событий выбранного календаря useEffect(() => { - if (selectedCalendarId === null) { - setEvents([]); - return; - } - let cancelled = false; const start = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); @@ -347,32 +342,18 @@ function CalendarCard({ setLoading(true); setError(null); - // Если календарь не выбран (null) - загружаем все события пользователя - if (selectedCalendarId === null) { - const myEventsResult = await apiClient.getMyEvents({ - start: formatDateKey(fetchStart), - end: formatDateKey(fetchEnd), - }); - - if (!cancelled) { - const eventsList = Array.isArray(myEventsResult) ? myEventsResult : (myEventsResult?.results || []); - setEvents(eventsList); - setSidebarEvents(eventsList); // Передаем события наружу - } - return; - } - - // Загружаем обычные события и occurrences параллельно для конкретного календаря + // Загружаем обычные события и occurrences параллельно + // Если selectedCalendarId === null, загружаем все доступные события со всех календарей const [eventsResult, occurrencesResult] = await Promise.all([ apiClient.getCalendarEvents({ start: formatDateKey(fetchStart), end: formatDateKey(fetchEnd), - calendar: selectedCalendarId, + calendar: selectedCalendarId || undefined, // undefined означает все календари }), apiClient.getOccurrences({ start: formatDateKey(fetchStart), end: formatDateKey(fetchEnd), - calendar: selectedCalendarId, + calendar: selectedCalendarId || undefined, }), ]); @@ -383,7 +364,7 @@ function CalendarCard({ // Occurrences (развернутые повторяющиеся события) const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []); - // Фильтруем только повторяющиеся события (избегаем дубликатов) + // Фильтруем только повторяющиеся события (обычные события уже в regularEvents) const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring); // Объединяем оба типа событий @@ -430,10 +411,7 @@ function CalendarCard({ // Обработчики событий const handleDayClick = (date: Date) => { - if (!selectedCalendarId) { - alert("Сначала выберите календарь"); - return; - } + // Больше не блокируем при selectedCalendarId === null (разрешаем просмотр "Все события") setSelectedDate(date); const startDate = new Date(date); startDate.setHours(10, 0, 0, 0); diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index 05284715..fb59ef2f 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -157,36 +157,16 @@ export function BigCalendar() { const startStr = start.toISOString().split("T")[0]; const endStr = end.toISOString().split("T")[0]; - // Если календарь не выбран (null) - загружаем все события пользователя - if (!selectedCalendarId) { - const myEventsResult = await apiClient.getMyEvents({ - start: startStr, - end: endStr, - }); - - const eventsList = Array.isArray(myEventsResult) ? myEventsResult : (myEventsResult?.results || []); - const allEvents = eventsList.map((evt: any) => ({ - ...evt, - start: new Date(evt.start), - end: new Date(evt.end), - allDay: false, - title: evt.rule ? `⟲ ${evt.title}` : evt.title, - })); - - setEvents(allEvents); - setLoading(false); - return; - } - - // Загружаем обычные события и occurrences параллельно для выбранного календаря + // Загружаем обычные события и occurrences параллельно + // Если selectedCalendarId === null, загружаем все доступные события со всех календарей const [eventsResult, occurrencesResult] = await Promise.all([ apiClient.getCalendarEvents({ - calendar: selectedCalendarId, + calendar: selectedCalendarId || undefined, start: startStr, end: endStr, }), apiClient.getOccurrences({ - calendar: selectedCalendarId, + calendar: selectedCalendarId || undefined, start: startStr, end: endStr, }), @@ -210,18 +190,20 @@ export function BigCalendar() { // Обработка occurrences (повторяющихся событий) const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []); + const occurrenceEvents = occurrencesList - .filter((occ: any) => occ && occ.is_recurring) // Только повторяющиеся (избегаем дубликатов) + .filter((occ: any) => occ && occ.is_recurring) // Только повторяющиеся (обычные события уже в regularEvents) .map((occ: any) => ({ id: occ.id, - title: `⟲ ${occ.title}`, // Добавляем индикатор повторения + title: occ.title.startsWith('⟲') ? occ.title : `⟲ ${occ.title}`, // Добавляем индикатор повторения description: occ.description, start: new Date(occ.start), end: new Date(occ.end), allDay: false, // Явно указываем что событие с временем - calendar: selectedCalendarId, // Календарь уже известен из запроса + calendar: occ.calendar, // Используем calendar из occurrence (важно для "Все события") color_event: occ.color_event || '#3498db', event_id: occ.event_id, + rule: occ.rule, isOccurrence: true, // Помечаем как occurrence для корректного отображения })); diff --git a/frontend/src/components/EventModal.tsx b/frontend/src/components/EventModal.tsx index a6e5b658..cbaa983b 100644 --- a/frontend/src/components/EventModal.tsx +++ b/frontend/src/components/EventModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { X, Trash2 } from "lucide-react"; import { apiClient } from "@/lib/api"; @@ -31,6 +31,43 @@ export function EventModal({ const [loadingEmployees, setLoadingEmployees] = useState(false); const [addingParticipants, setAddingParticipants] = useState(false); const [pendingParticipantIds, setPendingParticipantIds] = useState([]); + + // Ref для отслеживания, был ли уже выполнен авто-выбор дня (чтобы не перезаписывать выбор пользователя) + const autoWeekdaySetRef = useRef(false); + + // Сброс флага при открытии/закрытии модала + useEffect(() => { + if (!isOpen) { + autoWeekdaySetRef.current = false; + } + }, [isOpen]); + + // Автоматический выбор дня начала при включении WEEKLY (как в Apple Calendar) + useEffect(() => { + // Если уже устанавливали автоматически - не трогаем выбор пользователя + if (autoWeekdaySetRef.current) { + return; + } + + if (editingEvent?.frequency === 'WEEKLY' && editingEvent?.start) { + const startDate = new Date(editingEvent.start); + const startDay = startDate.getDay(); + // Конвертируем: JS (0=Вс, 1=Пн...) -> наш формат (0=Пн, 6=Вс) + const dayIndex = startDay === 0 ? 6 : startDay - 1; + + // Если дни недели еще не выбраны - добавляем день начала + if (!editingEvent.byweekday || editingEvent.byweekday.length === 0) { + setEditingEvent((prev: any) => ({ + ...prev, + byweekday: [dayIndex] + })); + autoWeekdaySetRef.current = true; + } else { + // Дни уже выбраны (например, при редактировании события) + autoWeekdaySetRef.current = true; + } + } + }, [editingEvent?.frequency, editingEvent?.start]); useEffect(() => { if (isOpen && event) { @@ -76,6 +113,45 @@ export function EventModal({ return `${year}-${month}-${day}T${hours}:${minutes}`; }; + // Вычисляет дату первого вхождения еженедельного события + const getFirstOccurrenceDate = (): string | null => { + if (editingEvent?.frequency !== 'WEEKLY' || !editingEvent?.start || !editingEvent?.byweekday || editingEvent.byweekday.length === 0) { + return null; + } + + const startDate = new Date(editingEvent.start); + const startDay = startDate.getDay(); + const startDayIndex = startDay === 0 ? 6 : startDay - 1; + + // Если день начала выбран - первое вхождение это сам день начала + if (editingEvent.byweekday.includes(startDayIndex)) { + return null; // Не показываем подсказку + } + + // Ищем ближайший выбранный день недели + const sortedDays = [...editingEvent.byweekday].sort((a, b) => a - b); + let daysToAdd = 0; + + // Ищем день после startDayIndex + const nextDay = sortedDays.find(d => d > startDayIndex); + if (nextDay !== undefined) { + daysToAdd = nextDay - startDayIndex; + } else { + // Если все дни до текущего - берем первый день следующей недели + daysToAdd = 7 - startDayIndex + sortedDays[0]; + } + + const firstOccurrence = new Date(startDate); + firstOccurrence.setDate(firstOccurrence.getDate() + daysToAdd); + + return firstOccurrence.toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'long', + year: 'numeric', + weekday: 'short' + }); + }; + const loadEventParticipants = async (eventId: number) => { setEventParticipantsLoading(true); try { @@ -489,6 +565,19 @@ export function EventModal({ ); })} + {(() => { + const firstOccurrence = getFirstOccurrenceDate(); + return firstOccurrence ? ( +
+ + + +

+ Первое событие: {firstOccurrence} +

+
+ ) : null; + })()} )} diff --git a/frontend/src/components/ViewDayEventsModal.tsx b/frontend/src/components/ViewDayEventsModal.tsx index deff64b0..7d26c05b 100644 --- a/frontend/src/components/ViewDayEventsModal.tsx +++ b/frontend/src/components/ViewDayEventsModal.tsx @@ -43,12 +43,11 @@ export function ViewDayEventsModal({ return (
-
- {/* Header */} -
+
+

События на {capitalizedDate}

-

+

{sortedEvents.length === 0 ? "Нет событий" : `${sortedEvents.length} ${sortedEvents.length === 1 ? "событие" : "событий"}`} @@ -56,19 +55,18 @@ export function ViewDayEventsModal({

- {/* Events List */} -
+
{sortedEvents.length === 0 ? ( -
- +
+

Нет событий в этот день

-

Создайте новое событие, нажав кнопку ниже

+

Создайте новое событие

) : (
@@ -76,31 +74,31 @@ export function ViewDayEventsModal({ +
+ +
diff --git a/frontend/src/components/ViewEventDetailsModal.tsx b/frontend/src/components/ViewEventDetailsModal.tsx index aaa6791f..6bc82722 100644 --- a/frontend/src/components/ViewEventDetailsModal.tsx +++ b/frontend/src/components/ViewEventDetailsModal.tsx @@ -66,23 +66,22 @@ export function ViewEventDetailsModal({ return (
-
- {/* Header */} -
-
+
+
+
-

+

{event.title}

{event.rule && ( - - 🔁 Повторяется + + 🔁 )}
-
+
Календарь #{event.calendar} @@ -90,23 +89,22 @@ export function ViewEventDetailsModal({
- {/* Content */} -
+
{/* Time */} -
- +
+
-

Время

-

+

Время

+

Начало: {capitalizedStart}

-

+

Конец: {capitalizedEnd}

@@ -114,11 +112,11 @@ export function ViewEventDetailsModal({ {/* Recurring Info */} {event.rule && event.end_recurring_period && ( -
- +
+
-

Повторение

-

+

Повторение

+

До {formatDate(event.end_recurring_period)}

@@ -127,11 +125,11 @@ export function ViewEventDetailsModal({ {/* Description */} {event.description && ( -
- +
+

Описание

-

+

{event.description}

@@ -140,22 +138,22 @@ export function ViewEventDetailsModal({ {/* Participants */} {showParticipants && ( -
- +
+
-

+

Участники {participants.length > 0 && `(${participants.length})`}

{loadingParticipants ? (

Загрузка...

) : participants.length > 0 ? ( -
+
{participants.map((participant: any) => (
-
+
{participant.user_name?.[0] || "?"} @@ -177,11 +175,8 @@ export function ViewEventDetailsModal({
)} -
- {/* Footer */} -
-
+