фикс календаря

This commit is contained in:
admin i 2026-02-23 12:20:18 +03:00
parent 6164ee038a
commit ffd3e469a1
5 changed files with 553 additions and 540 deletions

View File

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

View File

@ -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();
},
};
}
// Файл будет создан в следующем шаге

View File

@ -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 {

View 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

View 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
**Проверено:** Требуется тестирование