diff --git a/backend/CONSOLE_DEBUG_COMMANDS.md b/backend/CONSOLE_DEBUG_COMMANDS.md index 9b685c3c..a9412630 100644 --- a/backend/CONSOLE_DEBUG_COMMANDS.md +++ b/backend/CONSOLE_DEBUG_COMMANDS.md @@ -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; })(); diff --git a/backend/FIX_INTEGRATION.md b/backend/FIX_INTEGRATION.md index 42fb0062..4585382b 100644 --- a/backend/FIX_INTEGRATION.md +++ b/backend/FIX_INTEGRATION.md @@ -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(); diff --git a/backend/api/v1/calendar/serializers.py b/backend/api/v1/calendar/serializers.py index 9c1e67eb..aa02f7dd 100644 --- a/backend/api/v1/calendar/serializers.py +++ b/backend/api/v1/calendar/serializers.py @@ -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 - diff --git a/backend/api/v1/calendar/views.py b/backend/api/v1/calendar/views.py index afadfeac..849529b9 100644 --- a/backend/api/v1/calendar/views.py +++ b/backend/api/v1/calendar/views.py @@ -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() diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 7fc3d9b6..cd81a41c 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -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)), ] diff --git a/backend/calendar_app/admin.py b/backend/calendar_app/admin.py index 516b363e..65675b9e 100644 --- a/backend/calendar_app/admin.py +++ b/backend/calendar_app/admin.py @@ -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( '{}', - 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] - diff --git a/backend/calendar_app/admin_calendar.py b/backend/calendar_app/admin_calendar.py index 2ce6813b..11c217bd 100644 --- a/backend/calendar_app/admin_calendar.py +++ b/backend/calendar_app/admin_calendar.py @@ -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) diff --git a/backend/calendar_app/admin_subscription.py b/backend/calendar_app/admin_subscription.py index c42ef6d8..6e5aaebe 100644 --- a/backend/calendar_app/admin_subscription.py +++ b/backend/calendar_app/admin_subscription.py @@ -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) diff --git a/backend/calendar_app/models.py b/backend/calendar_app/models.py index 870b6eb5..4c3f06fa 100644 --- a/backend/calendar_app/models.py +++ b/backend/calendar_app/models.py @@ -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( diff --git a/backend/static/css/calendar-improvements.css b/backend/static/css/calendar-improvements.css new file mode 100644 index 00000000..527a6836 --- /dev/null +++ b/backend/static/css/calendar-improvements.css @@ -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; +} diff --git a/backend/static/js/api/calendarsApi.js b/backend/static/js/api/calendarsApi.js index 241cd806..744ff841 100644 --- a/backend/static/js/api/calendarsApi.js +++ b/backend/static/js/api/calendarsApi.js @@ -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} Массив календарей */ 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} Созданный календарь */ 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} Обновленный календарь */ 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} */ 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} Массив подписок */ 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, ); } diff --git a/backend/static/js/components/calendarManageModal.js b/backend/static/js/components/calendarManageModal.js index 4818117e..23d3e3ae 100644 --- a/backend/static/js/components/calendarManageModal.js +++ b/backend/static/js/components/calendarManageModal.js @@ -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 = '' + - departments.map(dept => - `` - ).join(''); - + deptSelect.innerHTML = + '' + + departments + .map((dept) => ``) + .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(), }; } diff --git a/backend/static/js/components/calendarManager.js b/backend/static/js/components/calendarManager.js index d839f32c..96abcd03 100644 --- a/backend/static/js/components/calendarManager.js +++ b/backend/static/js/components/calendarManager.js @@ -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); }); }); diff --git a/backend/static/js/components/calendarWidget.js b/backend/static/js/components/calendarWidget.js index c1946991..86564afd 100644 --- a/backend/static/js/components/calendarWidget.js +++ b/backend/static/js/components/calendarWidget.js @@ -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(); diff --git a/backend/static/js/components/calendarWidgetIntegration.js b/backend/static/js/components/calendarWidgetIntegration.js index 51ab6149..5d5ddf3b 100644 --- a/backend/static/js/components/calendarWidgetIntegration.js +++ b/backend/static/js/components/calendarWidgetIntegration.js @@ -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( diff --git a/backend/static/scss/components/calendar-manager.scss b/backend/static/scss/components/calendar-manager.scss index b8d2c083..dcc4f7c6 100644 --- a/backend/static/scss/components/calendar-manager.scss +++ b/backend/static/scss/components/calendar-manager.scss @@ -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; // На мобильных всегда показываем кнопки } diff --git a/backend/templates/includes/calendar/calendar_styles.html b/backend/templates/includes/calendar/calendar_styles.html index 4315297d..9c43d6bc 100644 --- a/backend/templates/includes/calendar/calendar_styles.html +++ b/backend/templates/includes/calendar/calendar_styles.html @@ -1,2 +1,3 @@ {% load static %} + diff --git a/backend/templates/includes/components/calendar_desktop.html b/backend/templates/includes/components/calendar_desktop.html index bc1da004..bf454d41 100644 --- a/backend/templates/includes/components/calendar_desktop.html +++ b/backend/templates/includes/components/calendar_desktop.html @@ -7,12 +7,12 @@
Календарь
{# Кнопка списка календарей #} - - - +