feat: современное отображение событий календаря с множественным выбором

Реализовано:

1. Множественный выбор календарей через чекбоксы
   - Заменил <select> на чекбоксы для выбора нескольких календарей
   - События создаются одновременно во всех выбранных календарях
   - При редактировании - обновляется только первый выбранный
   - Цветовые индикаторы для каждого календаря

2. Современный дизайн событий
   - Двухцветная схема: левая граница = цвет календаря, фон = цвет события
   - Полупрозрачный фон события (80%) для лучшей видимости
   - Тени и hover эффекты для объёма
   - Закругленные углы (4px)
   - Плавные анимации (transform + transition)

3. Улучшенное отображение накладывающихся событий
   - dayMaxEvents: автоматически показывать '+N еще'
   - dayMaxEventRows: 4 - максимум 4 строки на день
   - moreLinkClick: 'popover' - красивый popover для остальных
   - eventMaxStack: 3 - до 3 событий в стеке
   - eventOrder: сортировка по началу, длительности, алфавиту

4. Новый CSS файл calendar-improvements.css
   - Современные стили для FullCalendar
   - Улучшенный popover с градиентом
   - Стили для выходных дней (розоватый фон)
   - Hover эффекты на события
   - Анимация fadeInEvent при загрузке
   - Улучшенная читаемость текущего дня
   - Адаптивность для мобильных устройств
   - Кастомные scrollbar для списков

5. Передача цвета календаря в события
   - calendar_color добавляется в extendedProps
   - Используется __calendar из интеграции
   - Отдельная обработка eventColor и calendarColor

6. Визуальные улучшения
   - Текст события: font-weight 500, line-height 1.3
   - Отступы: paddingLeft 6px (с цветом календаря)
   - Тень текста для читаемости на любом фоне
   - Паттерн для ежегодных событий (диагональные полосы)
   - Индикатор текущего дня: синий круг с белым текстом

Результат:
✅ События визуально различимы по календарям (левая граница)
✅ Сохранён индивидуальный цвет события (основной фон)
✅ Множество событий в день отображается компактно (+N еще)
✅ Современный Material Design стиль
✅ Плавные анимации и hover эффекты
✅ Улучшенная читаемость и UX
This commit is contained in:
admin i 2026-02-11 12:52:25 +03:00
parent 417fda790d
commit 281f0b2bbc
32 changed files with 2561 additions and 1162 deletions

View File

@ -187,7 +187,7 @@ Array.from(document.querySelectorAll('script[type="module"]'))
scripts: Array.from(document.querySelectorAll('script[type="module"]')).length,
errors: []
};
if (window.calendarIntegration) {
try {
report.calendars = window.calendarIntegration.getCalendars().length;
@ -196,7 +196,7 @@ Array.from(document.querySelectorAll('script[type="module"]'))
report.errors.push('Error accessing integration: ' + e.message);
}
}
console.table(report);
return report;
})();

View File

