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
This commit is contained in:
igor_nadein 2026-02-24 17:54:46 +03:00
parent d94e82d313
commit 63b6fff1ef
4 changed files with 91 additions and 342 deletions

View File

@ -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):

View File

@ -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<Object>} Данные подписки
*/
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<void>}
*/
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<Object>} Созданная подписка
*/
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<Object>} Результат массового приглашения
* @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<Array>} Массив подписок
*/
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<Object>} Обновленная подписка
*/
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:/);
}
/**

View File

@ -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);
}
}
}

View File

@ -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/",