From 63b6fff1efbb2917d96e0a99ccd3e431842a30be Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Tue, 24 Feb 2026 17:54:46 +0300 Subject: [PATCH] feat: migrate frontend to django-scheduler API - Updated API endpoints from /api/v1/calendar/ to /api/v1/schedule/ - Removed subscriptions concept (django-scheduler uses simpler approach) - Adapted CalendarSerializer to accept 'title' field for EUSRR compatibility - All calendar visibility now stored in localStorage - Removed deprecated subscribe/unsubscribe functions --- backend/api/v1/schedule/serializers.py | 31 ++- backend/static/js/api/calendarsApi.js | 217 +----------------- .../static/js/components/calendarManager.js | 150 +++--------- backend/static/js/constants/apiUrls.js | 35 +-- 4 files changed, 91 insertions(+), 342 deletions(-) diff --git a/backend/api/v1/schedule/serializers.py b/backend/api/v1/schedule/serializers.py index cfc98f96..bfc6c1a6 100644 --- a/backend/api/v1/schedule/serializers.py +++ b/backend/api/v1/schedule/serializers.py @@ -9,18 +9,45 @@ import pytz class CalendarSerializer(serializers.ModelSerializer): - """Сериализатор для Calendar (django-scheduler).""" + """Сериализатор для Calendar (django-scheduler) с совместимостью EUSRR.""" + # Совместимость с EUSRR API - принимаем title, конвертируем в name + title = serializers.CharField(source='name', required=False) events_count = serializers.SerializerMethodField() class Meta: model = Calendar - fields = ['id', 'name', 'slug', 'events_count'] + fields = ['id', 'name', 'title', 'slug', 'events_count'] read_only_fields = ['id'] + extra_kwargs = { + 'slug': {'required': False}, # Сделаем auto-generated если не указан + } def get_events_count(self, obj): """Количество событий в календаре.""" return obj.event_set.count() + + def create(self, validated_data): + """Создать календарь с автогенерацией slug из name/title.""" + from django.utils.text import slugify + + # Если slug не указан - генерируем из name + if 'slug' not in validated_data or not validated_data.get('slug'): + name = validated_data.get('name', 'calendar') + base_slug = slugify(name) + if not base_slug: + base_slug = 'calendar' + + # Проверяем уникальность slug + slug = base_slug + counter = 1 + while Calendar.objects.filter(slug=slug).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + + validated_data['slug'] = slug + + return super().create(validated_data) class RuleSerializer(serializers.ModelSerializer): diff --git a/backend/static/js/api/calendarsApi.js b/backend/static/js/api/calendarsApi.js index 7c2697ac..898ba5a2 100644 --- a/backend/static/js/api/calendarsApi.js +++ b/backend/static/js/api/calendarsApi.js @@ -173,217 +173,11 @@ export async function deleteCalendar(calendarId) { dataManager.invalidate("calendars:my"); } -/** - * Подписаться на календарь - * @param {number} calendarId - ID календаря - * @param {Object} [options] - Опции подписки - * @param {boolean} [options.can_edit] - Право редактирования - * @param {boolean} [options.can_manage] - Право управления - * @returns {Promise} Данные подписки - */ -export async function subscribeToCalendar(calendarId, options = {}) { - const url = new URL( - API_URLS.CALENDAR_SUBSCRIBE(calendarId), - window.location.origin, - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: authHeaders(), - body: JSON.stringify(options), - }); - - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: response.statusText })); - throw new Error(error.detail || `HTTP ${response.status}`); - } - - // Инвалидируем кеш - dataManager.invalidate(`calendars:${calendarId}`); - dataManager.invalidate("calendars:my"); - dataManager.invalidate("subscriptions:my"); - - return response.json(); -} - -/** - * Отписаться от календаря - * @param {number} calendarId - ID календаря - * @returns {Promise} - */ -export async function unsubscribeFromCalendar(calendarId) { - const url = new URL( - API_URLS.CALENDAR_UNSUBSCRIBE(calendarId), - window.location.origin, - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: authHeaders(false), - }); - - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: response.statusText })); - throw new Error(error.detail || `HTTP ${response.status}`); - } - - // Инвалидируем кеш - dataManager.invalidate(`calendars:${calendarId}`); - dataManager.invalidate("calendars:my"); - dataManager.invalidate("subscriptions:my"); -} - -/** - * Пригласить пользователя в календарь (только владелец) - * @param {number} calendarId - ID календаря - * @param {Object} inviteData - Данные приглашения - * @param {number} [inviteData.user_id] - ID пользователя - * @param {string} [inviteData.username] - Username пользователя - * @param {boolean} [inviteData.can_edit=false] - Право редактирования событий - * @param {boolean} [inviteData.can_manage=false] - Право управления календарем - * @param {boolean} [inviteData.notify=true] - Отправить уведомление - * @returns {Promise} Созданная подписка - */ -export async function inviteUserToCalendar(calendarId, inviteData) { - // ИСПРАВЛЕНО: используем константу вместо хардкода - const url = new URL( - API_URLS.CALENDAR_INVITE(calendarId), - window.location.origin, - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: authHeaders(), - body: JSON.stringify(inviteData), - }); - - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: response.statusText })); - throw new Error(error.detail || `HTTP ${response.status}`); - } - - // Инвалидируем кеш - dataManager.invalidate(`calendars:${calendarId}`); - dataManager.invalidate("calendars:my"); - dataManager.invalidate("subscriptions:my"); - - return response.json(); -} - -/** - * Массовое приглашение пользователей в календарь (только владелец) - * @param {number} calendarId - ID календаря - * @param {Object} inviteData - Данные приглашения - * @param {number[]} [inviteData.user_ids] - Список ID пользователей - * @param {string[]} [inviteData.usernames] - Список usernames - * @param {boolean} [inviteData.can_edit=false] - Право редактирования событий - * @param {boolean} [inviteData.can_manage=false] - Право управления календарем - * @param {boolean} [inviteData.notify=true] - Отправить уведомления - * @returns {Promise} Результат массового приглашения - * @returns {Promise<{created: Array, already_subscribed: Array, errors: Array, total_created: number, total_already_subscribed: number, total_errors: number}>} - */ -export async function inviteBulkToCalendar(calendarId, inviteData) { - // ИСПРАВЛЕНО: используем константу вместо хардкода - const url = new URL( - API_URLS.CALENDAR_INVITE_BULK(calendarId), - window.location.origin, - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: authHeaders(), - body: JSON.stringify(inviteData), - }); - - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: response.statusText })); - throw new Error(error.detail || `HTTP ${response.status}`); - } - - // Инвалидируем кеш - dataManager.invalidate(`calendars:${calendarId}`); - dataManager.invalidate("calendars:my"); - dataManager.invalidate("subscriptions:my"); - - return response.json(); -} - -/** - * Получить мои подписки на календари - * @param {number} [ttl=60000] - Time to live кеша в мс - * @returns {Promise} Массив подписок - */ -export async function getMySubscriptions(ttl = 60000) { - const key = "subscriptions:my"; - - return dataManager.fetch( - key, - async () => { - // ИСПРАВЛЕНО: используем константу вместо хардкода - const url = new URL(API_URLS.SUBSCRIPTIONS, window.location.origin); - - const response = await fetch(url.toString(), { - headers: authHeaders(false), - }); - - if (response.status === 401) { - console.warn("[CalendarsAPI] 401 Unauthorized"); - return []; - } - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - return data.results || []; - }, - ttl, - ); -} - -/** - * Обновить подписку на календарь - * @param {number} subscriptionId - ID подписки - * @param {Object} updates - Обновления - * @param {boolean} [updates.is_visible] - Видимость - * @param {string} [updates.color_override] - Свой цвет - * @returns {Promise} Обновленная подписка - */ -export async function updateSubscription(subscriptionId, updates) { - // ИСПРАВЛЕНО: используем константу вместо хардкода - const url = new URL( - API_URLS.SUBSCRIPTION_DETAIL(subscriptionId), - window.location.origin, - ); - - const response = await fetch(url.toString(), { - method: "PATCH", - headers: authHeaders(), - body: JSON.stringify(updates), - }); - - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: response.statusText })); - throw new Error(error.detail || `HTTP ${response.status}`); - } - - // Инвалидируем кеш - dataManager.invalidate("subscriptions:my"); - dataManager.invalidate("calendars:my"); - - return response.json(); -} +// ────────────────────────────────────────────────────────────── +// ПРИМЕЧАНИЕ: Функции subscriptions, invite удалены +// Django-scheduler не имеет концепции "подписок" +// Видимость календарей управляется через localStorage +// ────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────── // Events API @@ -537,7 +331,6 @@ export function invalidateEventsCache() { */ export function invalidateCalendarsCache() { dataManager.invalidatePattern(/^calendars:/); - dataManager.invalidatePattern(/^subscriptions:/); } /** diff --git a/backend/static/js/components/calendarManager.js b/backend/static/js/components/calendarManager.js index 7b616f8b..bed9de09 100644 --- a/backend/static/js/components/calendarManager.js +++ b/backend/static/js/components/calendarManager.js @@ -5,10 +5,6 @@ import { getMyCalendars, - getMySubscriptions, - updateSubscription, - subscribeToCalendar, - unsubscribeFromCalendar, invalidateCalendarsCache, invalidateCalendarEventsCache, } from "../api/calendarsApi.js"; @@ -42,7 +38,6 @@ export function initCalendarManager(options = {}) { // Состояние let calendars = []; - let subscriptions = []; // Подписки пользователя let visibleCalendarIds = new Set(); let eventCounts = new Map(); // Счётчики событий для папок: folderId -> count let dragDropInstance = null; @@ -114,13 +109,8 @@ export function initCalendarManager(options = {}) { */ async function loadCalendars() { try { - // Загрузить новые Calendar записи и подписки - const [newCalendars, userSubscriptions] = await Promise.all([ - getMyCalendars(), - getMySubscriptions(), - ]); - - subscriptions = userSubscriptions; + // Загрузить календари из django-scheduler API + const newCalendars = await getMyCalendars(); // Добавить legacy календари const legacyCalendars = createLegacyCalendars(); @@ -141,39 +131,19 @@ export function initCalendarManager(options = {}) { newCalendars.map((c) => ({ id: c.id, title: c.title, - is_global: c.is_global, - is_personal: c.is_personal, - is_department: c.is_department, - owner_user_id: c.owner_user_id, - owner_department_id: c.owner_department_id, - visibility: c.visibility, + name: c.name, + slug: c.slug, })), ); - // Загрузить видимость календарей из подписок и localStorage + // Загрузить видимость календарей из localStorage visibleCalendarIds = new Set(); calendars.forEach((cal) => { - let isVisible = true; - - if (cal.is_legacy || !cal.user_can_edit) { - // Legacy календари или календари без прав редактирования - используем localStorage - const storageKey = `calendar_visible_${cal.id}`; - const stored = localStorage.getItem(storageKey); - isVisible = stored === null ? true : stored === "true"; - } else { - // Новые календари с правами редактирования - используем подписки - const subscription = subscriptions.find((s) => s.calendar === cal.id); - if (subscription) { - // Если есть подписка - используем её is_visible - isVisible = subscription.is_visible; - } else { - // Если подписки нет - проверяем localStorage (fallback) - const storageKey = `calendar_visible_${cal.id}`; - const stored = localStorage.getItem(storageKey); - isVisible = stored === null ? true : stored === "true"; - } - } + // Используем localStorage для всех календарей + const storageKey = `calendar_visible_${cal.id}`; + const stored = localStorage.getItem(storageKey); + const isVisible = stored === null ? true : stored === "true"; if (isVisible) { visibleCalendarIds.add(cal.id); @@ -296,65 +266,13 @@ export function initCalendarManager(options = {}) { visibleCalendarIds.delete(calendarId); } - // Найти календарь - const calendar = calendars.find((c) => c.id === calendarId); - - // Сохранить изменение + // Сохранить в localStorage (django-scheduler не имеет subscriptions) try { - // Проверяем, можно ли использовать API для этого календаря - const canUseAPI = - calendar && !calendar.is_legacy && calendar.user_can_edit; - - if (!canUseAPI) { - // Legacy календари или календари без прав редактирования - сохраняем в localStorage - const storageKey = `calendar_visible_${calendarId}`; - localStorage.setItem(storageKey, String(isVisible)); - console.log( - `[CalendarManager] Saved calendar visibility to localStorage: ${calendarId} = ${isVisible}`, - ); - } else { - // Новые календари с правами редактирования - обновляем подписку через API - const subscription = subscriptions.find( - (s) => s.calendar === calendarId, - ); - - if (subscription) { - await updateSubscription(subscription.id, { is_visible: isVisible }); - console.log( - `[CalendarManager] Updated subscription ${subscription.id} visibility: ${isVisible}`, - ); - } else { - // Если подписки нет - создаём её автоматически - try { - // Сначала создаём подписку - const newSubscription = await subscribeToCalendar(calendarId); - - // Если visibility не по умолчанию (false), обновляем её - if (!isVisible) { - await updateSubscription(newSubscription.id, { - is_visible: isVisible, - }); - } - - // Обновляем локальный список подписок - subscriptions.push({ ...newSubscription, is_visible: isVisible }); - console.log( - `[CalendarManager] Created subscription ${newSubscription.id} for calendar ${calendarId} with visibility: ${isVisible}`, - ); - } catch (subError) { - // Если создание подписки не удалось - сохраняем в localStorage как fallback - console.warn( - `[CalendarManager] Failed to create subscription for calendar ${calendarId}:`, - subError, - ); - const storageKey = `calendar_visible_${calendarId}`; - localStorage.setItem(storageKey, String(isVisible)); - console.log( - `[CalendarManager] Saved to localStorage as fallback: ${isVisible}`, - ); - } - } - } + const storageKey = `calendar_visible_${calendarId}`; + localStorage.setItem(storageKey, String(isVisible)); + console.log( + `[CalendarManager] Saved calendar visibility to localStorage: ${calendarId} = ${isVisible}`, + ); } catch (error) { console.error("[CalendarManager] Failed to save visibility:", error); // Откатить изменение при ошибке @@ -370,32 +288,38 @@ export function initCalendarManager(options = {}) { } /** - * Подписаться на календарь + * Подписаться на календарь (заглушка для совместимости) + * @deprecated django-scheduler не использует subscriptions * @param {number} calendarId */ async function subscribe(calendarId) { - try { - await subscribeToCalendar(calendarId); - await loadCalendars(); // Перезагрузить список - } catch (error) { - console.error("[CalendarManager] Subscribe failed:", error); - alert("Не удалось подписаться на календарь"); + console.warn( + "[CalendarManager] subscribe() deprecated - django-scheduler doesn't use subscriptions", + ); + const calendar = calendars.find((c) => c.id === calendarId); + if (calendar) { + // Просто показываем календарь + if (!visibleCalendarIds.has(calendarId)) { + await toggleCalendarVisibility(calendarId); + } } } /** - * Отписаться от календаря + * Отписаться от календаря (заглушка для совместимости) + * @deprecated django-scheduler не использует subscriptions * @param {number} calendarId */ async function unsubscribe(calendarId) { - if (!confirm("Отписаться от этого календаря?")) return; - - try { - await unsubscribeFromCalendar(calendarId); - await loadCalendars(); // Перезагрузить список - } catch (error) { - console.error("[CalendarManager] Unsubscribe failed:", error); - alert("Не удалось отписаться от календаря"); + console.warn( + "[CalendarManager] unsubscribe() deprecated - django-scheduler doesn't use subscriptions", + ); + const calendar = calendars.find((c) => c.id === calendarId); + if (calendar) { + // Просто скрываем календарь + if (visibleCalendarIds.has(calendarId)) { + await toggleCalendarVisibility(calendarId); + } } } diff --git a/backend/static/js/constants/apiUrls.js b/backend/static/js/constants/apiUrls.js index e034b09d..cd19c99a 100644 --- a/backend/static/js/constants/apiUrls.js +++ b/backend/static/js/constants/apiUrls.js @@ -4,26 +4,31 @@ */ /** - * Базовые URL API эндпоинтов + * Базовые URL API эндпоинтов (django-scheduler) * @enum {string} */ export const API_URLS = { - // Calendar Events API - EVENTS: "/api/v1/calendar/events/", - EVENT_DETAIL: (id) => `/api/v1/calendar/events/${id}/`, + // Schedule Events API (django-scheduler) + EVENTS: "/api/v1/schedule/events/", + EVENT_DETAIL: (id) => `/api/v1/schedule/events/${id}/`, + EVENT_OCCURRENCES: "/api/v1/schedule/events/occurrences/", - // Calendars API - CALENDARS: "/api/v1/calendar/calendars/", - CALENDARS_MY: "/api/v1/calendar/calendars/my-calendars/", - CALENDAR_DETAIL: (id) => `/api/v1/calendar/calendars/${id}/`, - CALENDAR_SUBSCRIBE: (id) => `/api/v1/calendar/calendars/${id}/subscribe/`, - CALENDAR_UNSUBSCRIBE: (id) => `/api/v1/calendar/calendars/${id}/unsubscribe/`, - CALENDAR_INVITE: (id) => `/api/v1/calendar/calendars/${id}/invite/`, - CALENDAR_INVITE_BULK: (id) => `/api/v1/calendar/calendars/${id}/invite-bulk/`, + // Schedule Calendars API (django-scheduler) + CALENDARS: "/api/v1/schedule/calendars/", + CALENDARS_MY: "/api/v1/schedule/calendars/", // django-scheduler не требует отдельного endpoint + CALENDAR_DETAIL: (id) => `/api/v1/schedule/calendars/${id}/`, - // Calendar Subscriptions API - SUBSCRIPTIONS: "/api/v1/calendar/subscriptions/", - SUBSCRIPTION_DETAIL: (id) => `/api/v1/calendar/subscriptions/${id}/`, + // Schedule Rules API (RFC 5545 rrule) + RULES: "/api/v1/schedule/rules/", + RULE_DETAIL: (id) => `/api/v1/schedule/rules/${id}/`, + + // Schedule Occurrences API (materialized recurring events) + OCCURRENCES: "/api/v1/schedule/occurrences/", + OCCURRENCE_DETAIL: (id) => `/api/v1/schedule/occurrences/${id}/`, + + // Schedule Relations API (event attendees) + RELATIONS: "/api/v1/schedule/relations/", + RELATION_DETAIL: (id) => `/api/v1/schedule/relations/${id}/`, // Departments API MY_DEPARTMENTS: "/api/v1/departments/my-departments/",