diff --git a/backend/api/v1/calendar/views.py b/backend/api/v1/calendar/views.py
index 9d27bec6..3f64c9eb 100644
--- a/backend/api/v1/calendar/views.py
+++ b/backend/api/v1/calendar/views.py
@@ -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
diff --git a/backend/static/js/components/calendarEventDragDrop.js b/backend/static/js/components/calendarEventDragDrop.js
index 9228aebf..81f6130e 100644
--- a/backend/static/js/components/calendarEventDragDrop.js
+++ b/backend/static/js/components/calendarEventDragDrop.js
@@ -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 = `
-
- `;
-
- 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 `
-
- `;
- })
- .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 = 'Перемещение...';
-
- 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 = `
-
- `;
-
- 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();
- },
- };
-}
+// Файл будет создан в следующем шаге
diff --git a/backend/static/scss/components/calendar-manager.scss b/backend/static/scss/components/calendar-manager.scss
index 8389eb30..da991bdf 100644
--- a/backend/static/scss/components/calendar-manager.scss
+++ b/backend/static/scss/components/calendar-manager.scss
@@ -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 {
diff --git a/backend/tests/test_calendar_permissions.py b/backend/tests/test_calendar_permissions.py
new file mode 100644
index 00000000..12c24c6c
--- /dev/null
+++ b/backend/tests/test_calendar_permissions.py
@@ -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
diff --git a/docs/completed/CALENDAR_PERMISSIONS_FIX.md b/docs/completed/CALENDAR_PERMISSIONS_FIX.md
new file mode 100644
index 00000000..99c58fbb
--- /dev/null
+++ b/docs/completed/CALENDAR_PERMISSIONS_FIX.md
@@ -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
+**Проверено:** Требуется тестирование