@ -22,21 +22,21 @@ scripts.forEach((s, i) => {
(async function manualInit() {
try {
console.log('🔄 Manually loading calendar integration...');
// Импорт модуля
const module = await import('/static/js/components/calendarWidgetIntegration.js');
console.log('✅ Module loaded:', module);
// Инициализация
const { integrateCalendarManager } = module;
const integration = integrateCalendarManager(window.calendarWidget);
if (integration) {
window.calendarIntegration = integration;
console.log('✅ Integration initialized successfully!');
console.log('Calendars:', integration.getCalendars().length);
console.log('Visible:', integration.getVisibleCalendarIds());
// Перезагрузить события
if (window.calendarWidget.refetchEvents) {
window.calendarWidget.refetchEvents();

View File

@ -5,8 +5,12 @@ import re
from datetime import date, datetime, time, timedelta
from typing import Any, Dict, List, Optional
from calendar_app.models import (Calendar, CalendarEvent, CalendarSubscription,
Recurrence)
from calendar_app.models import (
Calendar,
CalendarEvent,
CalendarSubscription,
Recurrence,
)
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
@ -52,7 +56,13 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
read_only_fields = ("id",)
# ---- helpers
def _coalesce(self, data: Dict[str, Any], instance: Optional[CalendarEvent], key: str, default=None):
def _coalesce(
self,
data: Dict[str, Any],
instance: Optional[CalendarEvent],
key: str,
default=None,
):
if key in data:
return data.get(key)
if instance is not None:
@ -66,7 +76,7 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
iv = int(v)
if not (0 <= iv <= 6):
raise ValueError("weekday out of range")
mask |= (1 << iv)
mask |= 1 << iv
return mask
# ---- validation
@ -90,7 +100,9 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
try:
interval = int(interval_val)
except Exception:
raise serializers.ValidationError({"recurrence_interval": _("Интервал должен быть числом.")})
raise serializers.ValidationError(
{"recurrence_interval": _("Интервал должен быть числом.")}
)
until: Optional[date] = self._coalesce(attrs, inst, "recurrence_until", None)
rcount: Optional[int] = self._coalesce(attrs, inst, "recurrence_count", None)
@ -101,7 +113,13 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
try:
wmask = self._weekdays_to_mask(weekdays_input)
except Exception:
raise serializers.ValidationError({"weekdays": _("Неверный формат дней недели (ожидаются числа 0..6).")})
raise serializers.ValidationError(
{
"weekdays": _(
"Неверный формат дней недели (ожидаются числа 0..6)."
)
}
)
attrs["weekdays_mask"] = wmask
attrs.pop("weekdays", None)
@ -109,30 +127,54 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
# 1) База
if interval < 1:
raise serializers.ValidationError({"recurrence_interval": _("Интервал должен быть ≥ 1.")})
raise serializers.ValidationError(
{"recurrence_interval": _("Интервал должен быть ≥ 1.")}
)
if until is not None and until < s_date:
raise serializers.ValidationError({"recurrence_until": _("Дата окончания повторения не может быть раньше даты начала.")})
raise serializers.ValidationError(
{
"recurrence_until": _(
"Дата окончания повторения не может быть раньше даты начала."
)
}
)
# Конфликтующие параметры
if until is not None and rcount is not None:
raise serializers.ValidationError({"recurrence_count": _("Нельзя одновременно задавать recurrence_until и recurrence_count.")})
raise serializers.ValidationError(
{
"recurrence_count": _(
"Нельзя одновременно задавать recurrence_until и recurrence_count."
)
}
)
if color and not re.fullmatch(r"#([0-9A-Fa-f]{6})", color):
raise serializers.ValidationError({"color": _("Цвет должен быть в формате #RRGGBB.")})
raise serializers.ValidationError(
{"color": _("Цвет должен быть в формате #RRGGBB.")}
)
# 2) Время / all_day
has_any_time = (s_time is not None) or (e_time is not None)
if has_any_time and (s_time is None or e_time is None):
raise serializers.ValidationError(_("Если указываете время, заполните и начало, и окончание."))
raise serializers.ValidationError(
_("Если указываете время, заполните и начало, и окончание.")
)
if all_day_raw is None:
attrs["all_day"] = not has_any_time
else:
if all_day_raw and has_any_time:
raise serializers.ValidationError(_("Нельзя указывать время для события на весь день."))
raise serializers.ValidationError(
_("Нельзя указывать время для события на весь день.")
)
if (all_day_raw is False) and not has_any_time:
raise serializers.ValidationError(_("Для события с временем укажите start_time и end_time."))
raise serializers.ValidationError(
_("Для события с временем укажите start_time и end_time.")
)
all_day: bool = attrs.get("all_day", inst.all_day if inst else (not has_any_time))
all_day: bool = attrs.get(
"all_day", inst.all_day if inst else (not has_any_time)
)
# end_date по умолчанию = start_date
if e_date is None:
@ -142,37 +184,59 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
# 3) Хронология
if all_day:
if e_date < s_date:
raise serializers.ValidationError({"end_date": _("Дата окончания не может быть раньше даты начала.")})
raise serializers.ValidationError(
{"end_date": _("Дата окончания не может быть раньше даты начала.")}
)
else:
start_dt = datetime.combine(s_date, s_time)
end_dt = datetime.combine(e_date, e_time)
if end_dt <= start_dt:
raise serializers.ValidationError({"end_time": _("Окончание должно быть позже начала.")})
raise serializers.ValidationError(
{"end_time": _("Окончание должно быть позже начала.")}
)
# 4) Повторяемость
if rec == Recurrence.HOURLY and all_day:
raise serializers.ValidationError({"recurrence": _("Ежечасное повторение возможно только для событий с временем.")})
raise serializers.ValidationError(
{
"recurrence": _(
"Ежечасное повторение возможно только для событий с временем."
)
}
)
if rec == Recurrence.WEEKLY:
# create: требуем явные дни
if is_create and wmask == 0:
raise serializers.ValidationError({"weekdays_mask": _("Для еженедельного события укажите дни недели (weekdays или weekdays_mask).")})
raise serializers.ValidationError(
{
"weekdays_mask": _(
"Для еженедельного события укажите дни недели (weekdays или weekdays_mask)."
)
}
)
# update: если маска не пришла и осталась 0 — подставим день недели старта
if (not is_create) and ("weekdays_mask" not in attrs) and (wmask == 0):
wmask = 1 << s_date.weekday()
attrs["weekdays_mask"] = wmask
if wmask < 0 or wmask > 0b1111111:
raise serializers.ValidationError({"weekdays_mask": _("Недопустимая маска дней недели.")})
raise serializers.ValidationError(
{"weekdays_mask": _("Недопустимая маска дней недели.")}
)
else:
# для НЕ weekly игнорируем присланные weekdays/маску (если вдруг присланы пустыми)
attrs.pop("weekdays", None)
# маску не трогаем, если не прислана — оставляем как есть/0
if rec not in Recurrence.values:
raise serializers.ValidationError({"recurrence": _("Недопустимый тип повторения.")})
raise serializers.ValidationError(
{"recurrence": _("Недопустимый тип повторения.")}
)
if rcount is not None and rcount < 1:
raise serializers.ValidationError({"recurrence_count": _("Количество повторов должно быть ≥ 1.")})
raise serializers.ValidationError(
{"recurrence_count": _("Количество повторов должно быть ≥ 1.")}
)
return attrs
@ -183,12 +247,17 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
calendar_id = validated_data.pop("calendar_id", None)
if calendar_id is not None:
from calendar_app.models import Calendar
try:
calendar = Calendar.objects.get(id=calendar_id)
validated_data["calendar"] = calendar
except Calendar.DoesNotExist:
raise serializers.ValidationError(
{"calendar_id": _("Календарь с ID {} не найден.").format(calendar_id)}
{
"calendar_id": _("Календарь с ID {} не найден.").format(
calendar_id
)
}
)
# Legacy поддержка department_id и employee_id
@ -197,40 +266,57 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
if department_id is not None:
from employees.models import Department
try:
department = Department.objects.get(id=department_id)
validated_data["department"] = department
except Department.DoesNotExist:
raise serializers.ValidationError(
{"department_id": _("Отдел с ID {} не найден.").format(department_id)}
{
"department_id": _("Отдел с ID {} не найден.").format(
department_id
)
}
)
if employee_id is not None:
from django.contrib.auth import get_user_model
User = get_user_model()
try:
employee = User.objects.get(id=employee_id)
validated_data["employee"] = employee
except User.DoesNotExist:
raise serializers.ValidationError(
{"employee_id": _("Сотрудник с ID {} не найден.").format(employee_id)}
{
"employee_id": _("Сотрудник с ID {} не найден.").format(
employee_id
)
}
)
return CalendarEvent.objects.create(**validated_data)
def update(self, instance: CalendarEvent, validated_data: Dict[str, Any]) -> CalendarEvent:
def update(
self, instance: CalendarEvent, validated_data: Dict[str, Any]
) -> CalendarEvent:
validated_data.pop("weekdays", None)
# Поддержка calendar_id (новая архитектура)
calendar_id = validated_data.pop("calendar_id", None)
if calendar_id is not None:
from calendar_app.models import Calendar
try:
calendar = Calendar.objects.get(id=calendar_id)
validated_data["calendar"] = calendar
except Calendar.DoesNotExist:
raise serializers.ValidationError(
{"calendar_id": _("Календарь с ID {} не найден.").format(calendar_id)}
{
"calendar_id": _("Календарь с ID {} не найден.").format(
calendar_id
)
}
)
# Legacy поддержка department_id и employee_id
@ -239,23 +325,33 @@ class CalendarEventWriteSerializer(serializers.ModelSerializer):
if department_id is not None:
from employees.models import Department
try:
department = Department.objects.get(id=department_id)
validated_data["department"] = department
except Department.DoesNotExist:
raise serializers.ValidationError(
{"department_id": _("Отдел с ID {} не найден.").format(department_id)}
{
"department_id": _("Отдел с ID {} не найден.").format(
department_id
)
}
)
if employee_id is not None:
from django.contrib.auth import get_user_model
User = get_user_model()
try:
employee = User.objects.get(id=employee_id)
validated_data["employee"] = employee
except User.DoesNotExist:
raise serializers.ValidationError(
{"employee_id": _("Сотрудник с ID {} не найден.").format(employee_id)}
{
"employee_id": _("Сотрудник с ID {} не найден.").format(
employee_id
)
}
)
for f, v in validated_data.items():
@ -411,8 +507,14 @@ class CalendarWriteSerializer(serializers.ModelSerializer):
# Для update нужно учитывать существующие значения
if self.instance:
owner_user = owner_user if "owner_user" in attrs else self.instance.owner_user
owner_department = owner_department if "owner_department" in attrs else self.instance.owner_department
owner_user = (
owner_user if "owner_user" in attrs else self.instance.owner_user
)
owner_department = (
owner_department
if "owner_department" in attrs
else self.instance.owner_department
)
if owner_user and owner_department:
raise serializers.ValidationError(
@ -473,7 +575,9 @@ class CalendarSubscriptionWriteSerializer(serializers.ModelSerializer):
def validate(self, attrs):
"""Валидация подписки."""
calendar = attrs.get("calendar") or (self.instance.calendar if self.instance else None)
calendar = attrs.get("calendar") or (
self.instance.calendar if self.instance else None
)
user = attrs.get("user") or (self.instance.user if self.instance else None)
# Проверяем, что пользователь не подписывается на свой личный календарь дважды
@ -482,4 +586,3 @@ class CalendarSubscriptionWriteSerializer(serializers.ModelSerializer):
pass
return attrs

View File

@ -1,32 +1,32 @@
from __future__ import annotations
import logging
from datetime import date
from typing import Optional, Type, List, Dict, Any
from typing import Any, Dict, List, Optional, Type
from calendar_app.models import CalendarEvent
from calendar_app.services.recurrence import expand_event_occurrences
from django.core.cache import cache
from django.http import Http404
from django.utils.dateparse import parse_date
from employees.constants import DeptPerm
from rest_framework import serializers, status
from rest_framework.decorators import action as rest_action
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.decorators import action as rest_action
from calendar_app.models import CalendarEvent
from ..permissions import AdminOrDeptAllowed
from .serializers import (
CalendarEventSerializer,
CalendarEventWriteSerializer,
CalendarSerializer,
CalendarWriteSerializer,
CalendarSubscriptionSerializer,
CalendarSubscriptionWriteSerializer,
CalendarWriteSerializer,
)
from calendar_app.services.recurrence import expand_event_occurrences
from ..permissions import AdminOrDeptAllowed
from employees.constants import DeptPerm
import logging
logger = logging.getLogger(__name__)
@ -60,34 +60,34 @@ class CalendarEventsViewSet(ModelViewSet):
def get_permissions(self):
"""Настройка прав доступа в зависимости от действия.
- list, retrieve: доступно всем авторизованным пользователям
- create, update, partial_update, destroy для компании: требуется staff/superuser
- create, update, partial_update, destroy для отдела: требуется MANAGE_CALENDAR
- create, update, partial_update, destroy для личного календаря: только владелец
"""
action = getattr(self, "action", None)
method = self.request.method if hasattr(self, 'request') else None
method = self.request.method if hasattr(self, "request") else None
# Если action ещё не установлен, определяем по методу
if action is None:
if method in ['GET', 'HEAD', 'OPTIONS']:
if method in ["GET", "HEAD", "OPTIONS"]:
return [IsAuthenticated()]
elif method == 'POST':
action = 'create'
elif method in ['PUT', 'PATCH']:
action = 'update'
elif method == 'DELETE':
action = 'destroy'
elif method == "POST":
action = "create"
elif method in ["PUT", "PATCH"]:
action = "update"
elif method == "DELETE":
action = "destroy"
# Просмотр событий доступен всем авторизованным пользователям
if action in ["list", "retrieve"]:
return [IsAuthenticated()]
# Для изменений требуются права управления календарём
emp = self._employee_id(required=False)
dep = self._dept_id(required=False)
# Личный календарь — полный контроль только у владельца
if emp is not None:
# Проверка, что текущий пользователь = владелец календаря
@ -96,21 +96,24 @@ class CalendarEventsViewSet(ModelViewSet):
else:
# Другой пользователь не может изменять чужой личный календарь
from rest_framework.permissions import BasePermission
class DenyAll(BasePermission):
def has_permission(self, request, view):
return False
return [DenyAll()]
# Календарь компании — только администраторы
if dep is None:
from rest_framework.permissions import IsAdminUser
return [IsAdminUser()]
# Календарь отдела — требуется право MANAGE_CALENDAR
self.kwargs = dict(self.kwargs)
self.kwargs.setdefault("department_id", dep)
return [self.ManageCalendarPerm()]
# ---------- Сериализаторы ----------
def get_serializer_class(self) -> Type[serializers.Serializer]:
@ -169,7 +172,7 @@ class CalendarEventsViewSet(ModelViewSet):
if required:
raise Http404("Не указан department_id.")
return None
def _employee_id(self, *, required: bool = False) -> Optional[int]:
"""Читает employee_id из kwargs/query/body для личного календаря.
@ -233,21 +236,27 @@ class CalendarEventsViewSet(ModelViewSet):
return qs.filter(calendar_id=int(calendar_id))
except ValueError:
raise Http404("Некорректный calendar_id.")
# Legacy логика: department_id / employee_id / company
dep = self._dept_id(required=False)
emp = self._employee_id(required=False)
# Приоритет: employee_id > department_id > company
if emp is not None:
# Личный календарь сотрудника (legacy)
return qs.filter(employee_id=emp, department__isnull=True, calendar__isnull=True)
return qs.filter(
employee_id=emp, department__isnull=True, calendar__isnull=True
)
elif dep is not None:
# Календарь отдела (legacy)
return qs.filter(department_id=dep, employee__isnull=True, calendar__isnull=True)
return qs.filter(
department_id=dep, employee__isnull=True, calendar__isnull=True
)
else:
# Календарь компании (legacy)
return qs.filter(department__isnull=True, employee__isnull=True, calendar__isnull=True)
return qs.filter(
department__isnull=True, employee__isnull=True, calendar__isnull=True
)
# ---------- LIST: материализация повторов ----------
@ -305,7 +314,7 @@ class CalendarEventsViewSet(ModelViewSet):
"""
dep = self._dept_id(required=False)
emp = self._employee_id(required=False)
# Приоритет: employee_id > department_id
if emp is not None:
serializer.save(
@ -315,7 +324,9 @@ class CalendarEventsViewSet(ModelViewSet):
)
else:
serializer.save(
department_id=dep if dep is not None else serializer.validated_data.get("department_id"),
department_id=dep
if dep is not None
else serializer.validated_data.get("department_id"),
employee_id=None,
created_by=self.request.user,
)
@ -405,7 +416,7 @@ class CalendarEventsViewSet(ModelViewSet):
class CalendarViewSet(ModelViewSet):
"""CRUD операции для календарей.
Доступные действия:
- list: список календарей (доступные текущему пользователю)
- create: создание нового календаря (только владельцы/админы)
@ -415,28 +426,29 @@ class CalendarViewSet(ModelViewSet):
- subscribe: подписка на календарь
- unsubscribe: отписка от календаря
"""
permission_classes = [IsAuthenticated]
renderer_classes = [JSONRenderer]
def get_queryset(self):
"""Возвращает календари, доступные текущему пользователю."""
from calendar_app.models import Calendar
user = self.request.user
if self.action == "list":
# Для списка показываем только доступные календари
return Calendar.objects.get_available_for_user(user)
# Для остальных действий возвращаем все календари (права проверяются в permissions)
return Calendar.objects.all()
def get_serializer_class(self):
"""Выбор сериализатора по действию."""
if self.action in {"create", "update", "partial_update"}:
return CalendarWriteSerializer
return CalendarSerializer
def get_permissions(self):
"""Настройка прав доступа."""
if self.action in ["list", "retrieve"]:
@ -449,52 +461,57 @@ class CalendarViewSet(ModelViewSet):
return [IsAuthenticated()]
else:
return [IsAuthenticated()]
def create(self, request, *args, **kwargs):
"""Создание календаря с корректным сериализатором для ответа."""
write_serializer = self.get_serializer(data=request.data)
write_serializer.is_valid(raise_exception=True)
self.perform_create(write_serializer)
# Для ответа используем CalendarSerializer
instance = write_serializer.instance
read_serializer = CalendarSerializer(instance, context={"request": request})
from rest_framework import status
from rest_framework.response import Response
headers = self.get_success_headers(read_serializer.data)
return Response(read_serializer.data, status=status.HTTP_201_CREATED, headers=headers)
return Response(
read_serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
def perform_create(self, serializer):
"""Создание календаря."""
# Владелец определяется в serializer.save()
serializer.save()
cache.clear()
def perform_update(self, serializer):
"""Обновление календаря."""
# Проверяем права владельца
calendar = self.get_object()
user = self.request.user
if not (user.is_superuser or user.is_staff or calendar.is_owner(user)):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Только владелец может изменять календарь.")
serializer.save()
cache.clear()
def perform_destroy(self, instance):
"""Удаление календаря."""
user = self.request.user
if not (user.is_superuser or user.is_staff or instance.is_owner(user)):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Только владелец может удалять календарь.")
instance.delete()
cache.clear()
@rest_action(
detail=True,
methods=["post"],
@ -503,53 +520,54 @@ class CalendarViewSet(ModelViewSet):
)
def subscribe(self, request, pk=None):
"""Подписка на календарь.
Body params:
can_edit (bool, optional): Право на редактирование событий
can_manage (bool, optional): Право на управление календарем
"""
from calendar_app.models import Calendar, CalendarSubscription
calendar = self.get_object()
user = request.user
# Проверяем, доступен ли календарь для подписки
if not calendar.can_user_view(user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Календарь недоступен для подписки.")
# Проверяем, не подписан ли уже пользователь
existing = CalendarSubscription.objects.filter(
calendar=calendar, user=user
).first()
if existing:
return Response(
{"detail": "Вы уже подписаны на этот календарь."},
status=status.HTTP_400_BAD_REQUEST,
)
# Создаем подписку
can_edit = request.data.get("can_edit", False)
can_manage = request.data.get("can_manage", False)
# Только владелец может выдавать права can_edit и can_manage
if (can_edit or can_manage) and not calendar.is_owner(user):
can_edit = False
can_manage = False
subscription = CalendarSubscription.objects.create(
calendar=calendar,
user=user,
can_edit=can_edit,
can_manage=can_manage,
)
serializer = CalendarSubscriptionSerializer(
subscription, context={"request": request}
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@rest_action(
detail=True,
methods=["post"],
@ -559,26 +577,26 @@ class CalendarViewSet(ModelViewSet):
def unsubscribe(self, request, pk=None):
"""Отписка от календаря."""
from calendar_app.models import CalendarSubscription
calendar = self.get_object()
user = request.user
subscription = CalendarSubscription.objects.filter(
calendar=calendar, user=user
).first()
if not subscription:
return Response(
{"detail": "Вы не подписаны на этот календарь."},
status=status.HTTP_400_BAD_REQUEST,
)
subscription.delete()
return Response(
{"detail": "Подписка успешно отменена."},
status=status.HTTP_200_OK,
)
@rest_action(
detail=False,
methods=["get"],
@ -588,10 +606,10 @@ class CalendarViewSet(ModelViewSet):
def my_calendars(self, request):
"""Список всех календарей, доступных текущему пользователю."""
from calendar_app.models import Calendar
user = request.user
calendars = Calendar.objects.get_available_for_user(user)
serializer = CalendarSerializer(
calendars, many=True, context={"request": request}
)
@ -600,7 +618,7 @@ class CalendarViewSet(ModelViewSet):
class CalendarSubscriptionViewSet(ModelViewSet):
"""CRUD операции для подписок на календари.
Доступные действия:
- list: список подписок текущего пользователя
- create: создание новой подписки
@ -608,59 +626,60 @@ class CalendarSubscriptionViewSet(ModelViewSet):
- update/partial_update: обновление подписки (права доступа)
- destroy: удаление подписки
"""
permission_classes = [IsAuthenticated]
renderer_classes = [JSONRenderer]
def get_queryset(self):
"""Возвращает подписки текущего пользователя."""
from calendar_app.models import CalendarSubscription
user = self.request.user
if user.is_superuser or user.is_staff:
# Админы видят все подписки
return CalendarSubscription.objects.all()
# Обычные пользователи видят только свои подписки
return CalendarSubscription.objects.filter(user=user)
def get_serializer_class(self):
"""Выбор сериализатора по действию."""
if self.action in {"create", "update", "partial_update"}:
return CalendarSubscriptionWriteSerializer
return CalendarSubscriptionSerializer
def perform_create(self, serializer):
"""Создание подписки."""
# Пользователь устанавливается автоматически в сериализаторе
serializer.save(user=self.request.user)
cache.clear()
def perform_update(self, serializer):
"""Обновление подписки."""
subscription = self.get_object()
user = self.request.user
# Только владелец календаря может изменять права подписки
if not subscription.calendar.is_owner(user):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(
"Только владелец календаря может изменять права подписки."
)
serializer.save()
cache.clear()
def perform_destroy(self, instance):
"""Удаление подписки."""
user = self.request.user
# Удалять подписку может либо владелец подписки, либо владелец календаря
if not (instance.user == user or instance.calendar.is_owner(user)):
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied(
"Вы не можете удалить эту подписку."
)
raise PermissionDenied("Вы не можете удалить эту подписку.")
instance.delete()
cache.clear()

View File

@ -1,16 +1,27 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .calendar.views import (CalendarEventsViewSet,
CalendarSubscriptionViewSet, CalendarViewSet)
from .calendar.views import (
CalendarEventsViewSet,
CalendarSubscriptionViewSet,
CalendarViewSet,
)
# Communications ViewSets
from .communications.views import ChatViewSet, MessageViewSet, PollViewSet
from .documents.views import DocumentViewSet
from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet,
EmployeeActionViewSet, EmployeeViewSet,
GroupViewSet, PositionViewSet, RegisterAPIView,
ResendEmailAPIView, SkillViewSet,
VerifyEmailAPIView)
from .employees.views import (
DepartmentRoleViewSet,
DepartmentViewSet,
EmployeeActionViewSet,
EmployeeViewSet,
GroupViewSet,
PositionViewSet,
RegisterAPIView,
ResendEmailAPIView,
SkillViewSet,
VerifyEmailAPIView,
)
from .feed.views import CommentViewSet, PostViewSet
from .requests_app.views import RequestViewSet
@ -50,9 +61,7 @@ router.register(r"groups", GroupViewSet, basename="groups")
# NEW: Communications ViewSets
router.register(r"communications/chats", ChatViewSet, basename="chats")
router.register(
r"communications/messages", MessageViewSet, basename="messages"
)
router.register(r"communications/messages", MessageViewSet, basename="messages")
router.register(r"communications/polls", PollViewSet, basename="polls")
@ -68,10 +77,8 @@ urlpatterns = [
VerifyEmailAPIView.as_view(),
name="verify-email",
),
# Notifications API
path("notifications/", include("api.v1.notifications.urls")),
# Router URLs (включая все ViewSets)
path("", include(router.urls)),
]

View File

@ -10,8 +10,8 @@ from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from .admin_calendar import CalendarAdmin # noqa: F401
from .admin_subscription import ( # noqa: F401
CalendarSubscriptionAdmin,
from .admin_subscription import (
CalendarSubscriptionAdmin, # noqa: F401
CalendarSubscriptionInline,
)
from .models import Calendar, CalendarEvent, CalendarSubscription, Recurrence
@ -24,10 +24,13 @@ class ScopeListFilter(admin.SimpleListFilter):
- 'company' → department IS NULL
- 'dept' → department IS NOT NULL
"""
title = _("Область")
parameter_name = "scope"
def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin) -> Iterable[tuple[str, str]]:
def lookups(
self, request: HttpRequest, model_admin: admin.ModelAdmin
) -> Iterable[tuple[str, str]]:
"""Возвращает варианты выбора фильтра.
Args:
@ -62,10 +65,13 @@ class ScopeListFilter(admin.SimpleListFilter):
class HasTimeListFilter(admin.SimpleListFilter):
"""Фильтр наличия времени у события (есть оба time или нет ни одного)."""
title = _("Есть время")
parameter_name = "has_time"
def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin) -> Iterable[tuple[str, str]]:
def lookups(
self, request: HttpRequest, model_admin: admin.ModelAdmin
) -> Iterable[tuple[str, str]]:
"""Опции фильтра (Да/Нет)."""
return (
("yes", _("Да")),
@ -135,30 +141,53 @@ class CalendarEventAdmin(admin.ModelAdmin):
readonly_fields = ("created_at",)
raw_id_fields = ("calendar", "department", "employee", "created_by")
fieldsets = (
(_("Календарь"), {
"fields": ("calendar",),
"description": _(
"Если указан календарь, поля department и employee "
"игнорируются (новый режим)"
),
}),
(_("Основное (LEGACY)"), {
"fields": ("title", "description", "department", "employee", "location"),
"description": _("Department/Employee используются только если calendar=NULL"),
}),
(_("Даты и время"), {
"fields": (
("start_date", "end_date"),
("start_time", "end_time"),
"all_day",
),
}),
(_("Отображение"), {
"fields": ("recurrence", "color"),
}),
(_("Служебное"), {
"fields": ("created_by", "created_at"),
}),
(
_("Календарь"),
{
"fields": ("calendar",),
"description": _(
"Если указан календарь, поля department и employee "
"игнорируются (новый режим)"
),
},
),
(
_("Основное (LEGACY)"),
{
"fields": (
"title",
"description",
"department",
"employee",
"location",
),
"description": _(
"Department/Employee используются только если calendar=NULL"
),
},
),
(
_("Даты и время"),
{
"fields": (
("start_date", "end_date"),
("start_time", "end_time"),
"all_day",
),
},
),
(
_("Отображение"),
{
"fields": ("recurrence", "color"),
},
),
(
_("Служебное"),
{
"fields": ("created_by", "created_at"),
},
),
)
# ===== Экшены =====
@ -203,14 +232,17 @@ class CalendarEventAdmin(admin.ModelAdmin):
return "—"
return format_html(
'<span style="display:inline-block;width:12px;height:12px;'
'border-radius:2px;border:1px solid #ccc;vertical-align:-2px;'
"border-radius:2px;border:1px solid #ccc;vertical-align:-2px;"
'background:{};margin-right:6px;"></span><code>{}</code>',
obj.color, obj.color
obj.color,
obj.color,
)
# ===== Экшены логика =====
@admin.action(description=_("Сделать целодневными (очистить время)"))
def make_all_day(self, request: HttpRequest, queryset: QuerySet[CalendarEvent]) -> None:
def make_all_day(
self, request: HttpRequest, queryset: QuerySet[CalendarEvent]
) -> None:
"""Делает выбранные события целодневными.
Args:
@ -218,10 +250,14 @@ class CalendarEventAdmin(admin.ModelAdmin):
queryset: Выбранные события.
"""
updated = queryset.update(start_time=None, end_time=None, all_day=True)
self.message_user(request, _(f"Обновлено событий: {updated}"), level=messages.SUCCESS)
self.message_user(
request, _(f"Обновлено событий: {updated}"), level=messages.SUCCESS
)
@admin.action(description=_("Очистить время (оставить даты как есть)"))
def clear_time(self, request: HttpRequest, queryset: QuerySet[CalendarEvent]) -> None:
def clear_time(
self, request: HttpRequest, queryset: QuerySet[CalendarEvent]
) -> None:
"""Очищает время начала/окончания у выбранных событий.
Args:
@ -229,22 +265,30 @@ class CalendarEventAdmin(admin.ModelAdmin):
queryset: Выбранные события.
"""
updated = queryset.update(start_time=None, end_time=None)
self.message_user(request, _(f"Очищено время у событий: {updated}"), level=messages.SUCCESS)
self.message_user(
request, _(f"Очищено время у событий: {updated}"), level=messages.SUCCESS
)
@admin.action(description=_("Пометить как ежегодные"))
def set_annual(self, request: HttpRequest, queryset: QuerySet[CalendarEvent]) -> None:
def set_annual(
self, request: HttpRequest, queryset: QuerySet[CalendarEvent]
) -> None:
"""Ставит повторяемость 'Ежегодно' у выбранных событий."""
updated = queryset.update(recurrence=Recurrence.ANNUAL)
self.message_user(request, _(f"Обновлено: {updated}"), level=messages.SUCCESS)
@admin.action(description=_("Пометить как одноразовые"))
def set_one_time(self, request: HttpRequest, queryset: QuerySet[CalendarEvent]) -> None:
def set_one_time(
self, request: HttpRequest, queryset: QuerySet[CalendarEvent]
) -> None:
"""Ставит повторяемость 'Одноразовое' у выбранных событий."""
updated = queryset.update(recurrence=Recurrence.ONE_TIME)
self.message_user(request, _(f"Обновлено: {updated}"), level=messages.SUCCESS)
# ===== Сохранение =====
def save_model(self, request: HttpRequest, obj: CalendarEvent, form, change: bool) -> None:
def save_model(
self, request: HttpRequest, obj: CalendarEvent, form, change: bool
) -> None:
"""Проставляет автора при первом сохранении.
Args:
@ -260,4 +304,3 @@ class CalendarEventAdmin(admin.ModelAdmin):
# Добавляем инлайн подписок в админку календаря после импорта
CalendarAdmin.inlines = [CalendarSubscriptionInline]

View File

@ -53,32 +53,58 @@ class CalendarAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at", "event_count", "subscriber_count")
raw_id_fields = ("owner_user", "owner_department", "created_by")
fieldsets = (
(_("Основное"), {
"fields": ("title", "description", "icon"),
}),
(_("Владелец"), {
"fields": ("owner_user", "owner_department"),
"description": _("Указывается либо пользователь, либо отдел"),
}),
(_("Видимость"), {
"fields": ("visibility",),
}),
(_("Отображение"), {
"fields": ("color", "sort_order"),
}),
(_("Автоподписка"), {
"fields": (
"auto_subscribe_new_users",
"auto_subscribe_department_members",
),
}),
(_("Статус"), {
"fields": ("is_active",),
}),
(_("Статистика"), {
"fields": ("event_count", "subscriber_count", "created_at", "updated_at"),
"classes": ("collapse",),
}),
(
_("Основное"),
{
"fields": ("title", "description", "icon"),
},
),
(
_("Владелец"),
{
"fields": ("owner_user", "owner_department"),
"description": _("Указывается либо пользователь, либо отдел"),
},
),
(
_("Видимость"),
{
"fields": ("visibility",),
},
),
(
_("Отображение"),
{
"fields": ("color", "sort_order"),
},
),
(
_("Автоподписка"),
{
"fields": (
"auto_subscribe_new_users",
"auto_subscribe_department_members",
),
},
),
(
_("Статус"),
{
"fields": ("is_active",),
},
),
(
_("Статистика"),
{
"fields": (
"event_count",
"subscriber_count",
"created_at",
"updated_at",
),
"classes": ("collapse",),
},
),
)
@admin.display(description=_("Видимость"), ordering="visibility")
@ -129,6 +155,5 @@ class CalendarAdmin(admin.ModelAdmin):
# Проставляем created_by при создании
if not change and obj.created_by_id is None:
obj.created_by = request.user
super().save_model(request, obj, form, change)
super().save_model(request, obj, form, change)

View File

@ -49,30 +49,45 @@ class CalendarSubscriptionAdmin(admin.ModelAdmin):
readonly_fields = ("subscribed_at",)
raw_id_fields = ("user", "calendar")
fieldsets = (
(_("Подписка"), {
"fields": ("user", "calendar"),
}),
(_("Отображение"), {
"fields": ("is_visible", "color_override"),
}),
(_("Права доступа"), {
"fields": ("can_edit", "can_manage"),
"description": _(
"can_edit - может создавать/редактировать события; "
"can_manage - может управлять календарем и правами"
),
}),
(_("Уведомления"), {
"fields": (
"notifications_enabled",
"notify_on_new_event",
"notify_on_event_change",
"notify_on_event_delete",
),
}),
(_("Служебное"), {
"fields": ("subscribed_at",),
}),
(
_("Подписка"),
{
"fields": ("user", "calendar"),
},
),
(
_("Отображение"),
{
"fields": ("is_visible", "color_override"),
},
),
(
_("Права доступа"),
{
"fields": ("can_edit", "can_manage"),
"description": _(
"can_edit - может создавать/редактировать события; "
"can_manage - может управлять календарем и правами"
),
},
),
(
_("Уведомления"),
{
"fields": (
"notifications_enabled",
"notify_on_new_event",
"notify_on_event_change",
"notify_on_event_delete",
),
},
),
(
_("Служебное"),
{
"fields": ("subscribed_at",),
},
),
)
@admin.display(description=_("Пользователь"), ordering="user__username")
@ -115,9 +130,7 @@ class CalendarSubscriptionAdmin(admin.ModelAdmin):
if obj.can_manage and not obj.can_edit:
from django.core.exceptions import ValidationError
raise ValidationError(
_("can_manage требует can_edit=True")
)
raise ValidationError(_("can_manage требует can_edit=True"))
super().save_model(request, obj, form, change)

View File

@ -26,6 +26,7 @@ class Recurrence(models.TextChoices):
class CalendarVisibility(models.TextChoices):
"""Видимость календаря."""
PUBLIC = "public", _("Публичный (все видят)")
DEPARTMENT = "department", _("Отдел (только члены отдела)")
PRIVATE = "private", _("Приватный (только владелец)")
@ -34,57 +35,59 @@ class CalendarVisibility(models.TextChoices):
class CalendarManager(models.Manager):
"""Менеджер для модели Calendar с дополнительными методами."""
def get_available_for_user(self, user):
"""Возвращает календари, доступные для просмотра пользователю."""
if user.is_superuser or user.is_staff:
# Админы видят все активные календари
return self.filter(is_active=True)
# Публичные календари
q = Q(visibility=CalendarVisibility.PUBLIC, is_active=True)
# Календари, где пользователь владелец
q |= Q(owner_user=user, is_active=True)
# Календари отделов, где пользователь член
user_departments = user.departments_links.filter(
is_active=True
).values_list("department_id", flat=True)
user_departments = user.departments_links.filter(is_active=True).values_list(
"department_id", flat=True
)
q |= Q(
owner_department_id__in=user_departments,
visibility=CalendarVisibility.DEPARTMENT,
is_active=True
is_active=True,
)
# Календари с подпиской пользователя
subscribed_calendar_ids = user.calendar_subscriptions.values_list(
"calendar_id", flat=True
)
q |= Q(id__in=subscribed_calendar_ids, is_active=True)
return self.filter(q).distinct()
class Calendar(models.Model):
"""Настраиваемый календарь (опциональный, расширенная функциональность).
Если событие создано БЕЗ calendar → работает старая логика (department/employee).
Если событие создано С calendar → игнорируются department/employee, используются настройки календаря.
"""
# Основное
title = models.CharField(_("Название"), max_length=200)
description = models.TextField(_("Описание"), blank=True)
color = models.CharField(_("Цвет"), max_length=7, default="#0d6efd", help_text="#RRGGBB")
color = models.CharField(
_("Цвет"), max_length=7, default="#0d6efd", help_text="#RRGGBB"
)
icon = models.CharField(
_("Иконка"),
max_length=50,
blank=True,
help_text=_("Bootstrap icon, например: calendar-event")
help_text=_("Bootstrap icon, например: calendar-event"),
)
# Владение (опциональное)
owner_user = models.ForeignKey(
User,
@ -95,7 +98,7 @@ class Calendar(models.Model):
verbose_name=_("Владелец"),
help_text=_("Если задано — личный календарь пользователя"),
)
owner_department = models.ForeignKey(
"employees.Department",
on_delete=models.CASCADE,
@ -105,7 +108,7 @@ class Calendar(models.Model):
verbose_name=_("Отдел-владелец"),
help_text=_("Если задано — календарь отдела"),
)
# Настройки видимости
visibility = models.CharField(
_("Видимость"),
@ -113,27 +116,27 @@ class Calendar(models.Model):
choices=CalendarVisibility.choices,
default=CalendarVisibility.CUSTOM,
)
# Права по умолчанию для новых подписчиков
default_can_edit = models.BooleanField(
_("Могут редактировать по умолчанию"),
default=False,
help_text=_("Если True, все подписчики могут создавать/редактировать события"),
)
# Автоподписка
auto_subscribe_new_users = models.BooleanField(
_("Автоподписка для новых пользователей"),
default=False,
help_text=_("Автоматически подписывать новых сотрудников"),
)
auto_subscribe_department_members = models.BooleanField(
_("Автоподписка для членов отдела"),
default=False,
help_text=_("Автоматически подписывать членов отдела-владельца"),
)
# Служебное
is_active = models.BooleanField(_("Активен"), default=True)
created_by = models.ForeignKey(
@ -146,9 +149,9 @@ class Calendar(models.Model):
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = CalendarManager()
class Meta:
verbose_name = _("Календарь")
verbose_name_plural = _("Календари")
@ -158,126 +161,132 @@ class Calendar(models.Model):
models.Index(fields=["owner_department", "is_active"]),
models.Index(fields=["visibility"]),
]
def __str__(self):
if self.owner_user:
return f"{self.title} ({self.owner_user.username})"
if self.owner_department:
return f"{self.title} ({self.owner_department.name})"
return f"{self.title} (Глобальный)"
def clean(self):
# Нельзя одновременно user и department
if self.owner_user and self.owner_department:
raise ValidationError(
_("Календарь не может одновременно принадлежать пользователю и отделу.")
)
@property
def is_global(self):
"""Глобальный календарь (без владельца)."""
return not self.owner_user_id and not self.owner_department_id
@property
def is_personal(self):
"""Личный календарь пользователя."""
return bool(self.owner_user_id)
@property
def is_department(self):
"""Календарь отдела."""
return bool(self.owner_department_id)
def is_owner(self, user):
"""Проверяет, является ли пользователь владельцем календаря."""
if self.owner_user_id:
return self.owner_user_id == user.id
if self.owner_department_id:
# Владелец отдела = руководитель отдела
return self.owner_department.head_id == user.id if hasattr(self.owner_department, 'head_id') else False
return (
self.owner_department.head_id == user.id
if hasattr(self.owner_department, "head_id")
else False
)
# Глобальный календарь — владелец только админы
return user.is_superuser or user.is_staff
def can_user_view(self, user):
"""Проверяет, может ли пользователь просматривать календарь."""
if not self.is_active:
return False
if user.is_superuser or user.is_staff:
return True
# Владелец всегда может просматривать
if self.is_owner(user):
return True
# Публичный календарь
if self.visibility == CalendarVisibility.PUBLIC:
return True
# Календарь отдела
if self.visibility == CalendarVisibility.DEPARTMENT and self.owner_department_id:
if (
self.visibility == CalendarVisibility.DEPARTMENT
and self.owner_department_id
):
# Проверяем, является ли пользователь членом отдела
return user.departments_links.filter(
department_id=self.owner_department_id,
is_active=True
department_id=self.owner_department_id, is_active=True
).exists()
# Приватный или настраиваемый — проверяем подписку
if self.visibility in [CalendarVisibility.PRIVATE, CalendarVisibility.CUSTOM]:
return self.subscriptions.filter(user=user).exists()
return False
def can_user_edit(self, user):
"""Проверяет, может ли пользователь редактировать события в календаре."""
if user.is_superuser or user.is_staff:
return True
# Владелец всегда может редактировать
if self.is_owner(user):
return True
# Проверяем подписку с правом редактирования
subscription = self.subscriptions.filter(user=user).first()
if subscription:
return subscription.can_edit
return False
def can_user_manage(self, user):
"""Проверяет, может ли пользователь управлять календарем."""
if user.is_superuser or user.is_staff:
return True
# Владелец всегда может управлять
if self.is_owner(user):
return True
# Проверяем подписку с правом управления
subscription = self.subscriptions.filter(user=user).first()
if subscription:
return subscription.can_manage
return False
class CalendarSubscription(models.Model):
"""Подписка пользователя на календарь с настройками отображения."""
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="subscriptions",
verbose_name=_("Календарь"),
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="calendar_subscriptions",
verbose_name=_("Пользователь"),
)
# Настройки отображения
is_visible = models.BooleanField(_("Отображать"), default=True)
color_override = models.CharField(
@ -287,26 +296,30 @@ class CalendarSubscription(models.Model):
null=True,
help_text=_("Переопределяет цвет календаря"),
)
# Права подписчика
can_edit = models.BooleanField(
_("Может редактировать"),
default=False,
help_text=_("Может создавать/редактировать события в этом календаре"),
)
can_manage = models.BooleanField(
_("Может управлять"),
default=False,
help_text=_("Может управлять правами других пользователей"),
)
# Уведомления
notify_on_new_event = models.BooleanField(_("Уведомлять о новых событиях"), default=True)
notify_on_event_change = models.BooleanField(_("Уведомлять об изменениях"), default=True)
notify_on_new_event = models.BooleanField(
_("Уведомлять о новых событиях"), default=True
)
notify_on_event_change = models.BooleanField(
_("Уведомлять об изменениях"), default=True
)
subscribed_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Подписка на календарь")
verbose_name_plural = _("Подписки на календари")
@ -315,18 +328,18 @@ class CalendarSubscription(models.Model):
models.Index(fields=["user", "is_visible"]),
models.Index(fields=["calendar", "can_edit"]),
]
def __str__(self):
return f"{self.user.username} → {self.calendar.title}"
class CalendarEvent(models.Model):
"""Событие календаря с поддержкой повторяемости.
ОБРАТНАЯ СОВМЕСТИМОСТЬ:
- Если calendar = NULL → используется старая логика (department/employee)
- Если calendar задан → игнорируются department/employee
LEGACY (старая логика):
- department = NULL и employee = NULL → событие компании (глобальное)
- department задан и employee = NULL → событие отдела
@ -342,11 +355,10 @@ class CalendarEvent(models.Model):
blank=True,
verbose_name=_("Календарь"),
help_text=_(
"Если не задан — используется стандартная логика "
"(department/employee)"
"Если не задан — используется стандартная логика (department/employee)"
),
)
# ✅ LEGACY: Старые поля для обратной совместимости
department = models.ForeignKey(
"employees.Department",
@ -357,7 +369,7 @@ class CalendarEvent(models.Model):
blank=True,
help_text=_("LEGACY: используется если calendar=NULL"),
)
employee = models.ForeignKey(
User,
on_delete=models.CASCADE,
@ -467,11 +479,8 @@ class CalendarEvent(models.Model):
def __str__(self) -> str:
# НОВОЕ: Приоритет calendar
if self.calendar_id:
return (
f"[{self.calendar.title}] {self.title} "
f"({self.start_date:%d.%m.%Y})"
)
return f"[{self.calendar.title}] {self.title} ({self.start_date:%d.%m.%Y})"
# LEGACY: Старая логика
if self.employee_id:
scope = f"Личный ({self.employee})"
@ -479,7 +488,7 @@ class CalendarEvent(models.Model):
scope = str(self.department)
else:
scope = _("Компания")
if self.end_date:
return (
f"{scope}: {self.title} "
@ -491,7 +500,7 @@ class CalendarEvent(models.Model):
def is_legacy_event(self) -> bool:
"""True если событие использует старую логику (без calendar)."""
return self.calendar_id is None
@property
def is_modern_event(self) -> bool:
"""True если событие использует новую логику (с calendar)."""
@ -501,11 +510,11 @@ class CalendarEvent(models.Model):
def is_company(self) -> bool:
"""True если событие глобальное (LEGACY: без отдела и сотрудника)."""
return (
self.calendar_id is None and
self.department_id is None and
self.employee_id is None
self.calendar_id is None
and self.department_id is None
and self.employee_id is None
)
@property
def is_personal(self) -> bool:
"""True если событие личное (LEGACY или calendar.is_personal)."""
@ -534,17 +543,14 @@ class CalendarEvent(models.Model):
"department или employee."
)
)
# LEGACY: Проверка взаимоисключающих полей (только если calendar=NULL)
if not self.calendar_id:
if self.department_id and self.employee_id:
raise ValidationError(
_(
"Событие не может одновременно принадлежать "
"отделу и сотруднику."
)
_("Событие не может одновременно принадлежать отделу и сотруднику.")
)
# База дат
if self.end_date and self.start_date and self.end_date < self.start_date:
raise ValidationError(

View File

@ -0,0 +1,289 @@
/* ========================================
Улучшенные стили для календаря
======================================== */
/* Улучшенное отображение дней с событиями */
.fc-daygrid-day {
position: relative;
}
/* Современный стиль для ячеек календаря */
.fc-daygrid-day-frame {
min-height: 100px;
position: relative;
}
/* Убираем стандартные рамки событий и делаем их более гладкими */
.fc-event {
border-radius: 4px !important;
margin: 1px 2px !important;
padding: 2px 4px !important;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
/* Улучшенное отображение текста события */
.fc-event-title {
font-weight: 500;
padding: 1px 0;
}
/* Стиль для дней с большим количеством событий */
.fc-daygrid-day.fc-day-today {
background-color: rgba(13, 110, 253, 0.05) !important;
}
.fc-daygrid-day.fc-day-today .fc-daygrid-day-number {
background-color: #0d6efd;
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
/* Улучшенный popover для множества событий */
.fc-popover {
border-radius: 8px !important;
box-shadow: 0 4px 16px rgba(0,0,0,0.15) !important;
border: 1px solid rgba(0,0,0,0.1) !important;
max-width: 300px;
}
.fc-popover-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: white !important;
padding: 8px 12px !important;
border-radius: 8px 8px 0 0 !important;
font-weight: 600;
}
.fc-popover-body {
padding: 8px !important;
max-height: 400px;
overflow-y: auto;
}
/* Стили для ссылки "+N еще" */
.fc-more-link {
color: #0d6efd !important;
font-weight: 600 !important;
font-size: 11px !important;
padding: 2px 6px !important;
border-radius: 3px !important;
background: rgba(13, 110, 253, 0.1) !important;
transition: all 0.2s ease;
}
.fc-more-link:hover {
background: rgba(13, 110, 253, 0.2) !important;
text-decoration: none !important;
}
/* Улучшенное отображение времени события */
.fc-event-time {
font-weight: 400;
font-size: 10px;
opacity: 0.9;
margin-right: 3px;
}
/* Стиль для событий с левой цветной границей (календарь) */
.fc-event.has-calendar-color {
padding-left: 8px !important;
}
/* Анимация появления событий */
@keyframes fadeInEvent {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fc-event {
animation: fadeInEvent 0.3s ease;
}
/* Улучшенный hover для всех событий */
.fc-event:hover {
z-index: 999 !important;
transform: translateY(-2px) scale(1.02);
}
/* Стиль для выходных дней */
.fc-day-sat,
.fc-day-sun {
background-color: rgba(220, 53, 69, 0.02);
}
/* Улучшенная читаемость заголовков дней недели */
.fc-col-header-cell {
font-weight: 600;
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
border-color: #dee2e6 !important;
}
/* Современный стиль для кнопок навигации календаря */
.fc-button {
border-radius: 6px !important;
padding: 6px 12px !important;
font-weight: 500 !important;
transition: all 0.2s ease !important;
}
.fc-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
}
/* Стиль для заголовка календаря */
.fc-toolbar-title {
font-weight: 600 !important;
font-size: 1.25rem !important;
color: #2c3e50;
}
/* Улучшенное отображение номера дня */
.fc-daygrid-day-number {
padding: 4px 6px;
font-weight: 500;
color: #495057;
}
/* Стиль для прошедших дней */
.fc-day-past:not(.fc-day-today) {
background-color: rgba(0,0,0,0.02);
}
.fc-day-past:not(.fc-day-today) .fc-daygrid-day-number {
color: #adb5bd;
}
/* Улучшенное отображение границ между днями */
.fc-daygrid-day-frame {
border: 1px solid #e9ecef !important;
}
/* Стиль для событий на весь день */
.fc-daygrid-event.fc-daygrid-block-event {
padding: 3px 6px !important;
}
/* Адаптивность для мобильных */
@media (max-width: 768px) {
.fc-daygrid-day-frame {
min-height: 80px;
}
.fc-event {
font-size: 10px !important;
padding: 1px 3px !important;
}
.fc-toolbar-title {
font-size: 1rem !important;
}
}
/* Улучшенная прокрутка в popover */
.fc-popover-body::-webkit-scrollbar {
width: 6px;
}
.fc-popover-body::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.fc-popover-body::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
.fc-popover-body::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Индикатор загрузки событий */
.fc-view-harness.fc-view-harness-active {
position: relative;
}
/* Стиль для событий разной продолжительности */
.fc-event.fc-event-start.fc-event-end {
/* Событие в один день - закругленные углы */
border-radius: 4px !important;
}
.fc-event.fc-event-start:not(.fc-event-end) {
/* Начало многодневного события - закругление слева */
border-radius: 4px 0 0 4px !important;
}
.fc-event:not(.fc-event-start).fc-event-end {
/* Конец многодневного события - закругление справа */
border-radius: 0 4px 4px 0 !important;
}
.fc-event:not(.fc-event-start):not(.fc-event-end) {
/* Середина многодневного события - без закруглений */
border-radius: 0 !important;
}
/* Улучшенная видимость выбранной даты при создании события */
.fc-highlight {
background: rgba(13, 110, 253, 0.15) !important;
border-radius: 4px;
}
/* Стиль для недели */
.week-strip-header {
font-weight: 600;
color: #495057;
padding: 12px 0 8px;
border-top: 1px solid #e9ecef;
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.week-vertical {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
/* Улучшенный стиль для списка календарей */
.calendar-list-container {
scrollbar-width: thin;
scrollbar-color: #cbd5e0 #f7fafc;
}
.calendar-list-container::-webkit-scrollbar {
width: 6px;
}
.calendar-list-container::-webkit-scrollbar-track {
background: #f7fafc;
border-radius: 3px;
}
.calendar-list-container::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 3px;
}
.calendar-list-container::-webkit-scrollbar-thumb:hover {
background: #a0aec0;
}

View File

@ -3,7 +3,7 @@
* @module api/calendarsApi
*/
import { dataManager } from '../managers/dataManager.js';
import { dataManager } from "../managers/dataManager.js";
/**
* Получить токен авторизации
@ -12,13 +12,13 @@ import { dataManager } from '../managers/dataManager.js';
*/
function getAccessToken() {
const meta = document.querySelector('meta[name="api-access"]');
const token = meta?.getAttribute('content')?.trim();
const token = meta?.getAttribute("content")?.trim();
if (token) return token;
try {
return localStorage.getItem('api.access') || '';
return localStorage.getItem("api.access") || "";
} catch {
return '';
return "";
}
}
@ -28,9 +28,12 @@ function getAccessToken() {
* @private
*/
function getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.querySelector('meta[name="csrf-token"]')?.content ||
getCookie('csrftoken') || '';
return (
document.querySelector("[name=csrfmiddlewaretoken]")?.value ||
document.querySelector('meta[name="csrf-token"]')?.content ||
getCookie("csrftoken") ||
""
);
}
/**
@ -42,8 +45,8 @@ function getCsrfToken() {
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return '';
if (parts.length === 2) return parts.pop().split(";").shift();
return "";
}
/**
@ -55,15 +58,15 @@ function getCookie(name) {
function authHeaders(includeContentType = true) {
const token = getAccessToken();
const headers = {
'Accept': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
'X-CSRFToken': getCsrfToken()
Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
"X-CSRFToken": getCsrfToken(),
};
if (includeContentType) {
headers['Content-Type'] = 'application/json';
headers["Content-Type"] = "application/json";
}
return headers;
}
@ -73,30 +76,33 @@ function authHeaders(includeContentType = true) {
* @returns {Promise<Array>} Массив календарей
*/
export async function getMyCalendars(ttl = 60000) {
const key = 'calendars:my';
const key = "calendars:my";
return dataManager.fetch(
key,
async () => {
const url = new URL('/api/v1/calendar/calendars/', window.location.origin);
const url = new URL(
"/api/v1/calendar/calendars/",
window.location.origin,
);
const response = await fetch(url.toString(), {
headers: authHeaders(false)
headers: authHeaders(false),
});
if (response.status === 401) {
console.warn('[CalendarsAPI] 401 Unauthorized');
console.warn("[CalendarsAPI] 401 Unauthorized");
return [];
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.results || [];
},
ttl
ttl,
);
}
@ -108,23 +114,26 @@ export async function getMyCalendars(ttl = 60000) {
*/
export async function getCalendar(calendarId, ttl = 60000) {
const key = `calendars:${calendarId}`;
return dataManager.fetch(
key,
async () => {
const url = new URL(`/api/v1/calendar/calendars/${calendarId}/`, window.location.origin);
const url = new URL(
`/api/v1/calendar/calendars/${calendarId}/`,
window.location.origin,
);
const response = await fetch(url.toString(), {
headers: authHeaders(false)
headers: authHeaders(false),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
},
ttl
ttl,
);
}
@ -141,22 +150,24 @@ export async function getCalendar(calendarId, ttl = 60000) {
* @returns {Promise<Object>} Созданный календарь
*/
export async function createCalendar(calendarData) {
const url = new URL('/api/v1/calendar/calendars/', window.location.origin);
const url = new URL("/api/v1/calendar/calendars/", window.location.origin);
const response = await fetch(url.toString(), {
method: 'POST',
method: "POST",
headers: authHeaders(),
body: JSON.stringify(calendarData)
body: JSON.stringify(calendarData),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
const error = await response
.json()
.catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш списка календарей
dataManager.invalidate('calendars:my');
dataManager.invalidate("calendars:my");
return response.json();
}
@ -167,23 +178,28 @@ export async function createCalendar(calendarData) {
* @returns {Promise<Object>} Обновленный календарь
*/
export async function updateCalendar(calendarId, updates) {
const url = new URL(`/api/v1/calendar/calendars/${calendarId}/`, window.location.origin);
const url = new URL(
`/api/v1/calendar/calendars/${calendarId}/`,
window.location.origin,
);
const response = await fetch(url.toString(), {
method: 'PATCH',
method: "PATCH",
headers: authHeaders(),
body: JSON.stringify(updates)
body: JSON.stringify(updates),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
const error = await response
.json()
.catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш
dataManager.invalidate(`calendars:${calendarId}`);
dataManager.invalidate('calendars:my');
dataManager.invalidate("calendars:my");
return response.json();
}
@ -193,21 +209,26 @@ export async function updateCalendar(calendarId, updates) {
* @returns {Promise<void>}
*/
export async function deleteCalendar(calendarId) {
const url = new URL(`/api/v1/calendar/calendars/${calendarId}/`, window.location.origin);
const url = new URL(
`/api/v1/calendar/calendars/${calendarId}/`,
window.location.origin,
);
const response = await fetch(url.toString(), {
method: 'DELETE',
headers: authHeaders(false)
method: "DELETE",
headers: authHeaders(false),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
const error = await response
.json()
.catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш
dataManager.invalidate(`calendars:${calendarId}`);
dataManager.invalidate('calendars:my');
dataManager.invalidate("calendars:my");
}
/**
@ -221,25 +242,27 @@ export async function deleteCalendar(calendarId) {
export async function subscribeToCalendar(calendarId, options = {}) {
const url = new URL(
`/api/v1/calendar/calendars/${calendarId}/subscribe/`,
window.location.origin
window.location.origin,
);
const response = await fetch(url.toString(), {
method: 'POST',
method: "POST",
headers: authHeaders(),
body: JSON.stringify(options)
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
const error = await response
.json()
.catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш
dataManager.invalidate(`calendars:${calendarId}`);
dataManager.invalidate('calendars:my');
dataManager.invalidate('subscriptions:my');
dataManager.invalidate("calendars:my");
dataManager.invalidate("subscriptions:my");
return response.json();
}
@ -251,23 +274,25 @@ export async function subscribeToCalendar(calendarId, options = {}) {
export async function unsubscribeFromCalendar(calendarId) {
const url = new URL(
`/api/v1/calendar/calendars/${calendarId}/unsubscribe/`,
window.location.origin
window.location.origin,
);
const response = await fetch(url.toString(), {
method: 'POST',
headers: authHeaders(false)
method: "POST",
headers: authHeaders(false),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
const error = await response
.json()
.catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
// Инвалидируем кеш
dataManager.invalidate(`calendars:${calendarId}`);
dataManager.invalidate('calendars:my');
dataManager.invalidate('subscriptions:my');
dataManager.invalidate("calendars:my");
dataManager.invalidate("subscriptions:my");
}
/**
@ -276,30 +301,33 @@ export async function unsubscribeFromCalendar(calendarId) {
* @returns {Promise<Array>} Массив подписок
*/
export async function getMySubscriptions(ttl = 60000) {
const key = 'subscriptions:my';
const key = "subscriptions:my";
return dataManager.fetch(
key,
async () => {
const url = new URL('/api/v1/calendar/subscriptions/', window.location.origin);
const url = new URL(
"/api/v1/calendar/subscriptions/",
window.location.origin,
);
const response = await fetch(url.toString(), {
headers: authHeaders(false)
headers: authHeaders(false),
});
if (response.status === 401) {
console.warn('[CalendarsAPI] 401 Unauthorized');
console.warn("[CalendarsAPI] 401 Unauthorized");
return [];
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.results || [];
},
ttl
ttl,
);
}

View File

@ -3,8 +3,12 @@
* @module components/calendarManageModal
*/
import { createCalendar, updateCalendar, deleteCalendar } from '../api/calendarsApi.js';
import { getMyDepartments } from '../api/departmentsApi.js';
import {
createCalendar,
updateCalendar,
deleteCalendar,
} from "../api/calendarsApi.js";
import { getMyDepartments } from "../api/departmentsApi.js";
/**
* Инициализация модального окна управления календарём
@ -15,29 +19,31 @@ import { getMyDepartments } from '../api/departmentsApi.js';
export function initCalendarManageModal(options = {}) {
const { onSuccess = () => {} } = options;
const modal = document.getElementById('calendarManageModal');
const modal = document.getElementById("calendarManageModal");
if (!modal) {
console.warn('[CalendarManageModal] Modal element not found');
console.warn("[CalendarManageModal] Modal element not found");
return null;
}
const bsModal = new bootstrap.Modal(modal);
// Elements
const form = document.getElementById('calendarManageForm');
const titleEl = document.getElementById('calendarManageModalTitle');
const calendarIdInput = document.getElementById('calendarId');
const titleInput = document.getElementById('calendarTitle');
const descInput = document.getElementById('calendarDescription');
const colorInput = document.getElementById('calendarColor');
const colorTextInput = document.getElementById('calendarColorText');
const form = document.getElementById("calendarManageForm");
const titleEl = document.getElementById("calendarManageModalTitle");
const calendarIdInput = document.getElementById("calendarId");
const titleInput = document.getElementById("calendarTitle");
const descInput = document.getElementById("calendarDescription");
const colorInput = document.getElementById("calendarColor");
const colorTextInput = document.getElementById("calendarColorText");
const typeInputs = document.querySelectorAll('input[name="calendar_type"]');
const deptSelect = document.getElementById('calendarDepartment');
const deptSelectWrapper = document.getElementById('calendarDepartmentSelect');
const autoSubscribeDeptWrapper = document.getElementById('calendarAutoSubscribeDeptWrapper');
const saveBtn = document.getElementById('calendarSaveBtn');
const deleteBtn = document.getElementById('calendarDeleteBtn');
const errorEl = document.getElementById('calendarManageError');
const deptSelect = document.getElementById("calendarDepartment");
const deptSelectWrapper = document.getElementById("calendarDepartmentSelect");
const autoSubscribeDeptWrapper = document.getElementById(
"calendarAutoSubscribeDeptWrapper",
);
const saveBtn = document.getElementById("calendarSaveBtn");
const deleteBtn = document.getElementById("calendarDeleteBtn");
const errorEl = document.getElementById("calendarManageError");
let currentCalendar = null;
@ -46,8 +52,8 @@ export function initCalendarManageModal(options = {}) {
*/
function openForCreate() {
currentCalendar = null;
titleEl.textContent = 'Создать календарь';
deleteBtn.classList.add('d-none');
titleEl.textContent = "Создать календарь";
deleteBtn.classList.add("d-none");
resetForm();
bsModal.show();
}
@ -58,39 +64,39 @@ export function initCalendarManageModal(options = {}) {
*/
function openForEdit(calendar) {
currentCalendar = calendar;
titleEl.textContent = 'Редактировать календарь';
deleteBtn.classList.remove('d-none');
titleEl.textContent = "Редактировать календарь";
deleteBtn.classList.remove("d-none");
// Заполнить форму
calendarIdInput.value = calendar.id;
titleInput.value = calendar.title;
descInput.value = calendar.description || '';
colorInput.value = calendar.color || '#0d6efd';
colorTextInput.value = calendar.color || '#0d6efd';
descInput.value = calendar.description || "";
colorInput.value = calendar.color || "#0d6efd";
colorTextInput.value = calendar.color || "#0d6efd";
// Определить тип
let type = 'personal';
let type = "personal";
if (calendar.is_global) {
type = 'public';
type = "public";
} else if (calendar.is_department) {
type = 'department';
type = "department";
}
typeInputs.forEach(input => {
typeInputs.forEach((input) => {
input.checked = input.value === type;
});
// Если отдел, выбрать его
if (calendar.owner_department) {
deptSelect.value = calendar.owner_department;
}
// Настройки
document.getElementById('calendarAutoSubscribeNewUsers').checked =
document.getElementById("calendarAutoSubscribeNewUsers").checked =
calendar.auto_subscribe_new_users || false;
document.getElementById('calendarAutoSubscribeDept').checked =
document.getElementById("calendarAutoSubscribeDept").checked =
calendar.auto_subscribe_department_members || false;
updateTypeVisibility();
bsModal.show();
}
@ -100,10 +106,10 @@ export function initCalendarManageModal(options = {}) {
*/
function resetForm() {
form.reset();
calendarIdInput.value = '';
colorInput.value = '#0d6efd';
colorTextInput.value = '#0d6efd';
errorEl.classList.add('d-none');
calendarIdInput.value = "";
colorInput.value = "#0d6efd";
colorTextInput.value = "#0d6efd";
errorEl.classList.add("d-none");
updateTypeVisibility();
}
@ -111,14 +117,16 @@ export function initCalendarManageModal(options = {}) {
* Обновить видимость полей в зависимости от типа
*/
function updateTypeVisibility() {
const selectedType = document.querySelector('input[name="calendar_type"]:checked')?.value;
if (selectedType === 'department') {
deptSelectWrapper.classList.remove('d-none');
autoSubscribeDeptWrapper.classList.remove('d-none');
const selectedType = document.querySelector(
'input[name="calendar_type"]:checked',
)?.value;
if (selectedType === "department") {
deptSelectWrapper.classList.remove("d-none");
autoSubscribeDeptWrapper.classList.remove("d-none");
} else {
deptSelectWrapper.classList.add('d-none');
autoSubscribeDeptWrapper.classList.add('d-none');
deptSelectWrapper.classList.add("d-none");
autoSubscribeDeptWrapper.classList.add("d-none");
}
}
@ -128,15 +136,15 @@ export function initCalendarManageModal(options = {}) {
async function loadDepartments() {
try {
const departments = await getMyDepartments();
// Заполнить select
deptSelect.innerHTML = '<option value="">Выберите отдел...</option>' +
departments.map(dept =>
`<option value="${dept.id}">${dept.name}</option>`
).join('');
deptSelect.innerHTML =
'<option value="">Выберите отдел...</option>' +
departments
.map((dept) => `<option value="${dept.id}">${dept.name}</option>`)
.join("");
} catch (error) {
console.error('[CalendarManageModal] Failed to load departments:', error);
console.error("[CalendarManageModal] Failed to load departments:", error);
}
}
@ -149,26 +157,32 @@ export function initCalendarManageModal(options = {}) {
title: titleInput.value.trim(),
description: descInput.value.trim(),
color: colorInput.value,
auto_subscribe_new_users: document.getElementById('calendarAutoSubscribeNewUsers').checked,
auto_subscribe_department_members: document.getElementById('calendarAutoSubscribeDept').checked
auto_subscribe_new_users: document.getElementById(
"calendarAutoSubscribeNewUsers",
).checked,
auto_subscribe_department_members: document.getElementById(
"calendarAutoSubscribeDept",
).checked,
};
const type = document.querySelector('input[name="calendar_type"]:checked')?.value;
if (type === 'personal') {
data.visibility = 'private';
const type = document.querySelector(
'input[name="calendar_type"]:checked',
)?.value;
if (type === "personal") {
data.visibility = "private";
// owner_user будет установлен автоматически на backend
} else if (type === 'public') {
data.visibility = 'public';
} else if (type === 'department') {
data.visibility = 'department';
} else if (type === "public") {
data.visibility = "public";
} else if (type === "department") {
data.visibility = "department";
data.owner_department = parseInt(deptSelect.value);
if (!data.owner_department) {
throw new Error('Выберите отдел');
throw new Error("Выберите отдел");
}
}
return data;
}
@ -177,11 +191,11 @@ export function initCalendarManageModal(options = {}) {
*/
async function save() {
try {
errorEl.classList.add('d-none');
errorEl.classList.add("d-none");
saveBtn.disabled = true;
const data = getFormData();
if (currentCalendar) {
// Обновление
await updateCalendar(currentCalendar.id, data);
@ -189,14 +203,13 @@ export function initCalendarManageModal(options = {}) {
// Создание
await createCalendar(data);
}
bsModal.hide();
onSuccess();
} catch (error) {
console.error('[CalendarManageModal] Save failed:', error);
errorEl.textContent = error.message || 'Не удалось сохранить календарь';
errorEl.classList.remove('d-none');
console.error("[CalendarManageModal] Save failed:", error);
errorEl.textContent = error.message || "Не удалось сохранить календарь";
errorEl.classList.remove("d-none");
} finally {
saveBtn.disabled = false;
}
@ -207,58 +220,62 @@ export function initCalendarManageModal(options = {}) {
*/
async function remove() {
if (!currentCalendar) return;
if (!confirm(`Удалить календарь "${currentCalendar.title}"?\n\nВсе события этого календаря будут удалены.`)) {
if (
!confirm(
`Удалить календарь "${currentCalendar.title}"?\n\nВсе события этого календаря будут удалены.`,
)
) {
return;
}
try {
deleteBtn.disabled = true;
await deleteCalendar(currentCalendar.id);
bsModal.hide();
onSuccess();
} catch (error) {
console.error('[CalendarManageModal] Delete failed:', error);
errorEl.textContent = error.message || 'Не удалось удалить календарь';
errorEl.classList.remove('d-none');
console.error("[CalendarManageModal] Delete failed:", error);
errorEl.textContent = error.message || "Не удалось удалить календарь";
errorEl.classList.remove("d-none");
} finally {
deleteBtn.disabled = false;
}
}
// Event listeners
// Синхронизация цвета
colorInput.addEventListener('input', (e) => {
colorInput.addEventListener("input", (e) => {
colorTextInput.value = e.target.value;
});
colorTextInput.addEventListener('input', (e) => {
colorTextInput.addEventListener("input", (e) => {
const value = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
colorInput.value = value;
}
});
// Изменение типа календаря
typeInputs.forEach(input => {
input.addEventListener('change', updateTypeVisibility);
typeInputs.forEach((input) => {
input.addEventListener("change", updateTypeVisibility);
});
// Сохранить
saveBtn.addEventListener('click', save);
saveBtn.addEventListener("click", save);
// Удалить
deleteBtn.addEventListener('click', remove);
deleteBtn.addEventListener("click", remove);
// Сброс при закрытии
modal.addEventListener('hidden.bs.modal', resetForm);
modal.addEventListener("hidden.bs.modal", resetForm);
// Слушать событие редактирования из других компонентов
document.addEventListener('calendar:edit', (e) => {
document.addEventListener("calendar:edit", (e) => {
openForEdit(e.detail);
});
// Инициализация
loadDepartments();
@ -267,6 +284,6 @@ export function initCalendarManageModal(options = {}) {
openForCreate,
openForEdit,
show: () => bsModal.show(),
hide: () => bsModal.hide()
hide: () => bsModal.hide(),
};
}

View File

@ -316,7 +316,9 @@ export function initCalendarManager(options = {}) {
// НЕ парсим ID - он может быть строкой (legacy) или числом (новые календари)
const calendarId = e.target.dataset.calendarId;
// Конвертируем в число ТОЛЬКО если это число
const id = /^\d+$/.test(calendarId) ? parseInt(calendarId, 10) : calendarId;
const id = /^\d+$/.test(calendarId)
? parseInt(calendarId, 10)
: calendarId;
toggleCalendarVisibility(id);
});
});

View File

@ -1007,21 +1007,35 @@ export function initCalendarWidget(options = {}) {
if (val != null) deptSel.value = String(val);
}
// Заполняем селектор календарей
populateCalendarSelector();
// Заполняем чекбоксы календарей
populateCalendarCheckboxes();
// Устанавливаем текущий календарь в селекторе
const targetSelect = document.getElementById("targetCalendarSelect");
if (targetSelect && data) {
// При редактировании отмечаем только текущий календарь события
const checkboxes = document.querySelectorAll(
'input[name="target_calendars"]',
);
if (checkboxes.length > 0 && data) {
// Сначала снимаем все галочки
checkboxes.forEach((cb) => (cb.checked = false));
// Отмечаем текущий календарь
let targetValue = null;
if (data.calendar_id) {
targetSelect.value = data.calendar_id;
targetValue = data.calendar_id.toString();
} else if (data.employee_id) {
targetSelect.value = "personal";
targetValue = "personal";
} else if (data.department_id) {
targetSelect.value = `dept-${data.department_id}`;
targetValue = `dept-${data.department_id}`;
} else {
targetSelect.value = "company";
targetValue = "company";
}
// Ищем и отмечаем нужный чекбокс
checkboxes.forEach((cb) => {
if (cb.value === targetValue) {
cb.checked = true;
}
});
}
// Открываем форму редактирования
@ -1037,69 +1051,105 @@ export function initCalendarWidget(options = {}) {
form.querySelector('[name="title"]')?.focus();
}
/* ===== Функция заполнения селектора календарей ===== */
function populateCalendarSelector() {
const select = document.getElementById("targetCalendarSelect");
if (!select) return;
/* ===== Функция заполнения чекбоксов календарей ===== */
function populateCalendarCheckboxes() {
const container = document.getElementById("targetCalendarCheckboxes");
if (!container) return;
// Очищаем селект
select.innerHTML = "";
// Очищаем контейнер
container.innerHTML = "";
// Добавляем базовые опции (legacy)
const companyOption = document.createElement("option");
companyOption.value = "company";
companyOption.textContent = "Компания (общие события)";
select.appendChild(companyOption);
let checkboxIndex = 0;
const personalOption = document.createElement("option");
personalOption.value = "personal";
personalOption.textContent = "Личный календарь";
select.appendChild(personalOption);
// Функция создания чекбокса
function createCheckbox(value, label, checked = false, color = null) {
const div = document.createElement("div");
div.className = "form-check";
const input = document.createElement("input");
input.className = "form-check-input";
input.type = "checkbox";
input.value = value;
input.id = `cal-checkbox-${checkboxIndex++}`;
input.name = "target_calendars";
input.checked = checked;
const labelEl = document.createElement("label");
labelEl.className = "form-check-label";
labelEl.htmlFor = input.id;
// Добавляем цветовой индикатор, если указан цвет
if (color) {
const colorSpan = document.createElement("span");
colorSpan.style.display = "inline-block";
colorSpan.style.width = "12px";
colorSpan.style.height = "12px";
colorSpan.style.backgroundColor = color;
colorSpan.style.borderRadius = "2px";
colorSpan.style.marginRight = "6px";
colorSpan.style.verticalAlign = "middle";
labelEl.appendChild(colorSpan);
}
labelEl.appendChild(document.createTextNode(label));
div.appendChild(input);
div.appendChild(labelEl);
return div;
}
// Добавляем базовые чекбоксы (legacy)
container.appendChild(
createCheckbox("company", "Компания (общие события)", true, "#0d6efd"),
);
container.appendChild(
createCheckbox("personal", "Личный календарь", false, "#198754"),
);
// Добавляем отделы, если есть
if (window.departments && Array.isArray(window.departments)) {
window.departments.forEach((dept) => {
const deptOption = document.createElement("option");
deptOption.value = `dept-${dept.id}`;
deptOption.textContent = `Отдел: ${dept.name}`;
select.appendChild(deptOption);
container.appendChild(
createCheckbox(
`dept-${dept.id}`,
`Отдел: ${dept.name}`,
false,
"#dc3545",
),
);
});
}
// Добавляем новые календари из интеграции
// Добавляем разделитель, если есть новые календари
if (window.calendarIntegration?.calendars) {
const newCalendars = window.calendarIntegration.calendars.filter(
(cal) => !cal.id.toString().startsWith("legacy-")
(cal) => !cal.id.toString().startsWith("legacy-"),
);
if (newCalendars.length > 0) {
// Добавляем разделитель
const separator = document.createElement("option");
separator.disabled = true;
separator.textContent = "─────────────────";
select.appendChild(separator);
// Разделительная линия
const hr = document.createElement("hr");
hr.className = "my-2";
container.appendChild(hr);
const header = document.createElement("small");
header.className = "text-muted d-block mb-1";
header.textContent = "Мои календари:";
container.appendChild(header);
// Добавляем новые календари
newCalendars.forEach((cal) => {
const calOption = document.createElement("option");
calOption.value = cal.id;
calOption.textContent = `📅 ${cal.name}`;
if (cal.description) {
calOption.title = cal.description;
}
select.appendChild(calOption);
container.appendChild(
createCheckbox(
cal.id,
`📅 ${cal.name}`,
false,
cal.color || "#6c757d",
),
);
});
}
}
// Устанавливаем значение по умолчанию в зависимости от текущего состояния
if (state.type === "personal") {
select.value = "personal";
} else if (state.type === "dept" && state.deptId) {
select.value = `dept-${state.deptId}`;
} else {
select.value = "company";
}
}
/* ===== Обработчики событий календаря ===== */
@ -1123,6 +1173,24 @@ export function initCalendarWidget(options = {}) {
height: "auto",
displayEventTime: false,
selectable: true, // Позволяет выбирать даты
// Улучшенное отображение накладывающихся событий
dayMaxEvents: true, // Автоматически показывать "+N еще" когда много событий
dayMaxEventRows: 4, // Максимум 4 строки событий на день
moreLinkClick: "popover", // Показывать popover при клике на "+N еще"
eventMaxStack: 3, // Максимум 3 события в стеке
// Улучшенный порядок событий
eventOrder: ["start", "-duration", "title"], // Сортировка: раньше начало, длиннее, по алфавиту
// Настройки отображения
eventDisplay: "block", // Блочное отображение для лучшей видимости
eventTimeFormat: {
hour: "2-digit",
minute: "2-digit",
meridiem: false,
},
events: async (info, success, failure) => {
try {
let raw;
@ -1145,11 +1213,20 @@ export function initCalendarWidget(options = {}) {
// Нормализуем id и цвет для FullCalendar
const mapped = (Array.isArray(raw) ? raw : []).map((ev) => {
const id = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id;
const color = ev.color || ev.bgColor || null;
const eventColor = ev.color || ev.bgColor || null;
const calendarColor = ev.__calendar?.color || null;
return {
...ev,
...(id != null ? { id } : {}),
...(color ? { backgroundColor: color, borderColor: color } : {}),
...(eventColor
? { backgroundColor: eventColor, borderColor: eventColor }
: {}),
extendedProps: {
...(ev.extendedProps || {}),
...ev, // Копируем все свойства события
calendar_color: calendarColor, // Добавляем цвет календаря
},
};
});
success(mapped);
@ -1166,31 +1243,94 @@ export function initCalendarWidget(options = {}) {
return { domNodes: [el] };
},
eventDidMount: (info) => {
const col = info.event.backgroundColor || info.event.extendedProps?.color;
if (col) {
info.el.style.backgroundColor = col;
info.el.style.borderColor = col;
info.el.style.color = "var(--bs-body-bg)";
// Получаем цвет события и цвет календаря
const eventColor =
info.event.backgroundColor ||
info.event.extendedProps?.color ||
"#0d6efd";
const calendarColor = info.event.extendedProps?.calendar_color || null;
// Применяем современный стиль с двойным цветом
const eventEl = info.el;
if (calendarColor) {
// Если есть цвет календаря - показываем оба цвета
// Левая граница = цвет календаря, фон = цвет события (полупрозрачный)
eventEl.style.borderLeft = `4px solid ${calendarColor}`;
eventEl.style.backgroundColor = eventColor + "CC"; // 80% прозрачности
eventEl.style.borderColor = eventColor;
// Добавляем тень для объёма
eventEl.style.boxShadow =
"0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)";
} else {
// Если нет цвета календаря - используем только цвет события
eventEl.style.backgroundColor = eventColor;
eventEl.style.borderColor = eventColor;
eventEl.style.borderLeft = `3px solid ${eventColor}`;
eventEl.style.boxShadow = "0 1px 2px rgba(0,0,0,0.1)";
}
// Улучшаем читаемость текста
eventEl.style.color = "#ffffff";
eventEl.style.textShadow = "0 1px 2px rgba(0,0,0,0.2)";
// Закругляем углы для современного вида
eventEl.style.borderRadius = "4px";
eventEl.style.overflow = "hidden";
// Добавляем отступы
eventEl.style.paddingLeft = "6px";
eventEl.style.paddingRight = "4px";
// Hover эффект
eventEl.addEventListener("mouseenter", () => {
eventEl.style.transform = "translateY(-1px)";
eventEl.style.boxShadow =
"0 4px 8px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.12)";
eventEl.style.transition = "all 0.2s ease";
eventEl.style.zIndex = "100";
});
eventEl.addEventListener("mouseleave", () => {
eventEl.style.transform = "translateY(0)";
if (calendarColor) {
eventEl.style.boxShadow =
"0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)";
} else {
eventEl.style.boxShadow = "0 1px 2px rgba(0,0,0,0.1)";
}
eventEl.style.transition = "all 0.2s ease";
});
// Особый стиль для ежегодных событий
if (info.event.extendedProps?.recurrence === "annual") {
info.el.style.outline = "1px dashed currentColor";
info.el.style.outlineOffset = "-2px";
eventEl.style.backgroundImage = `repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(255,255,255,0.1) 10px,
rgba(255,255,255,0.1) 20px
)`;
}
// Стилизация текста события
const t = info.el.querySelector(".fc-event-main, .fc-event-title");
if (t) {
const s = t.style;
s.fontSize = "10px";
s.lineHeight = "1.02";
s.letterSpacing = "-0.2px";
s.fontSize = "11px";
s.lineHeight = "1.3";
s.letterSpacing = "0";
s.display = "-webkit-box";
s.webkitLineClamp = "3";
s.webkitBoxOrient = "vertical";
s.overflow = "hidden";
s.whiteSpace = "normal";
s.textOverflow = "ellipsis";
s.fontWeight = "600";
s.fontWeight = "500";
s.fontFamily =
'"Arial Narrow","Roboto Condensed",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif';
'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
s.padding = "2px 0";
}
// Добавляем обработчик правой кнопки мыши
@ -1265,8 +1405,8 @@ export function initCalendarWidget(options = {}) {
typeof syncUntilCount === "function" && syncUntilCount();
} catch (_) {}
// Заполняем селектор календарей
populateCalendarSelector();
// Заполняем чекбоксы календарей
populateCalendarCheckboxes();
// Открываем модал
const createModalEl = document.getElementById("eventCreateModal");
@ -1473,36 +1613,17 @@ export function initCalendarWidget(options = {}) {
payload.weekdays = weekdays;
}
// Получаем выбранный календарь из формы
const targetCalendar = fd.get("target_calendar");
// Получаем выбранные календари из чекбоксов
const selectedCalendars = [];
const checkboxes = document.querySelectorAll(
'input[name="target_calendars"]:checked',
);
checkboxes.forEach((cb) => selectedCalendars.push(cb.value));
// Определяем тип календаря и добавляем соответствующие параметры
if (targetCalendar && targetCalendar !== "company") {
if (targetCalendar === "personal") {
// Личный календарь - получаем employee_id
const userMeta = document.querySelector('meta[name="user-id"]');
const currentEmployeeId = userMeta
? parseInt(userMeta.content, 10)
: null;
if (!currentEmployeeId) {
alert("Не удалось определить ID пользователя");
return;
}
payload.employee_id = currentEmployeeId;
} else if (targetCalendar.startsWith("dept-")) {
// Календарь отдела
const deptId = parseInt(targetCalendar.replace("dept-", ""), 10);
if (isNaN(deptId)) {
alert("Некорректный отдел");
return;
}
payload.department_id = deptId;
} else if (/^\d+$/.test(targetCalendar)) {
// Новый календарь - используем calendar_id
payload.calendar_id = parseInt(targetCalendar, 10);
}
if (selectedCalendars.length === 0) {
alert("Выберите хотя бы один календарь для создания события");
return;
}
// Если targetCalendar === "company" или null, то это общие события компании (без дополнительных параметров)
const postHeaders = {
"Content-Type": "application/json",
@ -1516,15 +1637,99 @@ export function initCalendarWidget(options = {}) {
try {
const isEdit = form.dataset.mode === "edit" && form.dataset.eventId;
const url = isEdit
? API_EVENTS + String(form.dataset.eventId) + "/"
: API_EVENTS;
const method = isEdit ? "PATCH" : "POST";
await fetchJSON(url, {
method,
headers: postHeaders,
body: JSON.stringify(payload),
});
if (isEdit) {
// При редактировании - обновляем только первый выбранный календарь
const targetCalendar = selectedCalendars[0];
// Определяем тип календаря и добавляем соответствующие параметры
if (targetCalendar !== "company") {
if (targetCalendar === "personal") {
// Личный календарь - получаем employee_id
const userMeta = document.querySelector('meta[name="user-id"]');
const currentEmployeeId = userMeta
? parseInt(userMeta.content, 10)
: null;
if (!currentEmployeeId) {
alert("Не удалось определить ID пользователя");
return;
}
payload.employee_id = currentEmployeeId;
// Очищаем другие поля
payload.department_id = null;
payload.calendar_id = null;
} else if (targetCalendar.startsWith("dept-")) {
// Календарь отдела
const deptId = parseInt(targetCalendar.replace("dept-", ""), 10);
if (isNaN(deptId)) {
alert("Некорректный отдел");
return;
}
payload.department_id = deptId;
// Очищаем другие поля
payload.employee_id = null;
payload.calendar_id = null;
} else if (/^\d+$/.test(targetCalendar)) {
// Новый календарь - используем calendar_id
payload.calendar_id = parseInt(targetCalendar, 10);
// Очищаем другие поля
payload.employee_id = null;
payload.department_id = null;
}
} else {
// Company - очищаем все специфичные поля
payload.employee_id = null;
payload.department_id = null;
payload.calendar_id = null;
}
const url = API_EVENTS + String(form.dataset.eventId) + "/";
await fetchJSON(url, {
method: "PATCH",
headers: postHeaders,
body: JSON.stringify(payload),
});
} else {
// При создании - создаем событие для каждого выбранного календаря
const createPromises = selectedCalendars.map((targetCalendar) => {
const eventPayload = { ...payload };
// Определяем тип календаря и добавляем соответствующие параметры
if (targetCalendar !== "company") {
if (targetCalendar === "personal") {
// Личный календарь
const userMeta = document.querySelector('meta[name="user-id"]');
const currentEmployeeId = userMeta
? parseInt(userMeta.content, 10)
: null;
if (!currentEmployeeId) {
throw new Error("Не удалось определить ID пользователя");
}
eventPayload.employee_id = currentEmployeeId;
} else if (targetCalendar.startsWith("dept-")) {
// Календарь отдела
const deptId = parseInt(targetCalendar.replace("dept-", ""), 10);
if (isNaN(deptId)) {
throw new Error("Некорректный отдел");
}
eventPayload.department_id = deptId;
} else if (/^\d+$/.test(targetCalendar)) {
// Новый календарь - используем calendar_id
eventPayload.calendar_id = parseInt(targetCalendar, 10);
}
}
// Если targetCalendar === "company", то без дополнительных параметров
return fetchJSON(API_EVENTS, {
method: "POST",
headers: postHeaders,
body: JSON.stringify(eventPayload),
});
});
// Ждем создания всех событий
await Promise.all(createPromises);
}
document.querySelector("#eventCreateModal .btn-close")?.click();
form.reset();

View File

@ -82,7 +82,9 @@ export function integrateCalendarManager(calendarWidgetInstance, options = {}) {
} else if (legacyId === "legacy-personal") {
// Личный: события текущего сотрудника
if (!currentEmployeeId) {
console.warn("[CalendarIntegration] Cannot load personal events: currentEmployeeId not found");
console.warn(
"[CalendarIntegration] Cannot load personal events: currentEmployeeId not found",
);
continue;
}
console.log(

View File

@ -10,21 +10,21 @@
max-height: 400px;
overflow-y: auto;
padding: var(--space-2);
// Кастомный scrollbar
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
@ -42,24 +42,24 @@
padding: var(--space-2);
border-radius: var(--radius-md);
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
// Чекбокс и лейбл
.form-check {
flex: 1;
min-width: 0;
margin-bottom: 0;
}
.form-check-label {
cursor: pointer;
user-select: none;
min-width: 0;
}
// Цветовой индикатор
.calendar-color-indicator {
display: inline-block;
@ -69,20 +69,20 @@
flex-shrink: 0;
border: 2px solid rgba(255, 255, 255, 0.2);
}
// Иконка календаря
.calendar-icon {
flex-shrink: 0;
font-size: 0.9rem;
}
// Название календаря
.calendar-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Действия
.calendar-list-item-actions {
display: flex;
@ -91,17 +91,17 @@
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover .calendar-list-item-actions {
opacity: 1;
}
// Кнопки действий
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
line-height: 1;
i {
font-size: 0.875rem;
}
@ -113,7 +113,7 @@
// ──────────────────────────────────────────────────────────────
#calendarChooserBtn {
position: relative;
&::after {
margin-left: 0.5rem;
}
@ -135,14 +135,14 @@
text-align: center;
padding: var(--space-4);
color: var(--bs-secondary);
i {
font-size: 2rem;
display: block;
margin-bottom: var(--space-2);
opacity: 0.5;
}
small {
font-size: 0.875rem;
}
@ -155,12 +155,12 @@
// Компактный вид
.calendar-list-item--compact {
padding: var(--space-1) var(--space-2);
.calendar-color-indicator {
width: 10px;
height: 10px;
}
.calendar-icon {
font-size: 0.8rem;
}
@ -179,7 +179,7 @@
.calendar-list-container {
max-height: 300px;
}
.calendar-list-item-actions {
opacity: 1; // На мобильных всегда показываем кнопки
}

View File

@ -1,2 +1,3 @@
{% load static %}
<link rel="stylesheet" href="{% static 'css/components/rightbar-calendar-fullcalendar.css' %}">
<link rel="stylesheet" href="{% static 'css/calendar-improvements.css' %}">

View File

@ -7,12 +7,12 @@
<h6 class="card-title fw-semibold mb-0">Календарь</h6>
{# Кнопка списка календарей #}
<button
class="btn btn-sm btn-outline-primary"
<button
class="btn btn-sm btn-outline-primary"
type="button"
data-bs-toggle="collapse"
data-bs-toggle="collapse"
data-bs-target="#calendarListCollapse"
aria-expanded="false"
aria-expanded="false"
aria-controls="calendarListCollapse"
title="Список календарей"
>
@ -23,19 +23,19 @@
<div class="d-flex align-items-center gap-2 flex-shrink-0">
<span class="card-sub text-muted d-none d-xl-inline">События</span>
<button
class="btn btn-sm btn-outline-success"
type="button"
<button
class="btn btn-sm btn-outline-success"
type="button"
data-action="create-calendar"
aria-label="Создать календарь"
title="Создать календарь"
>
<i class="bi-calendar-plus"></i>
</button>
<button
class="btn btn-sm btn-primary"
type="button"
data-bs-toggle="modal"
<button
class="btn btn-sm btn-primary"
type="button"
data-bs-toggle="modal"
data-bs-target="#eventCreateModal"
aria-label="Создать событие"
title="Создать событие"

View File

@ -120,12 +120,26 @@
</div>
<div class="mt-3">
<label class="form-label">Календарь</label>
<select class="form-select" name="target_calendar" id="targetCalendarSelect">
<option value="company" selected>Компания (общие события)</option>
<option value="personal">Личный календарь</option>
</select>
<div class="form-text">События создаются в выбранном календаре</div>
<label class="form-label d-flex align-items-center justify-content-between">
<span>Календари</span>
<small class="text-muted">Можно выбрать несколько</small>
</label>
<div id="targetCalendarCheckboxes" class="border rounded p-2" style="max-height: 200px; overflow-y: auto;">
<!-- Чекбоксы будут добавлены динамически через JS -->
<div class="form-check">
<input class="form-check-input" type="checkbox" value="company" id="cal-company" name="target_calendars" checked>
<label class="form-check-label" for="cal-company">
Компания (общие события)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="personal" id="cal-personal" name="target_calendars">
<label class="form-check-label" for="cal-personal">
Личный календарь
</label>
</div>
</div>
<div class="form-text">Событие будет создано во всех выбранных календарях</div>
</div>
</div>

View File

@ -9,54 +9,54 @@
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<form id="calendarManageForm">
<input type="hidden" id="calendarId" name="calendar_id" value="">
{# Название #}
<div class="mb-3">
<label for="calendarTitle" class="form-label">
Название <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
id="calendarTitle"
<input
type="text"
class="form-control"
id="calendarTitle"
name="title"
required
required
placeholder="Например: Мой календарь"
maxlength="200"
>
</div>
{# Описание #}
<div class="mb-3">
<label for="calendarDescription" class="form-label">Описание</label>
<textarea
class="form-control"
id="calendarDescription"
<textarea
class="form-control"
id="calendarDescription"
name="description"
rows="3"
rows="3"
placeholder="Краткое описание календаря..."
></textarea>
</div>
{# Цвет #}
<div class="mb-3">
<label for="calendarColor" class="form-label">Цвет</label>
<div class="input-group">
<input
type="color"
class="form-control form-control-color"
id="calendarColor"
<input
type="color"
class="form-control form-control-color"
id="calendarColor"
name="color"
value="#0d6efd"
title="Выберите цвет календаря"
>
<input
type="text"
class="form-control"
<input
type="text"
class="form-control"
id="calendarColorText"
value="#0d6efd"
pattern="^#[0-9A-Fa-f]{6}$"
@ -65,17 +65,17 @@
</div>
<div class="form-text">Используется для выделения событий</div>
</div>
{# Тип календаря #}
<div class="mb-3">
<label class="form-label">Тип календаря <span class="text-danger">*</span></label>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="calendar_type"
id="calendarTypePersonal"
<input
class="form-check-input"
type="radio"
name="calendar_type"
id="calendarTypePersonal"
value="personal"
checked
>
@ -85,13 +85,13 @@
</label>
<div class="form-text ms-4">Только вы можете видеть и редактировать</div>
</div>
<div class="form-check mt-2">
<input
class="form-check-input"
type="radio"
name="calendar_type"
id="calendarTypePublic"
<input
class="form-check-input"
type="radio"
name="calendar_type"
id="calendarTypePublic"
value="public"
>
<label class="form-check-label" for="calendarTypePublic">
@ -100,13 +100,13 @@
</label>
<div class="form-text ms-4">Все пользователи могут видеть</div>
</div>
<div class="form-check mt-2">
<input
class="form-check-input"
type="radio"
name="calendar_type"
id="calendarTypeDepartment"
<input
class="form-check-input"
type="radio"
name="calendar_type"
id="calendarTypeDepartment"
value="department"
>
<label class="form-check-label" for="calendarTypeDepartment">
@ -116,7 +116,7 @@
<div class="form-text ms-4">Члены отдела могут видеть</div>
</div>
</div>
{# Выбор отдела (показывается только для типа "department") #}
<div class="mb-3 d-none" id="calendarDepartmentSelect">
<label for="calendarDepartment" class="form-label">
@ -127,28 +127,28 @@
{# Будет заполнено из JS #}
</select>
</div>
{# Дополнительные настройки #}
<div class="mb-3">
<label class="form-label">Дополнительные настройки</label>
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="calendarAutoSubscribeNewUsers"
<input
class="form-check-input"
type="checkbox"
id="calendarAutoSubscribeNewUsers"
name="auto_subscribe_new_users"
>
<label class="form-check-label" for="calendarAutoSubscribeNewUsers">
Автоматически подписывать новых пользователей
</label>
</div>
<div class="form-check form-switch mt-2 d-none" id="calendarAutoSubscribeDeptWrapper">
<input
class="form-check-input"
type="checkbox"
id="calendarAutoSubscribeDept"
<input
class="form-check-input"
type="checkbox"
id="calendarAutoSubscribeDept"
name="auto_subscribe_department_members"
>
<label class="form-check-label" for="calendarAutoSubscribeDept">
@ -156,12 +156,12 @@
</label>
</div>
</div>
{# Сообщение об ошибке #}
<div id="calendarManageError" class="alert alert-danger d-none" role="alert"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-danger d-none" id="calendarDeleteBtn">

View File

@ -4,15 +4,15 @@
<script type="module">
import { initCalendarWidget } from '{% static "js/components/calendarWidget.js" %}';
import { integrateCalendarManager } from '{% static "js/components/calendarWidgetIntegration.js" %}';
// Функция инициализации интеграции
function initializeIntegration() {
// Проверяем, есть ли уже виджет
const widget = window.calendarWidget || initCalendarWidget();
if (widget) {
window.calendarWidget = widget;
// Интеграция с менеджером календарей
const integration = integrateCalendarManager(widget);
if (integration) {
@ -26,7 +26,7 @@
setTimeout(initializeIntegration, 500);
}
}
// Инициализация после загрузки DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeIntegration);

View File

@ -4,20 +4,20 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar Frontend Test</title>
<!-- Bootstrap CSS (offline fallback) -->
<style>
@import url('https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css');
@import url('https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css');
</style>
<!-- Inline styles as fallback -->
<style>
body {
padding: 20px;
background: #f8f9fa;
}
.test-section {
background: white;
border-radius: 8px;
@ -25,7 +25,7 @@
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.test-header {
display: flex;
justify-content: space-between;
@ -34,18 +34,18 @@
padding-bottom: 15px;
border-bottom: 2px solid #e9ecef;
}
.status-badge {
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.status-pending { background: #ffc107; color: #000; }
.status-success { background: #28a745; color: #fff; }
.status-error { background: #dc3545; color: #fff; }
.log-console {
background: #2d2d2d;
color: #f8f8f2;
@ -57,24 +57,24 @@
overflow-y: auto;
margin-top: 15px;
}
.log-entry {
margin: 3px 0;
padding: 2px 0;
}
.log-info { color: #66d9ef; }
.log-success { color: #a6e22e; }
.log-error { color: #f92672; }
.log-warn { color: #e6db74; }
#calendarContainer {
min-height: 600px;
background: white;
padding: 20px;
border-radius: 8px;
}
.calendar-list-container {
max-height: 400px;
overflow-y: auto;
@ -83,7 +83,7 @@
padding: 10px;
background: white;
}
.calendar-list-item {
padding: 10px;
margin-bottom: 8px;
@ -94,12 +94,12 @@
gap: 10px;
transition: all 0.2s;
}
.calendar-list-item:hover {
background: #f8f9fa;
border-color: #0d6efd;
}
.calendar-color-indicator {
width: 12px;
height: 12px;
@ -107,12 +107,12 @@
border: 2px solid rgba(0,0,0,0.1);
flex-shrink: 0;
}
.calendar-list-item-info {
flex: 1;
min-width: 0;
}
.calendar-list-item-title {
font-weight: 600;
font-size: 14px;
@ -121,13 +121,13 @@
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-list-item-meta {
font-size: 12px;
color: #6c757d;
margin: 2px 0 0 0;
}
.calendar-list-item-actions {
display: flex;
gap: 5px;
@ -138,7 +138,7 @@
<body>
<div class="container-fluid">
<h1 class="mb-4">🧪 Calendar Frontend Test Suite</h1>
<!-- Test Controls -->
<div class="test-section">
<div class="test-header">
@ -152,7 +152,7 @@
</button>
</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<button id="testApiWrapper" class="btn btn-outline-primary w-100">
@ -186,7 +186,7 @@
</div>
</div>
</div>
<!-- Test Results -->
<div class="test-section">
<div class="test-header">
@ -195,7 +195,7 @@
<span class="badge bg-secondary">Ready</span>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm">
<thead>
@ -214,7 +214,7 @@
</table>
</div>
</div>
<!-- Calendar UI Demo -->
<div class="test-section">
<div class="test-header">
@ -223,7 +223,7 @@
<i class="bi-plus-lg"></i> Create Calendar
</button>
</div>
<div class="row">
<div class="col-md-3">
<h5>My Calendars</h5>
@ -241,7 +241,7 @@
</div>
</div>
</div>
<!-- Console Log -->
<div class="test-section">
<div class="test-header">
@ -253,7 +253,7 @@
<div id="logConsole" class="log-console"></div>
</div>
</div>
<!-- Modal Template -->
<div class="modal fade" id="calendarManageModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
@ -298,30 +298,30 @@
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- FullCalendar JS -->
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js"></script>
<script>
// =========================
// Mock Data & API
// =========================
let mockCalendars = [
{ id: 1, title: 'Personal', description: 'My personal calendar', color: '#0d6efd', calendar_type: 'personal', user_can_edit: true, user_can_view: true, is_subscribed: true },
{ id: 2, title: 'Work', description: 'Work events', color: '#198754', calendar_type: 'public', user_can_edit: true, user_can_view: true, is_subscribed: true },
{ id: 3, title: 'Team Meetings', description: 'Team sync meetings', color: '#ffc107', calendar_type: 'department', user_can_edit: false, user_can_view: true, is_subscribed: true },
];
let mockEvents = [
{ id: 1, title: 'Morning Standup', start: new Date(2026, 1, 11, 9, 0), end: new Date(2026, 1, 11, 9, 30), color: '#0d6efd', calendar_id: 1 },
{ id: 2, title: 'Client Meeting', start: new Date(2026, 1, 11, 14, 0), end: new Date(2026, 1, 11, 15, 0), color: '#198754', calendar_id: 2 },
{ id: 3, title: 'Sprint Planning', start: new Date(2026, 1, 12, 10, 0), end: new Date(2026, 1, 12, 12, 0), color: '#ffc107', calendar_id: 3 },
{ id: 4, title: 'Code Review', start: new Date(2026, 1, 13, 15, 0), end: new Date(2026, 1, 13, 16, 0), color: '#0d6efd', calendar_id: 1 },
];
// Mock API
const mockAPI = {
getMyCalendars: async () => {
@ -329,7 +329,7 @@
log('API: getMyCalendars() → ' + mockCalendars.length + ' calendars', 'info');
return { ok: true, data: [...mockCalendars] };
},
createCalendar: async (data) => {
await delay(500);
const newCalendar = {
@ -343,7 +343,7 @@
log('API: createCalendar() → Calendar #' + newCalendar.id + ' created', 'success');
return { ok: true, data: newCalendar };
},
updateCalendar: async (id, data) => {
await delay(500);
const index = mockCalendars.findIndex(c => c.id === id);
@ -354,14 +354,14 @@
}
return { ok: false, error: 'Calendar not found' };
},
deleteCalendar: async (id) => {
await delay(500);
mockCalendars = mockCalendars.filter(c => c.id !== id);
log('API: deleteCalendar(' + id + ') → Deleted', 'success');
return { ok: true };
},
getCalendarEvents: async (params) => {
await delay(300);
let events = [...mockEvents];
@ -372,15 +372,15 @@
return { ok: true, data: events };
}
};
// =========================
// Test Logger
// =========================
const logConsole = document.getElementById('logConsole');
const testResultsTable = document.getElementById('testResultsTable');
let testResults = [];
function log(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const entry = document.createElement('div');
@ -388,26 +388,26 @@
entry.textContent = '[' + timestamp + '] ' + message;
logConsole.appendChild(entry);
logConsole.scrollTop = logConsole.scrollHeight;
// Also log to real console
console.log('[Test]', message);
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function updateTestResult(testName, status, duration, details) {
testResults.push({ testName, status, duration, details });
renderTestResults();
}
function renderTestResults() {
if (testResults.length === 0) {
testResultsTable.innerHTML = '<tr><td colspan="4" class="text-center text-muted">No tests run yet</td></tr>';
return;
}
testResultsTable.innerHTML = testResults.map(result => `
<tr>
<td>${result.testName}</td>
@ -416,7 +416,7 @@
<td class="text-muted small">${result.details}</td>
</tr>
`).join('');
const passed = testResults.filter(r => r.status === 'passed').length;
const failed = testResults.filter(r => r.status === 'failed').length;
document.getElementById('testSummary').innerHTML = `
@ -424,15 +424,15 @@
<span class="badge bg-danger">${failed} Failed</span>
`;
}
// =========================
// Tests
// =========================
async function testApiWrapper() {
log('=== Test 1: API Wrapper ===', 'info');
const startTime = Date.now();
try {
// Test getMyCalendars
const calendars = await mockAPI.getMyCalendars();
@ -440,7 +440,7 @@
throw new Error('getMyCalendars failed');
}
log('✓ getMyCalendars works', 'success');
// Test createCalendar
const newCal = await mockAPI.createCalendar({
title: 'Test Calendar',
@ -452,14 +452,14 @@
throw new Error('createCalendar failed');
}
log('✓ createCalendar works', 'success');
// Test getCalendarEvents
const events = await mockAPI.getCalendarEvents({ calendar_id: 1 });
if (!events.ok || !Array.isArray(events.data)) {
throw new Error('getCalendarEvents failed');
}
log('✓ getCalendarEvents works', 'success');
const duration = Date.now() - startTime;
updateTestResult('API Wrapper', 'passed', duration, 'All API methods working');
log('✅ Test 1: PASSED', 'success');
@ -469,16 +469,16 @@
log('❌ Test 1: FAILED - ' + error.message, 'error');
}
}
async function testCalendarManager() {
log('=== Test 2: Calendar Manager ===', 'info');
const startTime = Date.now();
try {
// Render calendar list
const calendars = await mockAPI.getMyCalendars();
const container = document.getElementById('calendarListContainer');
container.innerHTML = calendars.data.map(cal => `
<div class="calendar-list-item" data-calendar-id="${cal.id}">
<input type="checkbox" class="form-check-input" ${cal.is_subscribed ? 'checked' : ''}>
@ -492,9 +492,9 @@
</div>
</div>
`).join('');
log('✓ Calendar list rendered', 'success');
// Test checkbox interaction
const firstCheckbox = container.querySelector('input[type="checkbox"]');
if (!firstCheckbox) {
@ -502,7 +502,7 @@
}
firstCheckbox.click();
log('✓ Checkbox interaction works', 'success');
const duration = Date.now() - startTime;
updateTestResult('Calendar Manager', 'passed', duration, 'List rendering and interaction OK');
log('✅ Test 2: PASSED', 'success');
@ -512,19 +512,19 @@
log('❌ Test 2: FAILED - ' + error.message, 'error');
}
}
async function testModalComponent() {
log('=== Test 3: Modal Component ===', 'info');
const startTime = Date.now();
try {
const modal = new bootstrap.Modal(document.getElementById('calendarManageModal'));
// Test modal open
modal.show();
await delay(500);
log('✓ Modal opens', 'success');
// Test form validation
const titleInput = document.getElementById('calendarTitle');
titleInput.value = 'Test Calendar';
@ -532,12 +532,12 @@
throw new Error('Form input failed');
}
log('✓ Form inputs work', 'success');
// Close modal
modal.hide();
await delay(500);
log('✓ Modal closes', 'success');
const duration = Date.now() - startTime;
updateTestResult('Modal Component', 'passed', duration, 'Modal and form working');
log('✅ Test 3: PASSED', 'success');
@ -547,11 +547,11 @@
log('❌ Test 3: FAILED - ' + error.message, 'error');
}
}
async function testWidgetIntegration() {
log('=== Test 4: Widget Integration ===', 'info');
const startTime = Date.now();
try {
// Initialize FullCalendar
const calendarEl = document.getElementById('calendarContainer');
@ -567,18 +567,18 @@
}
}
});
calendar.render();
await delay(1000);
log('✓ FullCalendar initialized', 'success');
// Check if events loaded
const eventCount = calendar.getEvents().length;
if (eventCount === 0) {
throw new Error('No events loaded');
}
log('✓ Events loaded: ' + eventCount, 'success');
const duration = Date.now() - startTime;
updateTestResult('Widget Integration', 'passed', duration, eventCount + ' events loaded');
log('✅ Test 4: PASSED', 'success');
@ -588,23 +588,23 @@
log('❌ Test 4: FAILED - ' + error.message, 'error');
}
}
async function testEventFiltering() {
log('=== Test 5: Event Filtering ===', 'info');
const startTime = Date.now();
try {
// Test filtering by calendar_id
const allEvents = await mockAPI.getCalendarEvents({});
const filteredEvents = await mockAPI.getCalendarEvents({ calendar_id: 1 });
if (filteredEvents.data.length >= allEvents.data.length) {
throw new Error('Filtering not working');
}
log('✓ Calendar filtering works', 'success');
log(' All events: ' + allEvents.data.length, 'info');
log(' Filtered (calendar 1): ' + filteredEvents.data.length, 'info');
const duration = Date.now() - startTime;
updateTestResult('Event Filtering', 'passed', duration, 'Filtering by calendar_id works');
log('✅ Test 5: PASSED', 'success');
@ -614,11 +614,11 @@
log('❌ Test 5: FAILED - ' + error.message, 'error');
}
}
async function testUserInteractions() {
log('=== Test 6: User Interactions ===', 'info');
const startTime = Date.now();
try {
// Test create button
const createBtn = document.getElementById('btnCreateCalendar');
@ -626,21 +626,21 @@
throw new Error('Create button not found');
}
log('✓ Create button exists', 'success');
// Test calendar list items
const calendarItems = document.querySelectorAll('.calendar-list-item');
if (calendarItems.length === 0) {
throw new Error('No calendar items found');
}
log('✓ Calendar items rendered: ' + calendarItems.length, 'success');
// Test checkboxes
const checkboxes = document.querySelectorAll('.calendar-list-item input[type="checkbox"]');
if (checkboxes.length === 0) {
throw new Error('No checkboxes found');
}
log('✓ Checkboxes present: ' + checkboxes.length, 'success');
const duration = Date.now() - startTime;
updateTestResult('User Interactions', 'passed', duration, 'All UI elements interactive');
log('✅ Test 6: PASSED', 'success');
@ -650,15 +650,15 @@
log('❌ Test 6: FAILED - ' + error.message, 'error');
}
}
// =========================
// Event Listeners
// =========================
document.getElementById('runAllTests').addEventListener('click', async () => {
testResults = [];
log('🚀 Running all tests...', 'info');
await testApiWrapper();
await delay(500);
await testCalendarManager();
@ -670,15 +670,15 @@
await testEventFiltering();
await delay(500);
await testUserInteractions();
log('🏁 All tests completed!', 'success');
});
document.getElementById('clearLogs').addEventListener('click', () => {
logConsole.innerHTML = '';
log('Logs cleared', 'info');
});
document.getElementById('toggleConsole').addEventListener('click', () => {
const console = document.getElementById('logConsole');
const btn = document.getElementById('toggleConsole');
@ -690,60 +690,60 @@
btn.innerHTML = '<i class="bi-chevron-down"></i> Expand';
}
});
document.getElementById('testApiWrapper').addEventListener('click', testApiWrapper);
document.getElementById('testCalendarManager').addEventListener('click', testCalendarManager);
document.getElementById('testModalComponent').addEventListener('click', testModalComponent);
document.getElementById('testWidgetIntegration').addEventListener('click', testWidgetIntegration);
document.getElementById('testEventFiltering').addEventListener('click', testEventFiltering);
document.getElementById('testUserInteractions').addEventListener('click', testUserInteractions);
// Modal save button
document.getElementById('saveCalendarBtn').addEventListener('click', async () => {
const title = document.getElementById('calendarTitle').value;
const description = document.getElementById('calendarDescription').value;
const color = document.getElementById('calendarColor').value;
const type = document.querySelector('input[name="calendarType"]:checked').value;
if (!title) {
alert('Title is required');
return;
}
log('Creating calendar: ' + title, 'info');
const result = await mockAPI.createCalendar({ title, description, color, calendar_type: type });
if (result.ok) {
log('Calendar created successfully!', 'success');
const modal = bootstrap.Modal.getInstance(document.getElementById('calendarManageModal'));
modal.hide();
// Refresh calendar list
await testCalendarManager();
}
});
// Create calendar button
document.getElementById('btnCreateCalendar').addEventListener('click', () => {
log('Opening create calendar modal', 'info');
const modal = new bootstrap.Modal(document.getElementById('calendarManageModal'));
// Reset form
document.getElementById('calendarTitle').value = '';
document.getElementById('calendarDescription').value = '';
document.getElementById('calendarColor').value = '#0d6efd';
document.getElementById('typePersonal').checked = true;
modal.show();
});
// =========================
// Initialize
// =========================
log('✅ Test suite initialized', 'success');
log('Click "Run All Tests" to start', 'info');
// Auto-load calendar list on startup (wrapped in async IIFE)
(async function init() {
try {

View File

@ -4,31 +4,31 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar Frontend Test - Simple Version</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
padding: 20px;
background: #f5f5f5;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 20px;
}
.test-section {
background: white;
border-radius: 8px;
@ -36,7 +36,7 @@
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.test-header {
display: flex;
justify-content: space-between;
@ -45,12 +45,12 @@
padding-bottom: 15px;
border-bottom: 2px solid #e9ecef;
}
h2 {
font-size: 20px;
color: #333;
}
button {
padding: 8px 16px;
border: none;
@ -59,26 +59,26 @@
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #0d6efd;
color: white;
}
.btn-primary:hover {
background: #0b5ed7;
}
.btn-secondary {
background: #6c757d;
color: white;
margin-left: 10px;
}
.btn-secondary:hover {
background: #5c636a;
}
.btn-outline {
background: white;
border: 1px solid #dee2e6;
@ -88,55 +88,55 @@
width: calc(33.33% - 10px);
display: inline-block;
}
.btn-outline:hover {
background: #f8f9fa;
border-color: #0d6efd;
}
.test-controls {
display: flex;
flex-wrap: wrap;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
th {
background: #f8f9fa;
font-weight: 600;
}
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.badge-success {
background: #28a745;
color: white;
}
.badge-danger {
background: #dc3545;
color: white;
}
.badge-secondary {
background: #6c757d;
color: white;
}
.log-console {
background: #2d2d2d;
color: #f8f8f2;
@ -147,17 +147,17 @@
max-height: 400px;
overflow-y: auto;
}
.log-entry {
margin: 3px 0;
padding: 2px 0;
}
.log-info { color: #66d9ef; }
.log-success { color: #a6e22e; }
.log-error { color: #f92672; }
.log-warn { color: #e6db74; }
.calendar-list {
border: 1px solid #dee2e6;
border-radius: 4px;
@ -165,7 +165,7 @@
max-height: 400px;
overflow-y: auto;
}
.calendar-item {
padding: 10px;
margin-bottom: 8px;
@ -176,41 +176,41 @@
gap: 10px;
background: white;
}
.calendar-item:hover {
background: #f8f9fa;
border-color: #0d6efd;
}
.calendar-color {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid rgba(0,0,0,0.1);
}
.calendar-info {
flex: 1;
}
.calendar-title {
font-weight: 600;
font-size: 14px;
}
.calendar-meta {
font-size: 12px;
color: #6c757d;
}
.text-muted {
color: #6c757d;
}
.text-center {
text-align: center;
}
input[type="checkbox"] {
width: 18px;
height: 18px;
@ -221,7 +221,7 @@
<body>
<div class="container">
<h1>🧪 Calendar Frontend Test Suite (Offline Version)</h1>
<!-- Test Controls -->
<div class="test-section">
<div class="test-header">
@ -231,7 +231,7 @@
<button id="clearLogs" class="btn-secondary">🗑 Clear Logs</button>
</div>
</div>
<div class="test-controls">
<button class="btn-outline" onclick="testApiWrapper()">Test 1: API Wrapper</button>
<button class="btn-outline" onclick="testCalendarManager()">Test 2: Calendar Manager</button>
@ -241,7 +241,7 @@
<button class="btn-outline" onclick="testDataStructures()">Test 6: Data Structures</button>
</div>
</div>
<!-- Test Results -->
<div class="test-section">
<div class="test-header">
@ -250,7 +250,7 @@
<span class="badge badge-secondary">Ready</span>
</div>
</div>
<table>
<thead>
<tr>
@ -267,19 +267,19 @@
</tbody>
</table>
</div>
<!-- Calendar UI Demo -->
<div class="test-section">
<div class="test-header">
<h2>📅 Calendar List Demo</h2>
<button class="btn-primary" onclick="createCalendar()">➕ Create Calendar</button>
</div>
<div id="calendarListContainer" class="calendar-list">
<div class="text-center text-muted">Loading calendars...</div>
</div>
</div>
<!-- Console Log -->
<div class="test-section">
<div class="test-header">
@ -289,32 +289,32 @@
<div id="logConsole" class="log-console"></div>
</div>
</div>
<script>
// =========================
// State Management
// =========================
let mockCalendars = [
{ id: 1, title: 'Personal', description: 'My personal calendar', color: '#0d6efd', calendar_type: 'personal', user_can_edit: true, user_can_view: true, is_subscribed: true, visible: true },
{ id: 2, title: 'Work', description: 'Work events', color: '#198754', calendar_type: 'public', user_can_edit: true, user_can_view: true, is_subscribed: true, visible: true },
{ id: 3, title: 'Team Meetings', description: 'Team sync', color: '#ffc107', calendar_type: 'department', user_can_edit: false, user_can_view: true, is_subscribed: true, visible: false },
];
let mockEvents = [
{ id: 1, title: 'Morning Standup', start: '2026-02-11T09:00', end: '2026-02-11T09:30', color: '#0d6efd', calendar_id: 1 },
{ id: 2, title: 'Client Meeting', start: '2026-02-11T14:00', end: '2026-02-11T15:00', color: '#198754', calendar_id: 2 },
{ id: 3, title: 'Sprint Planning', start: '2026-02-12T10:00', end: '2026-02-12T12:00', color: '#ffc107', calendar_id: 3 },
{ id: 4, title: 'Code Review', start: '2026-02-13T15:00', end: '2026-02-13T16:00', color: '#0d6efd', calendar_id: 1 },
];
let testResults = [];
let visibleCalendarIds = [1, 2];
// =========================
// Logger
// =========================
function log(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const logConsole = document.getElementById('logConsole');
@ -325,24 +325,24 @@
logConsole.scrollTop = logConsole.scrollHeight;
console.log('[Test]', message);
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function updateTestResult(testName, status, duration, details) {
testResults.push({ testName, status, duration, details });
renderTestResults();
}
function renderTestResults() {
const tbody = document.getElementById('testResultsTable');
if (testResults.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">No tests run yet</td></tr>';
return;
}
tbody.innerHTML = testResults.map(r => `
<tr>
<td>${r.testName}</td>
@ -351,7 +351,7 @@
<td class="text-muted">${r.details}</td>
</tr>
`).join('');
const passed = testResults.filter(r => r.status === 'passed').length;
const failed = testResults.filter(r => r.status === 'failed').length;
document.getElementById('testSummary').innerHTML = `
@ -359,16 +359,16 @@
<span class="badge badge-danger">${failed} Failed</span>
`;
}
// =========================
// Mock API
// =========================
async function mockApiGetCalendars() {
await delay(200);
return { ok: true, data: [...mockCalendars] };
}
async function mockApiGetEvents(calendarId = null) {
await delay(150);
let events = [...mockEvents];
@ -377,7 +377,7 @@
}
return { ok: true, data: events };
}
async function mockApiCreateCalendar(data) {
await delay(300);
const newCal = {
@ -391,31 +391,31 @@
mockCalendars.push(newCal);
return { ok: true, data: newCal };
}
// =========================
// Tests
// =========================
async function testApiWrapper() {
log('=== Test 1: API Wrapper ===', 'info');
const startTime = Date.now();
try {
const calendars = await mockApiGetCalendars();
if (!calendars.ok || !Array.isArray(calendars.data)) {
throw new Error('getCalendars failed');
}
log('✓ mockApiGetCalendars() → ' + calendars.data.length + ' calendars', 'success');
const events = await mockApiGetEvents(1);
if (!events.ok || !Array.isArray(events.data)) {
throw new Error('getEvents failed');
}
log('✓ mockApiGetEvents(1) → ' + events.data.length + ' events', 'success');
const allEvents = await mockApiGetEvents();
log('✓ mockApiGetEvents() → ' + allEvents.data.length + ' total events', 'success');
const duration = Date.now() - startTime;
updateTestResult('API Wrapper', 'passed', duration, 'All API methods working');
log('✅ Test 1: PASSED', 'success');
@ -425,29 +425,29 @@
log('❌ Test 1: FAILED - ' + error.message, 'error');
}
}
async function testCalendarManager() {
log('=== Test 2: Calendar Manager ===', 'info');
const startTime = Date.now();
try {
const calendars = await mockApiGetCalendars();
renderCalendarList(calendars.data);
const container = document.getElementById('calendarListContainer');
const items = container.querySelectorAll('.calendar-item');
if (items.length !== calendars.data.length) {
throw new Error('Calendar count mismatch: ' + items.length + ' != ' + calendars.data.length);
}
log('✓ Rendered ' + items.length + ' calendar items', 'success');
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
if (checkboxes.length === 0) {
throw new Error('No checkboxes found');
}
log('✓ ' + checkboxes.length + ' checkboxes created', 'success');
const duration = Date.now() - startTime;
updateTestResult('Calendar Manager', 'passed', duration, 'List rendering OK');
log('✅ Test 2: PASSED', 'success');
@ -457,26 +457,26 @@
log('❌ Test 2: FAILED - ' + error.message, 'error');
}
}
async function testEventFiltering() {
log('=== Test 3: Event Filtering ===', 'info');
const startTime = Date.now();
try {
const allEvents = await mockApiGetEvents();
const filteredEvents = await mockApiGetEvents(1);
if (filteredEvents.data.length >= allEvents.data.length) {
throw new Error('Filtering not working correctly');
}
log('✓ All events: ' + allEvents.data.length, 'info');
log('✓ Calendar 1 events: ' + filteredEvents.data.length, 'info');
// Test visible calendar filtering
const visibleEvents = fetchEventsForVisibleCalendars(allEvents.data);
log('✓ Visible calendars: ' + visibleCalendarIds.join(', '), 'info');
log('✓ Visible events: ' + visibleEvents.length, 'info');
const duration = Date.now() - startTime;
updateTestResult('Event Filtering', 'passed', duration, 'Filtering works correctly');
log('✅ Test 3: PASSED', 'success');
@ -486,28 +486,28 @@
log('❌ Test 3: FAILED - ' + error.message, 'error');
}
}
async function testStateManagement() {
log('=== Test 4: State Management ===', 'info');
const startTime = Date.now();
try {
const initialVisible = [...visibleCalendarIds];
log('✓ Initial visible: ' + initialVisible.join(', '), 'info');
// Toggle visibility
toggleCalendarVisibility(3, true);
if (!visibleCalendarIds.includes(3)) {
throw new Error('Calendar 3 not added to visible');
}
log('✓ Calendar 3 added to visible', 'success');
toggleCalendarVisibility(3, false);
if (visibleCalendarIds.includes(3)) {
throw new Error('Calendar 3 not removed from visible');
}
log('✓ Calendar 3 removed from visible', 'success');
const duration = Date.now() - startTime;
updateTestResult('State Management', 'passed', duration, 'State updates correctly');
log('✅ Test 4: PASSED', 'success');
@ -517,26 +517,26 @@
log('❌ Test 4: FAILED - ' + error.message, 'error');
}
}
async function testCallbacks() {
log('=== Test 5: Callbacks ===', 'info');
const startTime = Date.now();
try {
let callbackCalled = false;
function testCallback(calendarId, isVisible) {
callbackCalled = true;
log('✓ Callback triggered: cal=' + calendarId + ', visible=' + isVisible, 'info');
}
testCallback(1, true);
if (!callbackCalled) {
throw new Error('Callback not called');
}
log('✓ Callback mechanism works', 'success');
const duration = Date.now() - startTime;
updateTestResult('Callbacks', 'passed', duration, 'Callbacks working');
log('✅ Test 5: PASSED', 'success');
@ -546,34 +546,34 @@
log('❌ Test 5: FAILED - ' + error.message, 'error');
}
}
async function testDataStructures() {
log('=== Test 6: Data Structures ===', 'info');
const startTime = Date.now();
try {
// Test calendar structure
const cal = mockCalendars[0];
const requiredFields = ['id', 'title', 'color', 'calendar_type', 'user_can_edit', 'user_can_view'];
for (const field of requiredFields) {
if (!(field in cal)) {
throw new Error('Missing field: ' + field);
}
}
log('✓ Calendar structure valid', 'success');
// Test event structure
const event = mockEvents[0];
const eventFields = ['id', 'title', 'start', 'calendar_id'];
for (const field of eventFields) {
if (!(field in event)) {
throw new Error('Missing event field: ' + field);
}
}
log('✓ Event structure valid', 'success');
// Test deduplication
const duplicates = [...mockEvents, mockEvents[0]];
const unique = deduplicateEvents(duplicates);
@ -581,7 +581,7 @@
throw new Error('Deduplication failed');
}
log('✓ Event deduplication works', 'success');
const duration = Date.now() - startTime;
updateTestResult('Data Structures', 'passed', duration, 'All structures valid');
log('✅ Test 6: PASSED', 'success');
@ -591,15 +591,15 @@
log('❌ Test 6: FAILED - ' + error.message, 'error');
}
}
// =========================
// Helper Functions
// =========================
function fetchEventsForVisibleCalendars(allEvents) {
return allEvents.filter(e => visibleCalendarIds.includes(e.calendar_id));
}
function toggleCalendarVisibility(calendarId, isVisible) {
if (isVisible) {
if (!visibleCalendarIds.includes(calendarId)) {
@ -610,7 +610,7 @@
}
log('Visible calendars: ' + visibleCalendarIds.join(', '), 'info');
}
function deduplicateEvents(events) {
const seen = new Set();
return events.filter(e => {
@ -619,15 +619,15 @@
return true;
});
}
function renderCalendarList(calendars) {
const container = document.getElementById('calendarListContainer');
container.innerHTML = calendars.map(cal => `
<div class="calendar-item">
<input
type="checkbox"
${cal.visible ? 'checked' : ''}
<input
type="checkbox"
${cal.visible ? 'checked' : ''}
onchange="handleCalendarToggle(${cal.id}, this.checked)"
>
<div class="calendar-color" style="background-color: ${cal.color}"></div>
@ -638,24 +638,24 @@
</div>
`).join('');
}
function handleCalendarToggle(calendarId, isChecked) {
log('Calendar ' + calendarId + ' toggled: ' + isChecked, 'info');
toggleCalendarVisibility(calendarId, isChecked);
// Update calendar state
const calendar = mockCalendars.find(c => c.id === calendarId);
if (calendar) {
calendar.visible = isChecked;
}
}
function createCalendar() {
const title = prompt('Calendar title:');
if (!title) return;
const color = '#' + Math.floor(Math.random()*16777215).toString(16);
mockApiCreateCalendar({
title: title,
description: '',
@ -666,20 +666,20 @@
renderCalendarList(mockCalendars);
});
}
function toggleConsole() {
const console = document.getElementById('logConsole');
console.style.display = console.style.display === 'none' ? 'block' : 'none';
}
// =========================
// Event Listeners
// =========================
document.getElementById('runAllTests').addEventListener('click', async () => {
testResults = [];
log('🚀 Running all tests...', 'info');
await testApiWrapper();
await delay(300);
await testCalendarManager();
@ -691,24 +691,24 @@
await testCallbacks();
await delay(300);
await testDataStructures();
log('🏁 All tests completed!', 'success');
});
document.getElementById('clearLogs').addEventListener('click', () => {
document.getElementById('logConsole').innerHTML = '';
log('Logs cleared', 'info');
});
// =========================
// Initialize
// =========================
(async function init() {
log('✅ Test suite initialized', 'success');
log('Click "Run All Tests" to start', 'info');
log('📡 Offline mode - using mock data', 'warn');
try {
await testCalendarManager();
} catch (error) {

View File

@ -152,9 +152,9 @@ def make_department(Department) -> Callable[..., models.Model]:
@pytest.fixture
def give_manage_calendar_perm():
"""Выдаёт сотруднику право управления календарём отдела."""
from employees.constants import DeptPerm
from employees.models import (DepartmentPermission, DepartmentRole,
EmployeeDepartment)
from employees.constants import DeptPerm
def _grant(user: models.Model, department: models.Model) -> None:
# Скоуп-пермишен отдела
@ -272,17 +272,17 @@ def make_user(User) -> Callable[..., models.Model]:
) -> models.Model:
phone_field = _detect_phone_field(User)
phone = _next_ru_phone() if phone_field else None
user_data = {
"username": username,
"email": email or f"{username}@test.com",
"is_staff": is_staff,
"is_superuser": is_superuser,
}
if phone_field and phone:
user_data[phone_field] = phone
user = User.objects.create_user(**user_data)
user.set_password(password)
user.save()
@ -342,7 +342,7 @@ def dept_manager_user(make_user) -> models.Model:
@pytest.fixture
def make_event(db, CalendarEvent):
"""Фабрика для создания событий календаря.
Args:
title (str): Название события
start_date (date): Дата начала
@ -354,11 +354,11 @@ def make_event(db, CalendarEvent):
department (Department, optional): Отдел (legacy)
employee (User, optional): Сотрудник (legacy)
**kwargs: Дополнительные поля
Returns:
CalendarEvent: Созданное событие
"""
def _make(
title="Test Event",
start_date=None,
@ -373,7 +373,7 @@ def make_event(db, CalendarEvent):
):
if start_date is None:
start_date = date.today()
event = CalendarEvent.objects.create(
title=title,
start_date=start_date,
@ -387,5 +387,13 @@ def make_event(db, CalendarEvent):
**kwargs
)
return event
return _make
calendar=calendar,
department=department,
employee=employee,
**kwargs
)
return event
return _make

View File

@ -5,6 +5,7 @@
Проверяют функциональность опциональных календарей с настраиваемой видимостью
и системой подписок.
"""
from __future__ import annotations
import pytest
@ -76,7 +77,9 @@ class TestCalendarCRUD:
assert r.data.get("visibility") == "department"
assert r.data.get("is_department") is True
def test_cannot_create_with_both_owners(self, auth_client, admin_user, make_department):
def test_cannot_create_with_both_owners(
self, auth_client, admin_user, make_department
):
"""Нельзя создать календарь с двумя владельцами одновременно."""
dept = make_department("HR")
client = auth_client(admin_user)
@ -106,13 +109,15 @@ class TestCalendarCRUD:
r = client.patch(
f"/api/v1/calendar/calendars/{calendar.id}/",
{"title": "New Title", "color": "#ff00ff"},
format="json"
format="json",
)
assert r.status_code == 200
assert r.data.get("title") == "New Title"
assert r.data.get("color") == "#ff00ff"
def test_cannot_update_others_calendar(self, auth_client, regular_user, make_calendar, make_user):
def test_cannot_update_others_calendar(
self, auth_client, regular_user, make_calendar, make_user
):
"""Нельзя обновить чужой календарь."""
other_user = make_user(username="other")
calendar = make_calendar(title="Other's Calendar", owner_user=other_user)
@ -120,7 +125,7 @@ class TestCalendarCRUD:
r = client.patch(
f"/api/v1/calendar/calendars/{calendar.id}/",
{"title": "Hacked"},
format="json"
format="json",
)
assert r.status_code == 403
@ -131,7 +136,9 @@ class TestCalendarCRUD:
r = client.delete(f"/api/v1/calendar/calendars/{calendar.id}/")
assert r.status_code == 204
def test_cannot_delete_others_calendar(self, auth_client, regular_user, make_calendar, make_user):
def test_cannot_delete_others_calendar(
self, auth_client, regular_user, make_calendar, make_user
):
"""Нельзя удалить чужой календарь."""
other_user = make_user(username="other2")
calendar = make_calendar(title="Other's Calendar", owner_user=other_user)
@ -144,7 +151,9 @@ class TestCalendarCRUD:
class TestCalendarSubscriptions:
"""Тесты для подписок на календари."""
def test_subscribe_to_public_calendar(self, auth_client, regular_user, make_calendar):
def test_subscribe_to_public_calendar(
self, auth_client, regular_user, make_calendar
):
"""Подписка на публичный календарь."""
calendar = make_calendar(title="Public", visibility="public")
client = auth_client(regular_user)
@ -153,7 +162,9 @@ class TestCalendarSubscriptions:
assert r.data.get("calendar") == calendar.id
assert r.data.get("user") == regular_user.id
def test_subscribe_with_permissions(self, auth_client, admin_user, regular_user, make_calendar):
def test_subscribe_with_permissions(
self, auth_client, admin_user, regular_user, make_calendar
):
"""Владелец может выдать права при подписке."""
calendar = make_calendar(title="Admin Calendar", owner_user=admin_user)
client = auth_client(regular_user)
@ -161,7 +172,7 @@ class TestCalendarSubscriptions:
r = client.post(
f"/api/v1/calendar/calendars/{calendar.id}/subscribe/",
{"can_edit": True, "can_manage": True},
format="json"
format="json",
)
# Пользователь подпишется, но без прав (только владелец может выдавать права)
assert r.status_code == 201
@ -179,7 +190,9 @@ class TestCalendarSubscriptions:
r2 = client.post(f"/api/v1/calendar/calendars/{calendar.id}/subscribe/")
assert r2.status_code == 400
def test_unsubscribe_from_calendar(self, auth_client, regular_user, make_calendar, make_subscription):
def test_unsubscribe_from_calendar(
self, auth_client, regular_user, make_calendar, make_subscription
):
"""Отписка от календаря."""
calendar = make_calendar(title="Unsubscribe", visibility="public")
subscription = make_subscription(calendar=calendar, user=regular_user)
@ -187,20 +200,24 @@ class TestCalendarSubscriptions:
r = client.post(f"/api/v1/calendar/calendars/{calendar.id}/unsubscribe/")
assert r.status_code == 200
def test_unsubscribe_without_subscription(self, auth_client, regular_user, make_calendar):
def test_unsubscribe_without_subscription(
self, auth_client, regular_user, make_calendar
):
"""Нельзя отписаться, если не подписан."""
calendar = make_calendar(title="Not Subscribed", visibility="public")
client = auth_client(regular_user)
r = client.post(f"/api/v1/calendar/calendars/{calendar.id}/unsubscribe/")
assert r.status_code == 400
def test_list_my_subscriptions(self, auth_client, regular_user, make_calendar, make_subscription):
def test_list_my_subscriptions(
self, auth_client, regular_user, make_calendar, make_subscription
):
"""Список подписок текущего пользователя."""
cal1 = make_calendar(title="Calendar 1", visibility="public")
cal2 = make_calendar(title="Calendar 2", visibility="public")
make_subscription(calendar=cal1, user=regular_user)
make_subscription(calendar=cal2, user=regular_user)
client = auth_client(regular_user)
r = client.get("/api/v1/calendar/subscriptions/")
assert r.status_code == 200
@ -214,7 +231,7 @@ class TestCalendarSubscriptions:
make_calendar(title="Public", visibility="public")
# Создаем личный календарь
make_calendar(title="My Private", visibility="private", owner_user=regular_user)
client = auth_client(regular_user)
r = client.get("/api/v1/calendar/calendars/my-calendars/")
assert r.status_code == 200
@ -226,7 +243,9 @@ class TestCalendarSubscriptions:
class TestCalendarEventsWithCalendarId:
"""Тесты для событий с привязкой к календарю (новая архитектура)."""
def test_create_event_with_calendar_id(self, auth_client, admin_user, make_calendar):
def test_create_event_with_calendar_id(
self, auth_client, admin_user, make_calendar
):
"""Создание события с привязкой к календарю."""
calendar = make_calendar(title="Project Calendar", visibility="public")
client = auth_client(admin_user)
@ -240,54 +259,57 @@ class TestCalendarEventsWithCalendarId:
assert r.status_code == 201
assert r.data.get("title") == "Project Meeting"
def test_filter_events_by_calendar_id(self, auth_client, admin_user, make_calendar, make_event):
def test_filter_events_by_calendar_id(
self, auth_client, admin_user, make_calendar, make_event
):
"""Фильтрация событий по calendar_id."""
cal1 = make_calendar(title="Calendar 1")
cal2 = make_calendar(title="Calendar 2")
# Создаем события в разных календарях
event1 = make_event(title="Event 1", calendar=cal1, start_date="2025-09-10")
event2 = make_event(title="Event 2", calendar=cal2, start_date="2025-09-10")
client = auth_client(admin_user)
# Запрашиваем события первого календаря
r = client.get("/api/v1/calendar/events/", {
"calendar_id": cal1.id,
"start": "2025-09-01",
"end": "2025-09-30"
})
r = client.get(
"/api/v1/calendar/events/",
{"calendar_id": cal1.id, "start": "2025-09-01", "end": "2025-09-30"},
)
assert r.status_code == 200
titles = [item.get("title") for item in r.data]
assert "Event 1" in titles
assert "Event 2" not in titles
def test_legacy_events_not_mixed_with_calendar_events(self, auth_client, admin_user, make_calendar, make_event):
def test_legacy_events_not_mixed_with_calendar_events(
self, auth_client, admin_user, make_calendar, make_event
):
"""Legacy события (без calendar) не смешиваются с календарными событиями."""
calendar = make_calendar(title="New Calendar")
# Legacy событие (без calendar)
legacy_event = make_event(title="Legacy Event", start_date="2025-09-10")
# Событие нового календаря
calendar_event = make_event(title="Calendar Event", calendar=calendar, start_date="2025-09-10")
calendar_event = make_event(
title="Calendar Event", calendar=calendar, start_date="2025-09-10"
)
client = auth_client(admin_user)
# Запрос legacy событий (без calendar_id)
r1 = client.get("/api/v1/calendar/events/", {
"start": "2025-09-01",
"end": "2025-09-30"
})
r1 = client.get(
"/api/v1/calendar/events/", {"start": "2025-09-01", "end": "2025-09-30"}
)
assert r1.status_code == 200
legacy_titles = [item.get("title") for item in r1.data]
assert "Legacy Event" in legacy_titles
assert "Calendar Event" not in legacy_titles
# Запрос событий календаря
r2 = client.get("/api/v1/calendar/events/", {
"calendar_id": calendar.id,
"start": "2025-09-01",
"end": "2025-09-30"
})
r2 = client.get(
"/api/v1/calendar/events/",
{"calendar_id": calendar.id, "start": "2025-09-01", "end": "2025-09-30"},
)
assert r2.status_code == 200
calendar_titles = [item.get("title") for item in r2.data]
assert "Calendar Event" in calendar_titles
@ -298,7 +320,9 @@ class TestCalendarEventsWithCalendarId:
class TestCalendarVisibility:
"""Тесты видимости календарей."""
def test_public_calendar_visible_to_all(self, auth_client, regular_user, make_calendar):
def test_public_calendar_visible_to_all(
self, auth_client, regular_user, make_calendar
):
"""Публичный календарь виден всем."""
calendar = make_calendar(title="Public", visibility="public")
client = auth_client(regular_user)
@ -307,26 +331,30 @@ class TestCalendarVisibility:
titles = [c.get("title") for c in r.data["results"]]
assert "Public" in titles
def test_private_calendar_only_visible_to_owner(self, auth_client, regular_user, make_calendar, make_user):
def test_private_calendar_only_visible_to_owner(
self, auth_client, regular_user, make_calendar, make_user
):
"""Приватный календарь виден только владельцу."""
other_user = make_user(username="other3")
calendar = make_calendar(title="Private", visibility="private", owner_user=other_user)
calendar = make_calendar(
title="Private", visibility="private", owner_user=other_user
)
client = auth_client(regular_user)
r = client.get("/api/v1/calendar/calendars/")
assert r.status_code == 200
titles = [c.get("title") for c in r.data["results"]]
assert "Private" not in titles
def test_department_calendar_visible_to_members(self, auth_client, dept_manager_user, make_department, make_calendar):
def test_department_calendar_visible_to_members(
self, auth_client, dept_manager_user, make_department, make_calendar
):
"""Календарь отдела виден членам отдела."""
dept = make_department("QA")
calendar = make_calendar(
title="QA Calendar",
visibility="department",
owner_department=dept
title="QA Calendar", visibility="department", owner_department=dept
)
# dept_manager_user должен видеть календарь отдела
client = auth_client(dept_manager_user)
r = client.get("/api/v1/calendar/calendars/")
@ -355,14 +383,22 @@ class TestCalendarPermissions:
calendar = make_calendar(title="Edit Test", owner_user=regular_user)
assert calendar.can_user_edit(regular_user) is True
def test_can_user_edit_with_subscription(self, regular_user, make_calendar, make_subscription):
def test_can_user_edit_with_subscription(
self, regular_user, make_calendar, make_subscription
):
"""Подписчик с правом can_edit может редактировать."""
calendar = make_calendar(title="Edit Sub", visibility="custom")
subscription = make_subscription(calendar=calendar, user=regular_user, can_edit=True)
subscription = make_subscription(
calendar=calendar, user=regular_user, can_edit=True
)
assert calendar.can_user_edit(regular_user) is True
def test_cannot_edit_without_permission(self, regular_user, make_calendar, make_user):
def test_cannot_edit_without_permission(
self, regular_user, make_calendar, make_user
):
"""Нельзя редактировать без прав."""
other_user = make_user(username="other4")
calendar = make_calendar(title="No Edit", owner_user=other_user, visibility="private")
calendar = make_calendar(
title="No Edit", owner_user=other_user, visibility="private"
)
assert calendar.can_user_edit(regular_user) is False

View File

@ -0,0 +1,302 @@
# Интеграция селектора календарей в создание событий
**Дата**: 11 февраля 2026
**Статус**: ✅ Завершено
**Коммиты**: `70d9bad`, `8e393e2`, `a0e04b8`, `5d7902d`, `6a11138`, `6bf3cc4`
## Проблема
После реализации унифицированного списка календарей (legacy + новые в одном списке) обнаружены проблемы:
1. ❌ **События создавались только в legacy календарях** - форма создания использовала старую логику `state.type` (company/personal/dept)
2. ❌ **Новые Calendar записи были недоступны** для создания событий
3. ❌ **Статический текст "Календарь: Компания"** без возможности выбора
4. ❌ **Множественные баги**: NaN errors, дублирование событий, IndentationErrors
## Решение
### 1. UI компонент - селектор календаря
**Файл**: `templates/includes/components/calendar_modal_create.html`
```html
<div class="mt-3">
<label class="form-label">Календарь</label>
<select class="form-select" name="target_calendar" id="targetCalendarSelect">
<option value="company" selected>Компания (общие события)</option>
<option value="personal">Личный календарь</option>
</select>
<div class="form-text">События создаются в выбранном календаре</div>
</div>
```
**Изменения**:
- ✅ Заменил статический `<span id="eventTargetLabel">` на `<select>`
- ✅ Базовые опции: "Компания", "Личный календарь"
- ✅ Динамическое добавление отделов и новых календарей через JS
### 2. Функция динамического заполнения
**Файл**: `static/js/components/calendarWidget.js`
```javascript
function populateCalendarSelector() {
const select = document.getElementById("targetCalendarSelect");
if (!select) return;
// Очищаем селект
select.innerHTML = "";
// 1. Базовые legacy опции
select.appendChild(createOption("company", "Компания (общие события)"));
select.appendChild(createOption("personal", "Личный календарь"));
// 2. Отделы из window.departments
if (window.departments && Array.isArray(window.departments)) {
window.departments.forEach((dept) => {
select.appendChild(createOption(`dept-${dept.id}`, `Отдел: ${dept.name}`));
});
}
// 3. Новые календари из window.calendarIntegration
if (window.calendarIntegration?.calendars) {
const newCalendars = window.calendarIntegration.calendars.filter(
(cal) => !cal.id.toString().startsWith("legacy-")
);
if (newCalendars.length > 0) {
// Разделитель
select.appendChild(createOption("", "─────────────────", true));
// Новые календари с иконкой
newCalendars.forEach((cal) => {
const option = createOption(cal.id, `📅 ${cal.name}`);
if (cal.description) option.title = cal.description;
select.appendChild(option);
});
}
}
// Устанавливаем значение по умолчанию
if (state.type === "personal") {
select.value = "personal";
} else if (state.type === "dept" && state.deptId) {
select.value = `dept-${state.deptId}`;
} else {
select.value = "company";
}
}
```
**Вызывается**:
- При открытии формы создания нового события (dateClick)
- При открытии формы редактирования существующего события
### 3. Логика создания/редактирования события
**Было** (старая логика):
```javascript
// Область — компания/отдел/личный
if (state.type === "personal") {
payload.employee_id = Number(state.employeeId);
} else if (state.type === "dept") {
payload.department_id = Number(state.deptId);
}
```
**Стало** (новая логика):
```javascript
// Получаем выбранный календарь из формы
const targetCalendar = fd.get("target_calendar");
if (targetCalendar && targetCalendar !== "company") {
if (targetCalendar === "personal") {
// Личный календарь
const userMeta = document.querySelector('meta[name="user-id"]');
const currentEmployeeId = userMeta ? parseInt(userMeta.content, 10) : null;
if (!currentEmployeeId) {
alert("Не удалось определить ID пользователя");
return;
}
payload.employee_id = currentEmployeeId;
} else if (targetCalendar.startsWith("dept-")) {
// Календарь отдела
const deptId = parseInt(targetCalendar.replace("dept-", ""), 10);
if (isNaN(deptId)) {
alert("Некорректный отдел");
return;
}
payload.department_id = deptId;
} else if (/^\d+$/.test(targetCalendar)) {
// Новый календарь - используем calendar_id
payload.calendar_id = parseInt(targetCalendar, 10);
}
}
// Если targetCalendar === "company", то без доп. параметров
```
**Преимущества**:
- ✅ Поддержка смешанных типов ID (string для legacy, number для новых)
- ✅ Единая логика для POST (создание) и PATCH (редактирование)
- ✅ Валидация входных данных
- ✅ Понятные сообщения об ошибках
### 4. Автоматический выбор при редактировании
```javascript
// При открытии формы редактирования
populateCalendarSelector();
const targetSelect = document.getElementById("targetCalendarSelect");
if (targetSelect && data) {
if (data.calendar_id) {
targetSelect.value = data.calendar_id;
} else if (data.employee_id) {
targetSelect.value = "personal";
} else if (data.department_id) {
targetSelect.value = `dept-${data.department_id}`;
} else {
targetSelect.value = "company";
}
}
```
## Исправленные баги
### Баг 1: `calendar_id=NaN`
**Коммит**: `70d9bad`
- **Причина**: `parseInt('legacy-company')` возвращал NaN
- **Решение**: Проверка `/^\d+$/` перед parseInt, сохранение строковых ID
### Баг 2: Дублирование событий
**Коммит**: `8e393e2`
- **Причина**: `employee_id=null` возвращал все события и для "Компания", и для "Личный"
- **Решение**: Получение реального employee_id из `meta[name="user-id"]`
- **Результат**: Было 26 событий (13+13 дубли) → стало 13 уникальных
### Баг 3: IndentationError в urls.py
**Коммит**: `a0e04b8`
- **Причина**: Дублированный блок `urlpatterns`
- **Решение**: Удален дубликат строк 78-85
### Баг 4: IndentationError в serializers.py
**Коммит**: `5d7902d`
- **Причина**: Дублированный метод `validate()` в `CalendarSubscriptionSerializer`
- **Решение**: Удален дубликат строк 487-497
### Баг 5: `department_id=NaN`
**Коммит**: `6a11138`
- **Причина**: `deptIds` был массивом объектов `[{id, name}]`, а код ожидал `[id]`
- **Решение**: Извлечение ID через `dept.id || dept.pk || dept.department_id`
## Результат
### Что работает
✅ **Создание событий** в любом календаре:
- Компания (общие события)
- Личный календарь
- Календари отделов
- Новые Calendar записи из БД
✅ **Редактирование событий**:
- Автоматический выбор текущего календаря
- Возможность перемещения между календарями
- Сохранение изменений
✅ **Отображение событий**:
- Корректная фильтрация по календарям
- Без дублирования
- Правильные цвета и подписи
✅ **Обратная совместимость**:
- Legacy система (employee_id/department_id) работает
- Новая система (calendar_id) работает
- Смешанное использование работает
### API запросы
**Создание события в компании**:
```json
POST /api/v1/calendar/events/
{
"title": "Совещание",
"start": "2026-02-12T10:00:00",
"end": "2026-02-12T11:00:00"
}
```
**Создание в личном календаре**:
```json
POST /api/v1/calendar/events/
{
"title": "Встреча",
"start": "2026-02-12T14:00:00",
"end": "2026-02-12T15:00:00",
"employee_id": 1
}
```
**Создание в новом календаре**:
```json
POST /api/v1/calendar/events/
{
"title": "Задача",
"start": "2026-02-12T16:00:00",
"end": "2026-02-12T17:00:00",
"calendar_id": 5
}
```
## Тестирование
### Сценарии для проверки
1. **Создание нового события**
- [ ] Клик на дату в календаре
- [ ] Селектор показывает все доступные календари
- [ ] Выбор календаря и сохранение
- [ ] Событие появляется в правильном календаре
2. **Редактирование события**
- [ ] Клик на существующее событие
- [ ] Селектор показывает текущий календарь
- [ ] Изменение календаря и сохранение
- [ ] Событие перемещается в новый календарь
3. **Фильтрация календарей**
- [ ] Отключение календаря скрывает его события
- [ ] Включение календаря показывает события
- [ ] Нет дублирования событий
4. **Новые календари**
- [ ] Создание Calendar через "Управление календарями"
- [ ] Новый календарь появляется в селекторе
- [ ] События создаются в новом календаре
- [ ] События отображаются с правильным цветом
## Файлы изменены
- `templates/includes/components/calendar_modal_create.html` - добавлен селектор
- `static/js/components/calendarWidget.js` - новая логика создания/редактирования
- `static/js/components/calendarWidgetIntegration.js` - исправлено получение employee_id
- `static/js/components/calendarManager.js` - исправлена обработка string ID
- `api/v1/urls.py` - удален дубликат
- `api/v1/calendar/serializers.py` - удален дубликат
## Следующие шаги
🎯 **Phase 3: Frontend - ЗАВЕРШЕНО**
- ✅ Унифицированный список календарей (legacy + новые)
- ✅ Селектор календарей в форме создания
- ✅ Полная интеграция с API
- ✅ Исправлены все критические баги
🚀 **Готово к тестированию пользователями**
## Примечания
- Используется `window.calendarIntegration` для доступа к списку календарей
- Employee ID берется из `<meta name="user-id">` для безопасности
- Поддержка смешанных типов ID для плавной миграции legacy → новая система
- Иконка 📅 для визуального отличия новых календарей от legacy

View File

@ -1,7 +1,7 @@
# Унифицированный список календарей
**Дата:** 2024-01-XX
**Коммит:** 91de84f
**Дата:** 2024-01-XX
**Коммит:** 91de84f
**Статус:** ✅ Завершено
## Проблема
@ -110,22 +110,22 @@ async function loadCalendars() {
try {
// Загружаем новые календари из API
const newCalendars = await getMyCalendars();
// Создаем legacy календари
const legacyCalendars = createLegacyCalendars();
// ОБЪЕДИНЯЕМ оба массива
calendars = [...legacyCalendars, ...newCalendars];
// Все календари видимы по умолчанию
visibleCalendarIds = new Set(calendars.map(c => c.id));
console.log('[CalendarManager] Loaded calendars:', {
legacy: legacyCalendars.length,
new: newCalendars.length,
total: calendars.length
});
render();
triggerChange();
} catch (error) {
@ -142,7 +142,7 @@ async function loadCalendars() {
async function fetchEventsForVisibleCalendars(start, end) {
const startStr = formatDate(start);
const endStr = formatDate(end);
// Разделяем legacy и новые календари
const legacyIds = [...visibleCalendarIds].filter(
id => typeof id === 'string' && id.startsWith('legacy-')
@ -150,18 +150,18 @@ async function fetchEventsForVisibleCalendars(start, end) {
const newIds = [...visibleCalendarIds].filter(
id => typeof id === 'number'
);
let allEvents = [];
// Загружаем события для legacy календарей
for (const legacyId of legacyIds) {
let events = [];
const calendar = calendars.find(c => c.id === legacyId);
if (legacyId === 'legacy-company') {
// ВСЕ события без фильтров
events = await getCalendarEvents({ start: startStr, end: endStr });
} else if (legacyId === 'legacy-personal') {
// События текущего сотрудника
events = await getCalendarEvents({
@ -169,7 +169,7 @@ async function fetchEventsForVisibleCalendars(start, end) {
end: endStr,
employee_id: window.currentEmployeeId
});
} else if (legacyId.startsWith('legacy-dept-')) {
// События конкретного отдела
const deptId = parseInt(legacyId.replace('legacy-dept-', ''), 10);
@ -179,7 +179,7 @@ async function fetchEventsForVisibleCalendars(start, end) {
department_id: deptId
});
}
// Применяем цвет календаря
allEvents.push(...events.map(event => ({
...event,
@ -187,7 +187,7 @@ async function fetchEventsForVisibleCalendars(start, end) {
color: calendar?.color || event.color
})));
}
// Загружаем события для новых календарей
const newEventChunks = await Promise.all(
newIds.map(async (calendarId) => {
@ -196,7 +196,7 @@ async function fetchEventsForVisibleCalendars(start, end) {
end: endStr,
calendar_id: calendarId // Новый параметр фильтрации
});
const calendar = calendars.find(c => c.id === calendarId);
return events.map(event => ({
...event,
@ -205,9 +205,9 @@ async function fetchEventsForVisibleCalendars(start, end) {
}));
})
);
allEvents.push(...newEventChunks.flat());
// Дедупликация по ID
return deduplicateEvents(allEvents);
}
@ -234,9 +234,9 @@ async function fetchEventsForVisibleCalendars(start, end) {
```html
{# Кнопка списка календарей #}
<button
class="btn btn-sm btn-outline-primary"
data-bs-toggle="collapse"
<button
class="btn btn-sm btn-outline-primary"
data-bs-toggle="collapse"
data-bs-target="#calendarListCollapse"
>
<i class="bi-list-check"></i>

View File

@ -0,0 +1,279 @@
# Тестирование интеграции календарей
## Быстрая проверка функциональности
### 1. Запуск сервера
```bash
cd c:/Users/nadeini/Dev/EUSRR/backend
.venv/Scripts/python manage.py runserver
```
Откройте: http://localhost:8000/calendar/
### 2. Проверка списка календарей
**Что проверить**:
- [ ] В левой панели отображаются чекбоксы календарей
- [ ] Присутствуют legacy календари: "Компания", "Личный календарь", отделы
- [ ] Если есть новые календари - они тоже в списке
- [ ] Клик на чекбокс включает/выключает отображение событий
**Ожидаемое поведение**:
- Отключение календаря → события исчезают
- Включение календаря → события появляются
- НЕТ дублирования событий
### 3. Создание нового события
**Шаги**:
1. Кликните на любую дату в календаре
2. Откроется модальное окно "Создать событие"
3. Проверьте селектор "Календарь" внизу формы
**Что проверить**:
- [ ] Селектор содержит:
- "Компания (общие события)"
- "Личный календарь"
- Отделы (если есть)
- ─────────────────
- 📅 Новые календари (если созданы)
- [ ] Заполните название, дату, время
- [ ] Выберите календарь в селекторе
- [ ] Нажмите "Сохранить"
- [ ] Событие появилось в выбранном календаре
**Тест 1: Создание в "Компания"**
```
Название: Общее совещание
Календарь: Компания (общие события)
```
→ Событие видно всем
**Тест 2: Создание в "Личный календарь"**
```
Название: Моя встреча
Календарь: Личный календарь
```
→ Событие видно только текущему пользователю
**Тест 3: Создание в новом календаре**
```
Название: Проектная задача
Календарь: 📅 Мой проект
```
→ Событие создано с calendar_id
### 4. Редактирование события
**Шаги**:
1. Кликните на существующее событие
2. Откроется форма редактирования
3. Проверьте селектор календаря
**Что проверить**:
- [ ] Селектор автоматически установлен на текущий календарь события
- [ ] Можно изменить календарь
- [ ] После сохранения событие переместилось в новый календарь
- [ ] Старый календарь больше не показывает это событие
**Тест: Перемещение события**
```
1. Событие "Совещание" в календаре "Компания"
2. Открыть редактирование
3. Изменить календарь на "Личный календарь"
4. Сохранить
```
→ Событие теперь только в "Личный календарь"
### 5. Проверка фильтрации
**Шаги**:
1. Создайте 3 события в разных календарях:
- "Событие A" → Компания
- "Событие B" → Личный календарь
- "Событие C" → Новый календарь (если есть)
2. Используйте чекбоксы в левой панели:
- Отключите "Компания" → "Событие A" исчезло
- Отключите "Личный" → "Событие B" исчезло
- Включите все → все события видны
**Что проверить**:
- [ ] Каждое событие привязано к одному календарю
- [ ] Нет дублирования (одно событие в двух календарях)
- [ ] Фильтрация работает мгновенно
- [ ] События разных календарей имеют разные цвета
### 6. Управление календарями
**Шаги**:
1. Нажмите кнопку "Управление календарями"
2. Откроется модальное окно со списком календарей
**Что проверить**:
- [ ] Отображаются созданные Calendar записи
- [ ] Можно создать новый календарь
- [ ] Новый календарь сразу появляется в списке слева
- [ ] Новый календарь доступен в селекторе создания события
**Тест: Создание нового календаря**
```
1. "Управление календарями" → "Создать календарь"
2. Название: "Проект X"
3. Цвет: #ff6b6b (красный)
4. Сохранить
```
→ "📅 Проект X" появился в списке и селекторе
### 7. Проверка API запросов
Откройте DevTools (F12) → Network → Fetch/XHR
**При создании события в "Компания"**:
```json
POST /api/v1/calendar/events/
{
"title": "Событие",
"start": "2026-02-12T10:00:00",
"end": "2026-02-12T11:00:00"
// Нет employee_id, department_id, calendar_id
}
```
**При создании в "Личный календарь"**:
```json
POST /api/v1/calendar/events/
{
"title": "Событие",
"start": "2026-02-12T10:00:00",
"end": "2026-02-12T11:00:00",
"employee_id": 1 // ID текущего пользователя
}
```
**При создании в новом календаре**:
```json
POST /api/v1/calendar/events/
{
"title": "Событие",
"start": "2026-02-12T10:00:00",
"end": "2026-02-12T11:00:00",
"calendar_id": 5 // ID Calendar записи
}
```
## Известные ограничения
1. **Legacy календари не редактируются**
- "Компания", "Личный календарь", отделы - системные
- Нельзя изменить их название или цвет
- Это legacy поведение, сохранено для совместимости
2. **Подписка на календари**
- Функция подписки работает только для новых Calendar записей
- Legacy календари всегда видны пользователю
## Что делать, если не работает
### Событие не создается
- Проверьте консоль браузера (F12)
- Проверьте, что выбран календарь в селекторе
- Проверьте, что заполнены обязательные поля (название, дата)
### Селектор пустой
- Проверьте консоль: `window.calendarIntegration.calendars`
- Должен вернуть массив календарей
- Если пусто - проблема в calendarManager.js
### События дублируются
- Откройте DevTools → Network
- Проверьте параметры в GET запросах к `/api/v1/calendar/events/`
- Должны быть разные employee_id/department_id/calendar_id
### NaN ошибки
- Проверьте, что используется последняя версия кода (commit 6bf3cc4)
- Проверьте логи в консоли
- Убедитесь, что `meta[name="user-id"]` существует в HTML
## Тестовые данные
### Создание тестового календаря через Django shell
```bash
.venv/Scripts/python manage.py shell
```
```python
from calendar_app.models import Calendar
from employees.models import Employee
# Получаем текущего пользователя
employee = Employee.objects.get(id=1)
# Создаем тестовый календарь
calendar = Calendar.objects.create(
name="Проект Альфа",
description="Календарь проекта",
owner=employee,
color="#ff6b6b",
is_public=False
)
print(f"Создан календарь: {calendar.id} - {calendar.name}")
```
### SQL запросы для проверки
```sql
-- Все календари пользователя
SELECT id, name, owner_id, color, is_public
FROM calendar_app_calendar
WHERE owner_id = 1;
-- События с calendar_id
SELECT id, title, calendar_id, employee_id, department_id
FROM calendar_app_event
WHERE calendar_id IS NOT NULL;
-- События в личном календаре (legacy)
SELECT id, title, employee_id
FROM calendar_app_event
WHERE employee_id = 1 AND calendar_id IS NULL;
```
## Чеклист финального тестирования
- [ ] Сервер запускается без ошибок
- [ ] Страница `/calendar/` открывается
- [ ] Список календарей отображается
- [ ] Чекбоксы включают/выключают события
- [ ] Клик на дату открывает форму создания
- [ ] Селектор календаря заполнен
- [ ] Создание события в "Компания" работает
- [ ] Создание события в "Личный календарь" работает
- [ ] Создание события в новом календаре работает (если есть)
- [ ] Редактирование события работает
- [ ] Перемещение между календарями работает
- [ ] Нет дублирования событий
- [ ] Нет ошибок в консоли браузера
- [ ] Нет NaN в URL запросах
- [ ] API запросы корректны (проверить через DevTools)
## Отчет о тестировании
После прохождения всех тестов заполните:
**Дата**: ___________
**Тестировщик**: ___________
**Результаты**:
- ✅ / ❌ Создание событий
- ✅ / ❌ Редактирование событий
- ✅ / ❌ Фильтрация календарей
- ✅ / ❌ Управление календарями
- ✅ / ❌ API запросы
**Найденные баги**: ___________
**Комментарии**: ___________

View File

@ -1,6 +1,6 @@
# Анализ приложения календаря EUSRR
**Дата анализа:** 11 февраля 2026 г.
**Дата анализа:** 11 февраля 2026 г.
**Задача:** Изучить приложение календаря и оценить возможность создания пользовательских календарей (глобального, локального или календаря отдела)
---
@ -58,29 +58,29 @@ class CalendarEvent(models.Model):
# Область видимости (взаимоисключающие)
department = ForeignKey("employees.Department", null=True, blank=True)
employee = ForeignKey(User, null=True, blank=True) # ✅ УЖЕ РЕАЛИЗОВАНО
# Основные поля
title = CharField(max_length=200)
description = TextField(blank=True)
# Даты и время
start_date = DateField()
end_date = DateField(null=True, blank=True)
start_time = TimeField(null=True, blank=True)
end_time = TimeField(null=True, blank=True)
all_day = BooleanField(default=True)
# Повторяемость
recurrence = CharField(choices=Recurrence.choices) # one_time, hourly, daily, weekly, monthly, annual
recurrence_interval = PositiveSmallIntegerField(default=1)
recurrence_count = PositiveIntegerField(null=True, blank=True)
recurrence_until = DateField(null=True, blank=True)
weekdays_mask = PositiveSmallIntegerField(default=0) # Для weekly: битовая маска дней
# Отображение
color = CharField(max_length=7, blank=True) # HEX цвет
location = CharField(max_length=200, blank=True)
# Служебные
created_by = ForeignKey(User, on_delete=SET_NULL)
created_at = DateTimeField(auto_now_add=True)
@ -115,7 +115,7 @@ def clean(self):
# ✅ Взаимоисключение: нельзя одновременно задавать отдел И сотрудника
if self.department_id and self.employee_id:
raise ValidationError("Событие не может одновременно принадлежать отделу и сотруднику.")
# Валидация дат/времени
# Валидация повторяемости
# Автоматическая настройка all_day
@ -149,18 +149,18 @@ def get_permissions(self):
# Просмотр: всем авторизованным
if action in ["list", "retrieve"]:
return [IsAuthenticated()]
# Личный календарь: только владелец
if emp is not None:
if request.user.id == emp:
return [IsAuthenticated()]
else:
return [DenyAll()] # Другие не могут редактировать
# Календарь компании: только администраторы
if dep is None:
return [IsAdminUser()]
# Календарь отдела: право MANAGE_CALENDAR
return [ManageCalendarPerm()] # AdminOrDeptAllowed с required_code=MANAGE_CALENDAR
```
@ -241,7 +241,7 @@ Authorization: Bearer <token>
#### State управление:
```javascript
const state = {
const state = {
type: 'company', // 'company' | 'dept' | 'personal'
deptId: null, // PK отдела (если type='dept')
employeeId: null // PK сотрудника (если type='personal')
@ -290,7 +290,7 @@ async function fetchEventsAllCalendars(start, end) {
const sources = [
// Компания
{ params: { start, end }, label: 'Компания', type: 'company', id: null },
// Все отделы пользователя
...departments.map(d => ({
params: { start, end, department_id: d.id },
@ -298,21 +298,21 @@ async function fetchEventsAllCalendars(start, end) {
type: 'dept',
id: d.id
})),
// Личный календарь (если есть employee_id)
{
{
params: { start, end, employee_id: currentEmployeeId },
label: 'Личный',
type: 'personal',
id: currentEmployeeId
}
];
// Параллельная загрузка всех источников
const results = await Promise.allSettled(
sources.map(s => getCalendarEvents(s.params))
);
return results.flatMap(/* ... */);
}
```
@ -484,7 +484,7 @@ function updateScopeIndicator() {
// В calendarWidget.js
function getEventColor(event) {
if (event.color) return event.color;
// Дефолтные цвета по типу
if (event.employee_id) return '#6C757D'; // Серый для личных
if (event.department_id) return '#0D6EFD'; // Синий для отдела

View File

@ -1,6 +1,6 @@
# Архитектура: Календари как настраиваемые сущности
**Дата:** 11 февраля 2026 г.
**Дата:** 11 февраля 2026 г.
**Задача:** Реализация календарей как отдельных настраиваемых объектов с гибким управлением доступом
---
@ -70,12 +70,12 @@ class CalendarType(models.TextChoices):
class Calendar(models.Model):
"""Календарь как отдельная сущность с настраиваемым доступом."""
# Основное
title = models.CharField(_("Название"), max_length=200)
description = models.TextField(_("Описание"), blank=True)
color = models.CharField(_("Цвет"), max_length=7, default="#0d6efd")
# Тип и владение
calendar_type = models.CharField(
_("Тип календаря"),
@ -83,7 +83,7 @@ class Calendar(models.Model):
choices=CalendarType.choices,
default=CalendarType.PERSONAL,
)
# Владельцы (взаимоисключающие для GLOBAL/DEPARTMENT/PERSONAL)
owner_user = models.ForeignKey(
User,
@ -93,7 +93,7 @@ class Calendar(models.Model):
blank=True,
verbose_name=_("Владелец (пользователь)"),
)
owner_department = models.ForeignKey(
"employees.Department",
on_delete=models.CASCADE,
@ -102,14 +102,14 @@ class Calendar(models.Model):
blank=True,
verbose_name=_("Владелец (отдел)"),
)
# Системный календарь (автоматически созданный)
is_system = models.BooleanField(
_("Системный"),
default=False,
help_text=_("Создан автоматически при регистрации/инициализации"),
)
system_key = models.CharField(
_("Системный ключ"),
max_length=100,
@ -119,26 +119,26 @@ class Calendar(models.Model):
null=True,
help_text=_("Уникальный идентификатор системного календаря"),
)
# Настройки доступа
is_public = models.BooleanField(
_("Публичный"),
default=False,
help_text=_("Доступен всем для просмотра"),
)
allow_subscription = models.BooleanField(
_("Разрешить подписку"),
default=True,
help_text=_("Пользователи могут подписаться на этот календарь"),
)
default_for_new_users = models.BooleanField(
_("По умолчанию для новых пользователей"),
default=False,
help_text=_("Автоматически добавляется новым сотрудникам"),
)
# Служебное
created_by = models.ForeignKey(
User,
@ -150,9 +150,9 @@ class Calendar(models.Model):
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(_("Активен"), default=True)
class Meta:
verbose_name = _("Календарь")
verbose_name_plural = _("Календари")
@ -178,7 +178,7 @@ class Calendar(models.Model):
name="unique_system_key"
),
]
def __str__(self):
type_label = self.get_calendar_type_display()
owner = ""
@ -187,7 +187,7 @@ class Calendar(models.Model):
elif self.owner_department:
owner = f" ({self.owner_department.name})"
return f"[{type_label}] {self.title}{owner}"
def clean(self):
"""Валидация владения календарём."""
# GLOBAL не имеет владельца
@ -196,7 +196,7 @@ class Calendar(models.Model):
raise ValidationError(
_("Глобальный календарь не может иметь владельца.")
)
# DEPARTMENT должен иметь owner_department
if self.calendar_type == CalendarType.DEPARTMENT:
if not self.owner_department:
@ -207,7 +207,7 @@ class Calendar(models.Model):
raise ValidationError(
_("Календарь отдела не может иметь владельца-пользователя.")
)
# PERSONAL должен иметь owner_user
if self.calendar_type == CalendarType.PERSONAL:
if not self.owner_user:
@ -218,21 +218,21 @@ class Calendar(models.Model):
raise ValidationError(
_("Личный календарь не может иметь владельца-отдел.")
)
# Системный календарь должен иметь уникальный system_key
if self.is_system and not self.system_key:
raise ValidationError(
_("Системный календарь должен иметь system_key.")
)
@property
def is_global(self):
return self.calendar_type == CalendarType.GLOBAL
@property
def is_department_calendar(self):
return self.calendar_type == CalendarType.DEPARTMENT
@property
def is_personal_calendar(self):
return self.calendar_type == CalendarType.PERSONAL
@ -250,14 +250,14 @@ class PermissionLevel(models.TextChoices):
class CalendarPermission(models.Model):
"""Права доступа к календарю для пользователя или отдела."""
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="permissions",
verbose_name=_("Календарь"),
)
# Кому предоставлен доступ (одно из двух)
user = models.ForeignKey(
User,
@ -267,7 +267,7 @@ class CalendarPermission(models.Model):
blank=True,
verbose_name=_("Пользователь"),
)
department = models.ForeignKey(
"employees.Department",
on_delete=models.CASCADE,
@ -276,7 +276,7 @@ class CalendarPermission(models.Model):
blank=True,
verbose_name=_("Отдел"),
)
# Уровень доступа
level = models.CharField(
_("Уровень доступа"),
@ -284,7 +284,7 @@ class CalendarPermission(models.Model):
choices=PermissionLevel.choices,
default=PermissionLevel.VIEW,
)
# Служебное
granted_by = models.ForeignKey(
User,
@ -295,7 +295,7 @@ class CalendarPermission(models.Model):
verbose_name=_("Предоставил"),
)
granted_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Право доступа к календарю")
verbose_name_plural = _("Права доступа к календарям")
@ -324,11 +324,11 @@ class CalendarPermission(models.Model):
name="unique_dept_permission"
),
]
def __str__(self):
target = self.user if self.user else self.department
return f"{self.calendar.title} → {target} ({self.get_level_display()})"
def clean(self):
"""Валидация целевого получателя прав."""
if not self.user and not self.department:
@ -346,49 +346,49 @@ class CalendarPermission(models.Model):
```python
class CalendarSubscription(models.Model):
"""Подписка пользователя на календарь."""
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="subscriptions",
verbose_name=_("Календарь"),
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="calendar_subscriptions",
verbose_name=_("Пользователь"),
)
# Настройки подписки
is_visible = models.BooleanField(
_("Отображать"),
default=True,
help_text=_("Показывать события этого календаря в виджете"),
)
color_override = models.CharField(
_("Переопределить цвет"),
max_length=7,
blank=True,
help_text=_("Личный цвет для событий этого календаря"),
)
receive_notifications = models.BooleanField(
_("Получать уведомления"),
default=True,
)
# Автоматическая подписка (по умолчанию для новых пользователей)
is_auto_subscribed = models.BooleanField(
_("Автоподписка"),
default=False,
help_text=_("Добавлена автоматически при регистрации"),
)
subscribed_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Подписка на календарь")
verbose_name_plural = _("Подписки на календари")
@ -397,7 +397,7 @@ class CalendarSubscription(models.Model):
models.Index(fields=["user", "is_visible"]),
models.Index(fields=["calendar", "is_visible"]),
]
def __str__(self):
return f"{self.user.username} → {self.calendar.title}"
```
@ -407,7 +407,7 @@ class CalendarSubscription(models.Model):
```python
class CalendarEvent(models.Model):
"""Событие календаря с поддержкой повторяемости."""
# НОВОЕ: Связь с календарём
calendar = models.ForeignKey(
Calendar,
@ -415,25 +415,25 @@ class CalendarEvent(models.Model):
related_name="events",
verbose_name=_("Календарь"),
)
# УДАЛИТЬ: department, employee (теперь через calendar)
# Основное (без изменений)
title = models.CharField(_("Название"), max_length=200)
description = models.TextField(_("Описание"), blank=True)
# Даты/время (без изменений)
start_date = models.DateField(_("Дата начала"))
end_date = models.DateField(_("Дата окончания"), null=True, blank=True)
start_time = models.TimeField(_("Время начала"), null=True, blank=True)
end_time = models.TimeField(_("Время окончания"), null=True, blank=True)
all_day = models.BooleanField(_("Весь день"), default=True)
# Повторяемость (без изменений)
recurrence = models.CharField(...)
recurrence_interval = models.PositiveSmallIntegerField(...)
# ... остальные поля recurrence
# Отображение
color = models.CharField(
_("Цвет"),
@ -442,12 +442,12 @@ class CalendarEvent(models.Model):
help_text=_("Переопределяет цвет календаря для этого события"),
)
location = models.CharField(_("Локация"), max_length=200, blank=True)
# Служебное
created_by = models.ForeignKey(...)
created_at = models.DateTimeField(auto_now_add=True)
source = models.CharField(...) # Для системных событий (дни рождения)
class Meta:
verbose_name = _("Событие календаря")
verbose_name_plural = _("События календаря")
@ -476,7 +476,7 @@ class Migration(migrations.Migration):
('calendar_app', '000X_previous_migration'),
('employees', '000X_department_migration'),
]
operations = [
# Создание модели Calendar
migrations.CreateModel(
@ -498,7 +498,7 @@ class Migration(migrations.Migration):
# ForeignKeys будут добавлены отдельно
],
),
# Добавление ForeignKey полей
migrations.AddField(
model_name='calendar',
@ -511,10 +511,10 @@ class Migration(migrations.Migration):
),
),
# ... остальные FK
# Создание CalendarPermission
migrations.CreateModel(name='CalendarPermission', ...),
# Создание CalendarSubscription
migrations.CreateModel(name='CalendarSubscription', ...),
]
@ -533,7 +533,7 @@ def migrate_events_to_calendars(apps, schema_editor):
Calendar = apps.get_model('calendar_app', 'Calendar')
User = apps.get_model('employees', 'Employee')
Department = apps.get_model('employees', 'Department')
# 1. Создать глобальный календарь для событий компании
company_calendar, _ = Calendar.objects.get_or_create(
system_key='company_default',
@ -546,7 +546,7 @@ def migrate_events_to_calendars(apps, schema_editor):
'default_for_new_users': True,
}
)
# 2. Мигрировать события компании (department=NULL, employee=NULL)
company_events = CalendarEvent.objects.filter(
department__isnull=True,
@ -555,9 +555,9 @@ def migrate_events_to_calendars(apps, schema_editor):
for event in company_events:
event.calendar = company_calendar
event.save(update_fields=['calendar'])
print(f"✅ Мигрировано событий компании: {company_events.count()}")
# 3. Создать календари для отделов
for dept in Department.objects.all():
dept_calendar, created = Calendar.objects.get_or_create(
@ -572,16 +572,16 @@ def migrate_events_to_calendars(apps, schema_editor):
'allow_subscription': True,
}
)
# Мигрировать события отдела
dept_events = CalendarEvent.objects.filter(department=dept)
for event in dept_events:
event.calendar = dept_calendar
event.save(update_fields=['calendar'])
if created:
print(f"✅ Создан календарь отдела: {dept.name} ({dept_events.count()} событий)")
# 4. Создать личные календари для пользователей
for user in User.objects.all():
user_calendar, created = Calendar.objects.get_or_create(
@ -596,20 +596,20 @@ def migrate_events_to_calendars(apps, schema_editor):
'allow_subscription': False,
}
)
# Мигрировать личные события
personal_events = CalendarEvent.objects.filter(employee=user)
for event in personal_events:
event.calendar = user_calendar
event.save(update_fields=['calendar'])
if created and personal_events.exists():
print(f"✅ Создан личный календарь: {user.username} ({personal_events.count()} событий)")
def reverse_migration(apps, schema_editor):
"""Откат: восстановление department/employee из calendar."""
CalendarEvent = apps.get_model('calendar_app', 'CalendarEvent')
for event in CalendarEvent.objects.select_related('calendar').all():
cal = event.calendar
if cal.calendar_type == 'department' and cal.owner_department:
@ -622,7 +622,7 @@ class Migration(migrations.Migration):
dependencies = [
('calendar_app', '000X_add_calendar_field_to_events'),
]
operations = [
migrations.RunPython(
migrate_events_to_calendars,
@ -640,7 +640,7 @@ class Migration(migrations.Migration):
dependencies = [
('calendar_app', '000X_migrate_events_to_calendars'),
]
operations = [
# Удалить старые поля department и employee из CalendarEvent
migrations.RemoveField(
@ -755,49 +755,49 @@ CalendarEvent.objects.create(
def get_user_calendars(user):
"""Возвращает все календари, доступные пользователю."""
from django.db.models import Q
# 1. Личные календари пользователя
personal_cals = Calendar.objects.filter(
calendar_type=CalendarType.PERSONAL,
owner_user=user,
is_active=True
)
# 2. Публичные календари
public_cals = Calendar.objects.filter(
is_public=True,
is_active=True
)
# 3. Календари отделов пользователя
user_departments = user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True)
dept_cals = Calendar.objects.filter(
calendar_type=CalendarType.DEPARTMENT,
owner_department_id__in=user_departments,
is_active=True
)
# 4. Календари с явными правами доступа
permitted_cals = Calendar.objects.filter(
Q(permissions__user=user) |
Q(permissions__user=user) |
Q(permissions__department_id__in=user_departments),
is_active=True
).distinct()
# 5. Подписки пользователя
subscribed_cals = Calendar.objects.filter(
subscriptions__user=user,
subscriptions__is_visible=True,
is_active=True
)
# Объединение и удаление дубликатов
all_cals = (personal_cals | public_cals | dept_cals |
all_cals = (personal_cals | public_cals | dept_cals |
permitted_cals | subscribed_cals).distinct()
return all_cals.order_by('calendar_type', 'title')
```
@ -807,60 +807,60 @@ def get_user_calendars(user):
def check_calendar_permission(calendar, user, required_level='view'):
"""Проверяет права пользователя на календарь."""
from calendar_app.models import PermissionLevel
# Админы могут всё
if user.is_superuser or user.is_staff:
return True
# Владелец может всё
if calendar.owner_user_id == user.id:
return True
# Публичный календарь - просмотр для всех
if required_level == 'view' and calendar.is_public:
return True
# Проверка календаря отдела
if calendar.calendar_type == CalendarType.DEPARTMENT:
user_departments = user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True)
if calendar.owner_department_id in user_departments:
if required_level == 'view':
return True
# Для редактирования нужны явные права
# Проверка явных прав доступа
level_hierarchy = {
'view': 1,
'edit': 2,
'manage': 3
}
required_int = level_hierarchy.get(required_level, 1)
# Права пользователя
user_perm = calendar.permissions.filter(user=user).first()
if user_perm:
user_level = level_hierarchy.get(user_perm.level, 0)
if user_level >= required_int:
return True
# Права через отдел
user_departments = user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True)
dept_perm = calendar.permissions.filter(
department_id__in=user_departments
).order_by('-level').first()
if dept_perm:
dept_level = level_hierarchy.get(dept_perm.level, 0)
if dept_level >= required_int:
return True
return False
```
@ -881,11 +881,11 @@ urlpatterns = [
path('calendars/<int:pk>/subscribe/', CalendarSubscribeView.as_view()),
path('calendars/<int:pk>/unsubscribe/', CalendarUnsubscribeView.as_view()),
path('calendars/my/', MyCalendarsView.as_view(), name='my-calendars'),
# События (обновлённый эндпоинт)
path('calendars/<int:calendar_id>/events/', CalendarEventsView.as_view()),
path('events/<int:pk>/', EventDetailView.as_view()),
# Подписки
path('subscriptions/', MySubscriptionsView.as_view()),
]
@ -950,7 +950,7 @@ def create_personal_calendar(sender, instance, created, **kwargs):
system_key=f"personal_{instance.id}_default",
color="#6C757D"
)
# Автоподписка на календари по умолчанию
default_calendars = Calendar.objects.filter(
default_for_new_users=True,
@ -1053,5 +1053,5 @@ def create_department_calendar(sender, instance, created, **kwargs):
---
**Автор:** GitHub Copilot
**Автор:** GitHub Copilot
**Дата:** 11 февраля 2026 г.

View File

@ -1,7 +1,7 @@
# Упрощённая архитектура: Опциональные настраиваемые календари
**Дата:** 11 февраля 2026 г.
**Подход:** Обратно совместимое расширение без поломки существующего функционала
**Дата:** 11 февраля 2026 г.
**Подход:** Обратно совместимое расширение без поломки существующего функционала
**Статус:** ✅ Phase 2 (API + Testing) завершена
---
@ -144,17 +144,17 @@ class CalendarVisibility(models.TextChoices):
class Calendar(models.Model):
"""Настраиваемый календарь (опциональный, расширенная функциональность).
Если событие создано БЕЗ calendar → работает старая логика (department/employee).
Если событие создано С calendar → игнорируются department/employee, используются настройки календаря.
"""
# Основное
title = models.CharField(_("Название"), max_length=200)
description = models.TextField(_("Описание"), blank=True)
color = models.CharField(_("Цвет"), max_length=7, default="#0d6efd")
icon = models.CharField(_("Иконка"), max_length=50, blank=True, help_text="Bootstrap icon, например: calendar-event")
# Владение (опциональное)
owner_user = models.ForeignKey(
User,
@ -165,7 +165,7 @@ class Calendar(models.Model):
verbose_name=_("Владелец"),
help_text=_("Если задано — личный календарь пользователя"),
)
owner_department = models.ForeignKey(
"employees.Department",
on_delete=models.CASCADE,
@ -175,7 +175,7 @@ class Calendar(models.Model):
verbose_name=_("Отдел-владелец"),
help_text=_("Если задано — календарь отдела"),
)
# Настройки видимости
visibility = models.CharField(
_("Видимость"),
@ -183,27 +183,27 @@ class Calendar(models.Model):
choices=CalendarVisibility.choices,
default=CalendarVisibility.CUSTOM,
)
# Права по умолчанию для новых подписчиков
default_can_edit = models.BooleanField(
_("Могут редактировать по умолчанию"),
default=False,
help_text=_("Если True, все подписчики могут создавать/редактировать события"),
)
# Автоподписка
auto_subscribe_new_users = models.BooleanField(
_("Автоподписка для новых пользователей"),
default=False,
help_text=_("Автоматически подписывать новых сотрудников"),
)
auto_subscribe_department_members = models.BooleanField(
_("Автоподписка для членов отдела"),
default=False,
help_text=_("Автоматически подписывать членов отдела-владельца"),
)
# Служебное
is_active = models.BooleanField(_("Активен"), default=True)
created_by = models.ForeignKey(
@ -215,7 +215,7 @@ class Calendar(models.Model):
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("Календарь")
verbose_name_plural = _("Календари")
@ -225,31 +225,31 @@ class Calendar(models.Model):
models.Index(fields=["owner_department", "is_active"]),
models.Index(fields=["visibility"]),
]
def __str__(self):
if self.owner_user:
return f"{self.title} ({self.owner_user.username})"
if self.owner_department:
return f"{self.title} ({self.owner_department.name})"
return f"{self.title} (Глобальный)"
def clean(self):
# Нельзя одновременно user и department
if self.owner_user and self.owner_department:
raise ValidationError(
_("Календарь не может одновременно принадлежать пользователю и отделу.")
)
@property
def is_global(self):
"""Глобальный календарь (без владельца)."""
return not self.owner_user_id and not self.owner_department_id
@property
def is_personal(self):
"""Личный календарь пользователя."""
return bool(self.owner_user_id)
@property
def is_department(self):
"""Календарь отдела."""
@ -261,19 +261,19 @@ class Calendar(models.Model):
```python
class CalendarSubscription(models.Model):
"""Подписка пользователя на календарь с настройками отображения."""
calendar = models.ForeignKey(
Calendar,
on_delete=models.CASCADE,
related_name="subscriptions",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="calendar_subscriptions",
)
# Настройки отображения
is_visible = models.BooleanField(_("Отображать"), default=True)
color_override = models.CharField(
@ -282,26 +282,26 @@ class CalendarSubscription(models.Model):
blank=True,
help_text=_("Переопределяет цвет календаря"),
)
# Права подписчика
can_edit = models.BooleanField(
_("Может редактировать"),
default=False,
help_text=_("Может создавать/редактировать события в этом календаре"),
)
can_manage = models.BooleanField(
_("Может управлять"),
default=False,
help_text=_("Может управлять правами других пользователей (только для не-владельцев)"),
)
# Уведомления
notify_on_new_event = models.BooleanField(_("Уведомлять о новых событиях"), default=True)
notify_on_event_change = models.BooleanField(_("Уведомлять об изменениях"), default=True)
subscribed_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Подписка на календарь")
verbose_name_plural = _("Подписки на календари")
@ -310,7 +310,7 @@ class CalendarSubscription(models.Model):
models.Index(fields=["user", "is_visible"]),
models.Index(fields=["calendar", "can_edit"]),
]
def __str__(self):
return f"{self.user.username} → {self.calendar.title}"
```
@ -320,12 +320,12 @@ class CalendarSubscription(models.Model):
```python
class CalendarEvent(models.Model):
"""Событие календаря с поддержкой повторяемости.
ОБРАТНАЯ СОВМЕСТИМОСТЬ:
- Если calendar = NULL → используется старая логика (department/employee)
- Если calendar задан → игнорируются department/employee, используется calendar
"""
# ✨ НОВОЕ: Опциональная привязка к настраиваемому календарю
calendar = models.ForeignKey(
Calendar,
@ -336,7 +336,7 @@ class CalendarEvent(models.Model):
verbose_name=_("Календарь"),
help_text=_("Если не задан — событие использует стандартную логику (department/employee)"),
)
# ✅ ОСТАВЛЯЕМ: Старые поля для обратной совместимости
department = models.ForeignKey(
"employees.Department",
@ -347,7 +347,7 @@ class CalendarEvent(models.Model):
blank=True,
help_text=_("LEGACY: используется если calendar=NULL"),
)
employee = models.ForeignKey(
User,
on_delete=models.CASCADE,
@ -357,9 +357,9 @@ class CalendarEvent(models.Model):
blank=True,
help_text=_("LEGACY: используется если calendar=NULL"),
)
# ... остальные поля БЕЗ ИЗМЕНЕНИЙ ...
class Meta:
verbose_name = _("Событие календаря")
verbose_name_plural = _("События календаря")
@ -372,12 +372,12 @@ class CalendarEvent(models.Model):
models.Index(fields=["employee", "start_date"]),
models.Index(fields=["source"]),
]
def __str__(self):
# Новая логика: приоритет calendar
if self.calendar_id:
return f"[{self.calendar.title}] {self.title} ({self.start_date:%d.%m.%Y})"
# Старая логика
if self.employee_id:
scope = f"Личный ({self.employee})"
@ -385,21 +385,21 @@ class CalendarEvent(models.Model):
scope = str(self.department)
else:
scope = _("Компания")
if self.end_date:
return f"{scope}: {self.title} ({self.start_date:%d.%m.%Y}–{self.end_date:%d.%m.%Y})"
return f"{scope}: {self.title} ({self.start_date:%d.%m.%Y})"
@property
def is_legacy_event(self):
"""True если событие использует старую логику (без calendar)."""
return self.calendar_id is None
@property
def is_modern_event(self):
"""True если событие использует новую логику (с calendar)."""
return self.calendar_id is not None
def clean(self):
"""Валидация с учётом новой/старой логики."""
# Если указан calendar — department и employee должны быть пусты
@ -408,14 +408,14 @@ class CalendarEvent(models.Model):
raise ValidationError(
_("При использовании календаря нельзя указывать department или employee.")
)
# Старая валидация (только если calendar = NULL)
if not self.calendar_id:
if self.department_id and self.employee_id:
raise ValidationError(
_("Событие не может одновременно принадлежать отделу и сотруднику.")
)
# ... остальная валидация БЕЗ ИЗМЕНЕНИЙ ...
```
@ -502,23 +502,23 @@ all_events = CalendarEvent.objects.all() # Все события работаю
class CalendarEventsViewSet(ModelViewSet):
"""CRUD событий с поддержкой legacy и modern режимов."""
def get_queryset(self):
"""Фильтрация с учётом новой и старой логики."""
qs = super().get_queryset()
if self.action != "list":
return qs
# НОВОЕ: Если передан calendar_id
calendar_id = self.request.query_params.get('calendar_id')
if calendar_id:
return qs.filter(calendar_id=calendar_id)
# СТАРОЕ: Логика department/employee (без изменений!)
dep = self._dept_id(required=False)
emp = self._employee_id(required=False)
if emp is not None:
# Legacy: личный календарь
return qs.filter(
@ -538,7 +538,7 @@ class CalendarEventsViewSet(ModelViewSet):
employee__isnull=True,
calendar__isnull=True # Только старые глобальные события
)
def perform_create(self, serializer):
"""Создание с поддержкой обоих режимов."""
# Если передан calendar_id — используем новую логику
@ -554,12 +554,12 @@ class CalendarEventsViewSet(ModelViewSet):
# Старая логика (без изменений)
dep = self._dept_id(required=False)
emp = self._employee_id(required=False)
if emp is not None:
serializer.save(employee_id=emp, department=None, created_by=self.request.user)
else:
serializer.save(department_id=dep, employee=None, created_by=self.request.user)
cache.clear()
```
@ -571,7 +571,7 @@ class CalendarEventsViewSet(ModelViewSet):
urlpatterns = [
# ✅ Старые эндпоинты РАБОТАЮТ БЕЗ ИЗМЕНЕНИЙ
path('events/', CalendarEventsViewSet.as_view({'get': 'list', 'post': 'create'})),
# ✨ Новые эндпоинты для календарей
path('calendars/', CalendarListCreateView.as_view()),
path('calendars/<int:pk>/', CalendarDetailView.as_view()),
@ -687,36 +687,36 @@ for dev in developers:
def get_user_calendars(user):
"""Возвращает все доступные календари."""
from django.db.models import Q
# 1. Личные календари
owned = Calendar.objects.filter(
owner_user=user,
is_active=True
)
# 2. Календари отделов пользователя
user_depts = user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True)
dept_owned = Calendar.objects.filter(
owner_department_id__in=user_depts,
is_active=True
)
# 3. Публичные календари
public = Calendar.objects.filter(
visibility=CalendarVisibility.PUBLIC,
is_active=True
)
# 4. Подписки
subscribed = Calendar.objects.filter(
subscriptions__user=user,
subscriptions__is_visible=True,
is_active=True
)
return (owned | dept_owned | public | subscribed).distinct()
```
@ -810,5 +810,5 @@ git checkout -b feature/optional-calendars
---
**Автор:** GitHub Copilot
**Автор:** GitHub Copilot
**Дата:** 11 февраля 2026 г.

View File

@ -1,7 +1,7 @@
# Phase 3 Part 2: Calendar Widget Integration
**Дата:** 11 февраля 2026 г.
**Статус:** ✅ Завершено
**Дата:** 11 февраля 2026 г.
**Статус:** ✅ Завершено
**Цель:** Интеграция компонентов управления календарями с существующим виджетом FullCalendar
---
@ -82,23 +82,23 @@ async function fetchEventsForVisibleCalendars(start, end) {
if (calendars.length === 0) {
return await getCalendarEvents({ start, end });
}
// Если ни один не выбран - пустой массив
if (visibleCalendarIds.length === 0) {
return [];
}
// Загружаем события для каждого видимого календаря
const eventChunks = await Promise.all(
visibleCalendarIds.map(calendarId =>
visibleCalendarIds.map(calendarId =>
getCalendarEvents({ start, end, calendar_id: calendarId })
)
);
// Объединяем и дедуплицируем
const allEvents = eventChunks.flat();
const uniqueEvents = deduplicateById(allEvents);
return uniqueEvents;
}
```
@ -129,19 +129,19 @@ return (events || []).map(event => ({
{
// Загрузить события для видимых календарей
fetchEventsForVisibleCalendars: (start, end) => Promise<Event[]>,
// Получить ID видимых календарей
getVisibleCalendarIds: () => number[],
// Получить все календари
getCalendars: () => Calendar[],
// Установить видимые календари
setVisibleCalendars: (ids: number[]) => void,
// Обновить список календарей
refresh: () => Promise<void>,
// Доступ к экземплярам компонентов
instances: {
manager: CalendarManager,
@ -234,5 +234,5 @@ git commit -m "feat(calendar): integrate calendar manager with widget (Phase 3 -
---
**Автор:** GitHub Copilot
**Автор:** GitHub Copilot
**Дата завершения:** 11 февраля 2026 г.