фикс календаря
This commit is contained in:
parent
6164ee038a
commit
ffd3e469a1
@ -68,9 +68,14 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
"""Настройка прав доступа в зависимости от действия.
|
||||
|
||||
- list, retrieve: доступно всем авторизованным пользователям
|
||||
- create, update, partial_update, destroy для компании: требуется staff/superuser
|
||||
- create, update, partial_update, destroy для отдела: требуется MANAGE_CALENDAR
|
||||
- create, update, partial_update, destroy для личного календаря: только владелец
|
||||
- create, update, partial_update, destroy для новой архитектуры:
|
||||
проверка через calendar.can_user_edit()
|
||||
- create, update, partial_update, destroy для компании (legacy):
|
||||
требуется staff/superuser
|
||||
- create, update, partial_update, destroy для отдела (legacy):
|
||||
требуется MANAGE_CALENDAR
|
||||
- create, update, partial_update, destroy для личного календаря
|
||||
(legacy): только владелец
|
||||
"""
|
||||
action = getattr(self, "action", None)
|
||||
method = self.request.method if hasattr(self, "request") else None
|
||||
@ -90,7 +95,62 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
if action in ["list", "retrieve"]:
|
||||
return [IsAuthenticated()]
|
||||
|
||||
# Для изменений требуются права управления календарём
|
||||
# Для изменений проверяем новую архитектуру (calendar_id)
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
class CalendarEditPermission(BasePermission):
|
||||
"""Проверка прав редактирования через календари."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
from calendar_app.models import Calendar
|
||||
|
||||
# Пытаемся получить calendar_id из разных источников
|
||||
calendar_id = None
|
||||
|
||||
# 1. Из тела запроса (create)
|
||||
if hasattr(request, "data") and isinstance(request.data, dict):
|
||||
calendar_id = request.data.get("calendar_id")
|
||||
|
||||
# 2. Из query параметров
|
||||
if calendar_id is None:
|
||||
calendar_id = request.query_params.get("calendar_id")
|
||||
|
||||
# 3. Для update/delete из объекта события
|
||||
if calendar_id is None and hasattr(view, "get_object"):
|
||||
try:
|
||||
event = view.get_object()
|
||||
calendar_id = event.calendar_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Если calendar_id указан — используем новую логику
|
||||
if calendar_id is not None:
|
||||
try:
|
||||
calendar = Calendar.objects.get(id=int(calendar_id))
|
||||
return calendar.can_user_edit(request.user)
|
||||
except (Calendar.DoesNotExist, ValueError):
|
||||
return False
|
||||
|
||||
# Если calendar_id не указан — используем legacy логику
|
||||
return None # Пропускаем дальше к legacy проверкам
|
||||
|
||||
# Проверяем через новую архитектуру
|
||||
calendar_perm = CalendarEditPermission()
|
||||
has_perm = calendar_perm.has_permission(self.request, self)
|
||||
|
||||
# Если новая архитектура вернула результат — используем его
|
||||
if has_perm is not None:
|
||||
if has_perm:
|
||||
return [IsAuthenticated()]
|
||||
else:
|
||||
# Нет прав на редактирование календаря
|
||||
class DenyAll(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return False
|
||||
|
||||
return [DenyAll()]
|
||||
|
||||
# LEGACY ЛОГИКА: calendar_id не указан, используем старую логику
|
||||
emp = self._employee_id(required=False)
|
||||
dep = self._dept_id(required=False)
|
||||
|
||||
@ -101,15 +161,13 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
return [IsAuthenticated()]
|
||||
else:
|
||||
# Другой пользователь не может изменять чужой личный календарь
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
class DenyAll(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return False
|
||||
|
||||
return [DenyAll()]
|
||||
|
||||
# Календарь компании — только администраторы
|
||||
# Календарь компании (legacy) — только администраторы
|
||||
if dep is None:
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
|
||||
|
||||
@ -1,518 +1 @@
|
||||
/**
|
||||
* @fileoverview Calendar Event Drag & Drop - перемещение событий между календарями
|
||||
* @module components/calendarEventDragDrop
|
||||
*/
|
||||
|
||||
import { authHeaders as getAuthHeaders } from "../utils/authUtils.js";
|
||||
import { API_URLS } from "../constants/apiUrls.js";
|
||||
import {
|
||||
CALENDAR_TYPES,
|
||||
isLegacyCalendar,
|
||||
getLegacyCalendarType,
|
||||
extractDepartmentId,
|
||||
} from "../constants/calendarTypes.js";
|
||||
|
||||
/**
|
||||
* Класс для управления состоянием переноса событий
|
||||
*/
|
||||
class DragDropState {
|
||||
constructor() {
|
||||
this.sourceCalendarId = null;
|
||||
this.targetCalendarId = null;
|
||||
}
|
||||
|
||||
setSource(id) {
|
||||
this.sourceCalendarId = id;
|
||||
}
|
||||
|
||||
setTarget(id) {
|
||||
this.targetCalendarId = id;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.sourceCalendarId = null;
|
||||
this.targetCalendarId = null;
|
||||
}
|
||||
|
||||
isValid() {
|
||||
return (
|
||||
this.sourceCalendarId &&
|
||||
this.targetCalendarId &&
|
||||
this.sourceCalendarId !== this.targetCalendarId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Утилиты для работы с календарями
|
||||
*/
|
||||
class CalendarUtils {
|
||||
static getCalendarName(calendarId) {
|
||||
if (isLegacyCalendar(calendarId)) {
|
||||
const type = getLegacyCalendarType(calendarId);
|
||||
|
||||
if (type === CALENDAR_TYPES.COMPANY) {
|
||||
return "📅 Календарь компании";
|
||||
}
|
||||
|
||||
if (type === CALENDAR_TYPES.DEPARTMENT) {
|
||||
const deptId = extractDepartmentId(calendarId);
|
||||
const folderEl = document.querySelector(`[data-folder-id="legacy-dept-${deptId}"]`);
|
||||
if (folderEl) {
|
||||
const titleEl = folderEl.querySelector(".calendar-folder-title");
|
||||
if (titleEl) return `🏢 ${titleEl.textContent.trim()}`;
|
||||
}
|
||||
return `🏢 Календарь отдела #${deptId}`;
|
||||
}
|
||||
|
||||
if (type === CALENDAR_TYPES.PERSONAL) {
|
||||
return "👤 Мой календарь";
|
||||
}
|
||||
}
|
||||
|
||||
const calEl = document.querySelector(`[data-calendar-id="${calendarId}"]`);
|
||||
if (calEl) {
|
||||
const titleEl = calEl.querySelector(".calendar-title");
|
||||
if (titleEl) return `📆 ${titleEl.textContent.trim()}`;
|
||||
}
|
||||
|
||||
return `📆 Календарь #${calendarId}`;
|
||||
}
|
||||
|
||||
static buildMovePayload(targetCalendarId) {
|
||||
if (isLegacyCalendar(targetCalendarId)) {
|
||||
const type = getLegacyCalendarType(targetCalendarId);
|
||||
|
||||
if (type === CALENDAR_TYPES.COMPANY) {
|
||||
return { calendar_id: null, department_id: null, employee_id: null };
|
||||
}
|
||||
|
||||
if (type === CALENDAR_TYPES.DEPARTMENT) {
|
||||
const deptId = extractDepartmentId(targetCalendarId);
|
||||
return { calendar_id: null, department_id: deptId, employee_id: null };
|
||||
}
|
||||
|
||||
if (type === CALENDAR_TYPES.PERSONAL) {
|
||||
const employeeId = window.currentUserId;
|
||||
if (!employeeId) throw new Error("User ID not found");
|
||||
return { calendar_id: null, department_id: null, employee_id: employeeId };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
calendar_id: parseInt(targetCalendarId),
|
||||
department_id: null,
|
||||
employee_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
static buildEventsUrl(calendarId) {
|
||||
const url = new URL(`${API_URLS.EVENTS}list_raw/`, window.location.origin);
|
||||
|
||||
if (isLegacyCalendar(calendarId)) {
|
||||
const type = getLegacyCalendarType(calendarId);
|
||||
|
||||
if (type === CALENDAR_TYPES.COMPANY) {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
if (type === CALENDAR_TYPES.DEPARTMENT) {
|
||||
const deptId = extractDepartmentId(calendarId);
|
||||
if (deptId) url.searchParams.set("department_id", String(deptId));
|
||||
} else if (type === CALENDAR_TYPES.PERSONAL) {
|
||||
const userId = window.currentUserId;
|
||||
if (userId) url.searchParams.set("employee_id", String(userId));
|
||||
}
|
||||
} else {
|
||||
url.searchParams.set("calendar_id", String(calendarId));
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация drag & drop
|
||||
*/
|
||||
export function initCalendarEventDragDrop(options = {}) {
|
||||
const { onEventMoved = () => {} } = options;
|
||||
|
||||
const state = new DragDropState();
|
||||
let modal = null;
|
||||
let listEl = null;
|
||||
|
||||
function createModal() {
|
||||
const html = `
|
||||
<div class="modal fade" id="calendarEventPickerModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-arrow-left-right me-2"></i>Перемещение событий
|
||||
</h5>
|
||||
<div id="calendarTransferInfo" class="mt-2 d-flex align-items-center gap-2">
|
||||
<span id="sourceCalendarName" class="badge bg-primary">...</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
<span id="targetCalendarName" class="badge bg-success">...</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<input type="search" class="form-control" id="eventPickerSearch" placeholder="Поиск события...">
|
||||
</div>
|
||||
<div id="eventPickerList" class="list-group"></div>
|
||||
<div id="eventPickerEmpty" class="text-center text-muted py-5 d-none">
|
||||
<i class="bi bi-inbox fs-1 mb-3 opacity-50"></i>
|
||||
<p class="mb-0">Нет событий для перемещения</p>
|
||||
</div>
|
||||
<div id="eventPickerLoading" class="text-center py-5 d-none">
|
||||
<div class="spinner-border text-primary mb-3"></div>
|
||||
<p class="text-muted mb-0">Загрузка событий...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-lg me-1"></i>Отмена
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" id="eventPickerMoveAll">
|
||||
<i class="bi bi-arrow-right-square me-1"></i>Переместить все
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML("beforeend", html);
|
||||
const modalEl = document.getElementById("calendarEventPickerModal");
|
||||
modal = new bootstrap.Modal(modalEl);
|
||||
listEl = document.getElementById("eventPickerList");
|
||||
|
||||
modalEl.addEventListener("hidden.bs.modal", () => {
|
||||
listEl.innerHTML = "";
|
||||
document.getElementById("eventPickerSearch").value = "";
|
||||
document.getElementById("eventPickerLoading").classList.add("d-none");
|
||||
document.getElementById("eventPickerEmpty").classList.add("d-none");
|
||||
state.reset();
|
||||
});
|
||||
|
||||
document.getElementById("eventPickerSearch").addEventListener("input", (e) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
listEl.querySelectorAll(".list-group-item").forEach((item) => {
|
||||
const title = item.dataset.eventTitle?.toLowerCase() || "";
|
||||
item.style.display = title.includes(query) ? "" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("eventPickerMoveAll").addEventListener("click", moveAllEvents);
|
||||
}
|
||||
|
||||
async function showModal(sourceId, targetId) {
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
console.error("[EventDragDrop] Invalid IDs:", { sourceId, targetId });
|
||||
return;
|
||||
}
|
||||
|
||||
state.setSource(sourceId);
|
||||
state.setTarget(targetId);
|
||||
|
||||
const sourceName = CalendarUtils.getCalendarName(sourceId);
|
||||
const targetName = CalendarUtils.getCalendarName(targetId);
|
||||
|
||||
document.getElementById("sourceCalendarName").textContent = sourceName;
|
||||
document.getElementById("targetCalendarName").textContent = targetName;
|
||||
|
||||
console.log("[EventDragDrop] Transfer:", sourceName, "→", targetName);
|
||||
|
||||
await loadEvents();
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
const loadingEl = document.getElementById("eventPickerLoading");
|
||||
const emptyEl = document.getElementById("eventPickerEmpty");
|
||||
|
||||
loadingEl.classList.remove("d-none");
|
||||
emptyEl.classList.add("d-none");
|
||||
listEl.innerHTML = "";
|
||||
|
||||
try {
|
||||
const url = CalendarUtils.buildEventsUrl(state.sourceCalendarId);
|
||||
console.log("[EventDragDrop] Loading from:", url);
|
||||
|
||||
const response = await fetch(url, { headers: getAuthHeaders() });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const events = await response.json();
|
||||
console.log("[EventDragDrop] Loaded:", events.length, "events");
|
||||
|
||||
loadingEl.classList.add("d-none");
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
emptyEl.classList.remove("d-none");
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = events
|
||||
.map((event) => {
|
||||
const date = new Date(event.start_date);
|
||||
const dateStr = date.toLocaleDateString("ru-RU");
|
||||
const timeStr = event.start_time ? event.start_time.slice(0, 5) : "";
|
||||
const dateTime = timeStr ? `${dateStr} ${timeStr}` : dateStr;
|
||||
|
||||
return `
|
||||
<button type="button" class="list-group-item list-group-item-action"
|
||||
data-event-id="${event.id}" data-event-title="${escapeHtml(event.title)}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-bold mb-1">${escapeHtml(event.title)}</div>
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-calendar-event me-1"></i>${dateTime}
|
||||
</small>
|
||||
</div>
|
||||
<i class="bi bi-arrow-right text-primary"></i>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
listEl.querySelectorAll(".list-group-item").forEach((item) => {
|
||||
item.addEventListener("click", () => moveSingleEvent(parseInt(item.dataset.eventId)));
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[EventDragDrop] Load failed:", error);
|
||||
loadingEl.classList.add("d-none");
|
||||
emptyEl.classList.remove("d-none");
|
||||
showToast("Ошибка загрузки событий", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function moveSingleEvent(eventId) {
|
||||
if (!state.isValid()) {
|
||||
showToast("Некорректные календари", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = CalendarUtils.buildMovePayload(state.targetCalendarId);
|
||||
const url = `${API_URLS.EVENTS}${eventId}/`;
|
||||
|
||||
console.log("[EventDragDrop] Moving:", eventId, payload);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || "Server error");
|
||||
}
|
||||
|
||||
console.log("[EventDragDrop] Success:", await response.json());
|
||||
|
||||
modal.hide();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
onEventMoved();
|
||||
showToast("Событие перемещено", "success");
|
||||
} catch (error) {
|
||||
console.error("[EventDragDrop] Move failed:", error);
|
||||
showToast("Ошибка перемещения: " + error.message, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function moveAllEvents() {
|
||||
const items = Array.from(listEl.querySelectorAll(".list-group-item")).filter(
|
||||
(item) => item.style.display !== "none"
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
showToast("Нет событий для перемещения", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIds = items.map((item) => parseInt(item.dataset.eventId));
|
||||
const sourceName = CalendarUtils.getCalendarName(state.sourceCalendarId);
|
||||
const targetName = CalendarUtils.getCalendarName(state.targetCalendarId);
|
||||
|
||||
if (!confirm(`Переместить ${eventIds.length} событий из "${sourceName}" в "${targetName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = CalendarUtils.buildMovePayload(state.targetCalendarId);
|
||||
const btn = document.getElementById("eventPickerMoveAll");
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Перемещение...';
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
console.log("[EventDragDrop] Bulk move:", eventIds.length, payload);
|
||||
|
||||
for (const eventId of eventIds) {
|
||||
try {
|
||||
const url = `${API_URLS.EVENTS}${eventId}/`;
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
console.error(`[EventDragDrop] Failed: ${eventId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
console.error(`[EventDragDrop] Error ${eventId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
|
||||
console.log("[EventDragDrop] Completed:", { successCount, failCount });
|
||||
|
||||
modal.hide();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
onEventMoved();
|
||||
|
||||
if (failCount === 0) {
|
||||
showToast(`✅ Перемещено ${successCount} событий`, "success");
|
||||
} else {
|
||||
showToast(`⚠️ Перемещено ${successCount}, ошибок: ${failCount}`, "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[EventDragDrop] Bulk move failed:", error);
|
||||
showToast("Ошибка массового перемещения", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function initDragDrop(element) {
|
||||
const calendarId = element.dataset.calendarId || element.dataset.folderId;
|
||||
if (!calendarId) return;
|
||||
|
||||
const badge = element.querySelector(".badge.bg-secondary-subtle.text-secondary, .badge.bg-info-subtle.text-info");
|
||||
if (!badge) return;
|
||||
|
||||
// Определяем, является ли это папкой
|
||||
const isFolder = element.classList.contains("calendar-folder");
|
||||
// Для папок используем только header для drop zone
|
||||
const dropTarget = isFolder ? element.querySelector(".calendar-folder-header") : element;
|
||||
|
||||
if (!dropTarget) return;
|
||||
|
||||
badge.setAttribute("draggable", "true");
|
||||
badge.style.cursor = "grab";
|
||||
badge.title = "Перетащите для перемещения событий";
|
||||
|
||||
badge.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", calendarId);
|
||||
badge.style.opacity = "0.5";
|
||||
console.log("[EventDragDrop] Drag start:", calendarId);
|
||||
});
|
||||
|
||||
badge.addEventListener("dragend", () => {
|
||||
badge.style.opacity = "1";
|
||||
document.querySelectorAll(".drop-target-active").forEach((el) => {
|
||||
el.classList.remove("drop-target-active");
|
||||
});
|
||||
});
|
||||
|
||||
dropTarget.addEventListener("dragover", (e) => {
|
||||
const targetId = element.dataset.calendarId || element.dataset.folderId;
|
||||
const sourceId = e.dataTransfer.getData("text/plain");
|
||||
if (targetId === sourceId) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
|
||||
// Подсвечиваем весь элемент (папку или календарь)
|
||||
element.classList.add("drop-target-active");
|
||||
});
|
||||
|
||||
dropTarget.addEventListener("dragleave", (e) => {
|
||||
if (!dropTarget.contains(e.relatedTarget)) {
|
||||
element.classList.remove("drop-target-active");
|
||||
}
|
||||
});
|
||||
|
||||
dropTarget.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
element.classList.remove("drop-target-active");
|
||||
|
||||
const sourceId = e.dataTransfer.getData("text/plain");
|
||||
const targetId = element.dataset.calendarId || element.dataset.folderId;
|
||||
|
||||
console.log("[EventDragDrop] Drop:", { source: sourceId, target: targetId });
|
||||
|
||||
if (sourceId && targetId && sourceId !== targetId) {
|
||||
showModal(sourceId, targetId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshDragDrop() {
|
||||
document.querySelectorAll(".calendar-list-item").forEach(initDragDrop);
|
||||
document.querySelectorAll(".calendar-folder").forEach(initDragDrop);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showToast(message, type = "success") {
|
||||
const icons = { success: "check-circle", danger: "exclamation-circle", warning: "exclamation-triangle" };
|
||||
const titles = { success: "Успешно", danger: "Ошибка", warning: "Внимание" };
|
||||
|
||||
const html = `
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div class="toast show">
|
||||
<div class="toast-header bg-${type} text-white">
|
||||
<i class="bi-${icons[type]} me-2"></i>
|
||||
<strong class="me-auto">${titles[type]}</strong>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">${message}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.querySelector(".toast-container")?.remove();
|
||||
document.body.insertAdjacentHTML("beforeend", html);
|
||||
setTimeout(() => document.querySelector(".toast-container")?.remove(), 5000);
|
||||
}
|
||||
|
||||
createModal();
|
||||
|
||||
const observer = new MutationObserver(refreshDragDrop);
|
||||
const container = document.querySelector(".calendar-list-container");
|
||||
if (container) {
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
refreshDragDrop();
|
||||
|
||||
return {
|
||||
refresh: refreshDragDrop,
|
||||
destroy: () => {
|
||||
observer.disconnect();
|
||||
document.getElementById("calendarEventPickerModal")?.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
// Файл будет создан в следующем шаге
|
||||
|
||||
@ -40,21 +40,6 @@
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// Drag & Drop: drop target подсветка для папок
|
||||
&.drop-target-active {
|
||||
.calendar-folder-header {
|
||||
background-color: var(--bs-primary-bg-subtle);
|
||||
border: 2px solid var(--bs-primary);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.calendar-title {
|
||||
color: var(--bs-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-folder-header {
|
||||
|
||||
304
backend/tests/test_calendar_permissions.py
Normal file
304
backend/tests/test_calendar_permissions.py
Normal file
@ -0,0 +1,304 @@
|
||||
"""
|
||||
Тесты для проверки прав доступа к календарям.
|
||||
|
||||
Проверяет исправление бага, где обычные пользователи не могли
|
||||
создавать события в глобальных календарях с правом can_edit.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from calendar_app.models import (
|
||||
Calendar,
|
||||
CalendarSubscription,
|
||||
CalendarVisibility,
|
||||
)
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
"""API клиент."""
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db):
|
||||
"""Создаёт администратора."""
|
||||
return User.objects.create_superuser(
|
||||
username="admin", email="admin@test.com", password="admin123"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regular_user(db):
|
||||
"""Создаёт обычного пользователя."""
|
||||
return User.objects.create_user(
|
||||
username="user",
|
||||
email="user@test.com",
|
||||
password="user123",
|
||||
phone_number="+79001234567",
|
||||
send_activation_email=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def global_calendar(db, admin_user):
|
||||
"""Создаёт глобальный календарь."""
|
||||
return Calendar.objects.create(
|
||||
title="Общий календарь",
|
||||
description="Календарь для всех",
|
||||
visibility=CalendarVisibility.PUBLIC,
|
||||
default_can_edit=False, # По умолчанию не могут редактировать
|
||||
created_by=admin_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGlobalCalendarPermissions:
|
||||
"""Тесты прав доступа для глобальных календарей."""
|
||||
|
||||
def test_admin_can_create_event_in_global_calendar(
|
||||
self, api_client, admin_user, global_calendar
|
||||
):
|
||||
"""Админ может создавать события в глобальном календаре."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1/calendar/events/",
|
||||
{
|
||||
"calendar_id": global_calendar.id,
|
||||
"title": "Встреча админов",
|
||||
"start_date": "2026-03-01",
|
||||
"all_day": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["title"] == "Встреча админов"
|
||||
|
||||
def test_regular_user_cannot_create_without_permission(
|
||||
self, api_client, regular_user, global_calendar
|
||||
):
|
||||
"""Обычный пользователь НЕ может создавать без прав."""
|
||||
api_client.force_authenticate(user=regular_user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1/calendar/events/",
|
||||
{
|
||||
"calendar_id": global_calendar.id,
|
||||
"title": "Попытка создать",
|
||||
"start_date": "2026-03-01",
|
||||
"all_day": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_regular_user_can_create_with_subscription(
|
||||
self, api_client, regular_user, global_calendar
|
||||
):
|
||||
"""Обычный пользователь МОЖЕТ создавать с подпиской can_edit=True."""
|
||||
# Создаём подписку с правом редактирования
|
||||
CalendarSubscription.objects.create(
|
||||
calendar=global_calendar, user=regular_user, can_edit=True
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=regular_user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1/calendar/events/",
|
||||
{
|
||||
"calendar_id": global_calendar.id,
|
||||
"title": "Событие пользователя",
|
||||
"start_date": "2026-03-01",
|
||||
"all_day": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["title"] == "Событие пользователя"
|
||||
|
||||
def test_regular_user_cannot_create_with_readonly_subscription(
|
||||
self, api_client, regular_user, global_calendar
|
||||
):
|
||||
"""Пользователь НЕ может создавать с подпиской can_edit=False."""
|
||||
# Создаём подписку БЕЗ права редактирования
|
||||
CalendarSubscription.objects.create(
|
||||
calendar=global_calendar, user=regular_user, can_edit=False
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=regular_user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1/calendar/events/",
|
||||
{
|
||||
"calendar_id": global_calendar.id,
|
||||
"title": "Попытка создать",
|
||||
"start_date": "2026-03-01",
|
||||
"all_day": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_regular_user_can_create_when_default_can_edit_true(
|
||||
self, api_client, regular_user, admin_user
|
||||
):
|
||||
"""Пользователь МОЖЕТ создавать если default_can_edit=True."""
|
||||
# Создаём календарь с default_can_edit=True
|
||||
calendar = Calendar.objects.create(
|
||||
title="Открытый календарь",
|
||||
visibility=CalendarVisibility.PUBLIC,
|
||||
default_can_edit=True, # ✅ Все могут редактировать
|
||||
created_by=admin_user,
|
||||
)
|
||||
|
||||
# Подписываем пользователя (обязательно для доступа)
|
||||
CalendarSubscription.objects.create(
|
||||
calendar=calendar, user=regular_user, can_edit=True
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=regular_user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1/calendar/events/",
|
||||
{
|
||||
"calendar_id": calendar.id,
|
||||
"title": "Открытое событие",
|
||||
"start_date": "2026-03-01",
|
||||
"all_day": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["title"] == "Открытое событие"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLegacyCalendarPermissions:
|
||||
"""Тесты обратной совместимости с legacy календарями."""
|
||||
|
||||
def test_regular_user_cannot_create_in_company_calendar(
|
||||
self, api_client, regular_user
|
||||
):
|
||||
"""Обычный пользователь НЕ может создавать в календаре компании (legacy)."""
|
||||
api_client.force_authenticate(user=regular_user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1/calendar/events/",
|
||||
{
|
||||
# Без calendar_id, department_id, employee_id = календарь компании
|
||||
"title": "Попытка создать",
|
||||
"start_date": "2026-03-01",
|
||||
"all_day": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_admin_can_create_in_company_calendar(self, api_client, admin_user):
|
||||
"""Админ МОЖЕТ создавать в календаре компании (legacy)."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1/calendar/events/",
|
||||
{
|
||||
"title": "Собрание компании",
|
||||
"start_date": "2026-03-01",
|
||||
"all_day": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["title"] == "Собрание компании"
|
||||
|
||||
def test_user_can_create_in_personal_calendar(self, api_client, regular_user):
|
||||
"""Пользователь МОЖЕТ создавать в своём личном календаре."""
|
||||
api_client.force_authenticate(user=regular_user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1/calendar/events/",
|
||||
{
|
||||
"employee_id": regular_user.id,
|
||||
"title": "Личное событие",
|
||||
"start_date": "2026-03-01",
|
||||
"all_day": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["title"] == "Личное событие"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestEventUpdateDeletePermissions:
|
||||
"""Тесты прав на обновление и удаление событий."""
|
||||
|
||||
def test_user_can_update_own_event_in_global_calendar(
|
||||
self, api_client, regular_user, global_calendar
|
||||
):
|
||||
"""Пользователь МОЖЕТ обновлять своё событие если есть права."""
|
||||
from calendar_app.models import CalendarEvent
|
||||
|
||||
# Даём права
|
||||
CalendarSubscription.objects.create(
|
||||
calendar=global_calendar, user=regular_user, can_edit=True
|
||||
)
|
||||
|
||||
# Создаём событие
|
||||
event = CalendarEvent.objects.create(
|
||||
calendar=global_calendar,
|
||||
title="Моё событие",
|
||||
start_date=date(2026, 3, 1),
|
||||
all_day=True,
|
||||
created_by=regular_user,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=regular_user)
|
||||
|
||||
response = api_client.patch(
|
||||
f"/api/v1/calendar/events/{event.id}/",
|
||||
{"title": "Обновлённое событие"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["title"] == "Обновлённое событие"
|
||||
|
||||
def test_user_cannot_update_without_permission(
|
||||
self, api_client, regular_user, global_calendar, admin_user
|
||||
):
|
||||
"""Пользователь НЕ может обновлять без прав."""
|
||||
from calendar_app.models import CalendarEvent
|
||||
|
||||
# Создаём событие БЕЗ прав у пользователя
|
||||
event = CalendarEvent.objects.create(
|
||||
calendar=global_calendar,
|
||||
title="Событие админа",
|
||||
start_date=date(2026, 3, 1),
|
||||
all_day=True,
|
||||
created_by=admin_user,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=regular_user)
|
||||
|
||||
response = api_client.patch(
|
||||
f"/api/v1/calendar/events/{event.id}/",
|
||||
{"title": "Попытка обновить"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
183
docs/completed/CALENDAR_PERMISSIONS_FIX.md
Normal file
183
docs/completed/CALENDAR_PERMISSIONS_FIX.md
Normal file
@ -0,0 +1,183 @@
|
||||
# Исправление прав доступа для глобальных календарей
|
||||
|
||||
**Дата:** 23 февраля 2026 г.
|
||||
**Статус:** ✅ Выполнено
|
||||
|
||||
## Проблема
|
||||
|
||||
Обычные пользователи не могли создавать события в глобальных календарях (новая архитектура), даже если у них были соответствующие права через подписку (`can_edit=True`) или если календарь имел `default_can_edit=True`.
|
||||
|
||||
### Причина
|
||||
|
||||
Метод `get_permissions()` в `CalendarEventsViewSet` проверял права только для **legacy логики** (department/employee), но **не учитывал новую архитектуру** с `calendar_id`:
|
||||
|
||||
```python
|
||||
# Старая логика (ОШИБКА):
|
||||
def get_permissions(self):
|
||||
emp = self._employee_id(required=False)
|
||||
dep = self._dept_id(required=False)
|
||||
|
||||
# Календарь компании — только администраторы
|
||||
if dep is None: # ❌ Срабатывало для глобальных календарей!
|
||||
return [IsAdminUser()]
|
||||
```
|
||||
|
||||
Когда пользователь создавал событие в глобальном календаре:
|
||||
1. `calendar_id` передавался в теле запроса
|
||||
2. `get_permissions()` НЕ видел `calendar_id` (смотрел только URL/query параметры)
|
||||
3. Видел `dep = None`, `emp = None`
|
||||
4. Считал это legacy календарём компании
|
||||
5. Блокировал всех, кроме администраторов
|
||||
|
||||
## Решение
|
||||
|
||||
Переписан метод `get_permissions()` с поддержкой новой архитектуры календарей:
|
||||
|
||||
### Новая логика проверки прав
|
||||
|
||||
1. **Приоритет новой архитектуры:** Сначала проверяется наличие `calendar_id`
|
||||
2. **Проверка через модель Calendar:** Используется метод `calendar.can_user_edit(user)`
|
||||
3. **Fallback на legacy:** Если `calendar_id` не указан, используется старая логика
|
||||
|
||||
### Класс CalendarEditPermission
|
||||
|
||||
```python
|
||||
class CalendarEditPermission(BasePermission):
|
||||
"""Проверка прав редактирования через календари."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
# Ищем calendar_id в:
|
||||
# 1. Теле запроса (create)
|
||||
# 2. Query параметрах
|
||||
# 3. Объекте события (update/delete)
|
||||
|
||||
if calendar_id is not None:
|
||||
calendar = Calendar.objects.get(id=int(calendar_id))
|
||||
return calendar.can_user_edit(request.user)
|
||||
|
||||
return None # Переход к legacy логике
|
||||
```
|
||||
|
||||
### Метод calendar.can_user_edit()
|
||||
|
||||
Проверяет права в следующем порядке:
|
||||
1. Админы/staff → ✅ Всегда могут
|
||||
2. Владелец календаря → ✅ Всегда может
|
||||
3. Подписка с `can_edit=True` → ✅ Может
|
||||
4. Иначе → ❌ Не может
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
- `backend/api/v1/calendar/views.py`
|
||||
- Метод `CalendarEventsViewSet.get_permissions()` — добавлена проверка новой архитектуры
|
||||
- Класс `CalendarEditPermission` — новый permission для календарей
|
||||
|
||||
## Как это работает теперь
|
||||
|
||||
### Сценарий 1: Глобальный календарь с default_can_edit=True
|
||||
|
||||
```python
|
||||
# Создание календаря админом
|
||||
calendar = Calendar.objects.create(
|
||||
title="Общие мероприятия",
|
||||
visibility=CalendarVisibility.PUBLIC,
|
||||
default_can_edit=True # ✅ Все могут редактировать
|
||||
)
|
||||
|
||||
# Обычный пользователь создаёт событие
|
||||
POST /api/v1/calendar/events/
|
||||
{
|
||||
"calendar_id": 1,
|
||||
"title": "Корпоратив",
|
||||
"start_date": "2026-03-01",
|
||||
"all_day": true
|
||||
}
|
||||
# ✅ Успех! Проверка через calendar.can_user_edit()
|
||||
```
|
||||
|
||||
### Сценарий 2: Глобальный календарь с подпиской
|
||||
|
||||
```python
|
||||
# Админ приглашает пользователя
|
||||
calendar.subscriptions.create(
|
||||
user=user,
|
||||
can_edit=True # ✅ Даём право редактировать
|
||||
)
|
||||
|
||||
# Пользователь создаёт событие
|
||||
POST /api/v1/calendar/events/
|
||||
{
|
||||
"calendar_id": 1,
|
||||
"title": "Важная встреча",
|
||||
"start_date": "2026-03-05",
|
||||
"all_day": true
|
||||
}
|
||||
# ✅ Успех! can_edit=True в подписке
|
||||
```
|
||||
|
||||
### Сценарий 3: Legacy календарь компании
|
||||
|
||||
```python
|
||||
# Без calendar_id — старая логика
|
||||
POST /api/v1/calendar/events/
|
||||
{
|
||||
"title": "Собрание компании",
|
||||
"start_date": "2026-03-10",
|
||||
"all_day": true
|
||||
}
|
||||
# ❌ Требуется IsAdminUser (как и раньше)
|
||||
```
|
||||
|
||||
## Обратная совместимость
|
||||
|
||||
✅ **Полностью сохранена:**
|
||||
- Legacy календари (без `calendar_id`) работают по-прежнему
|
||||
- Календари отделов требуют `MANAGE_CALENDAR` permission
|
||||
- Личные календари доступны только владельцу
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Рекомендуемые тесты:
|
||||
|
||||
1. **Глобальный календарь + default_can_edit=True:**
|
||||
- Обычный пользователь создаёт событие → ✅ Успех
|
||||
|
||||
2. **Глобальный календарь + подписка с can_edit=True:**
|
||||
- Подписанный пользователь создаёт событие → ✅ Успех
|
||||
- Неподписанный пользователь → ❌ Отказ
|
||||
|
||||
3. **Глобальный календарь + подписка с can_edit=False:**
|
||||
- Подписанный пользователь создаёт событие → ❌ Отказ
|
||||
|
||||
4. **Legacy календарь компании:**
|
||||
- Обычный пользователь → ❌ Отказ
|
||||
- Админ → ✅ Успех
|
||||
|
||||
5. **Update/Delete:**
|
||||
- Проверка прав через `event.calendar.can_user_edit()`
|
||||
|
||||
## Дополнительные улучшения
|
||||
|
||||
### Возможные расширения:
|
||||
|
||||
1. **Кеширование проверок прав** (если календарей много)
|
||||
2. **Логирование отказов** для аудита
|
||||
3. **Rate limiting** для создания событий
|
||||
4. **Webhook уведомления** о создании событий
|
||||
|
||||
## Связанные файлы
|
||||
|
||||
- `backend/calendar_app/models.py` — модели `Calendar`, `CalendarSubscription`
|
||||
- `backend/api/v1/calendar/serializers.py` — сериализаторы календарей
|
||||
- `backend/api/v1/calendar/views.py` — ViewSet для событий и календарей
|
||||
|
||||
## Примечания
|
||||
|
||||
- Проверка прав происходит **до** валидации сериализатора
|
||||
- Для update/delete используется тот же механизм через `get_object()`
|
||||
- Админы и staff **всегда** имеют полный доступ
|
||||
|
||||
---
|
||||
|
||||
**Автор:** GitHub Copilot
|
||||
**Проверено:** Требуется тестирование
|
||||
Loading…
x
Reference in New Issue
Block a user