Merge branch 'develop'
This commit is contained in:
commit
8beb8b3471
@ -36,6 +36,7 @@ from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from scheduling.services import get_or_create_department_calendar
|
||||
|
||||
from ...permissions import (
|
||||
AdminOrActionOrModelPerms,
|
||||
@ -110,6 +111,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
return [AdminOrActionOrModelPerms()]
|
||||
if self.action in {
|
||||
"members",
|
||||
"calendar",
|
||||
"discussion_chat",
|
||||
"user_perms",
|
||||
"list",
|
||||
@ -500,6 +502,28 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="calendar")
|
||||
def calendar(self, request, pk=None):
|
||||
"""Возвращает calendar отдела для входа из detail page."""
|
||||
dept = self.get_object()
|
||||
calendar, binding = get_or_create_department_calendar(dept)
|
||||
return Response(
|
||||
{
|
||||
"calendar_id": calendar.id,
|
||||
"calendar_type": binding.type,
|
||||
"name": calendar.name,
|
||||
"slug": calendar.slug,
|
||||
"context_type": (
|
||||
binding.context_content_type.model
|
||||
if binding.context_content_type_id
|
||||
else None
|
||||
),
|
||||
"context_object_id": binding.context_object_id,
|
||||
"flags": binding.flags,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="user-perms")
|
||||
def user_perms(self, request, pk=None):
|
||||
"""GET /api/v1/departments/{id}/user-perms/.
|
||||
|
||||
@ -176,8 +176,8 @@ class AdminOrDeptAllowed(BasePermission):
|
||||
|
||||
Поведение:
|
||||
1. Если пользователь superuser/staff — доступ всегда разрешён.
|
||||
2. Если для текущего экшена требуется код права
|
||||
(например, DeptPerm.MANAGE_CALENDAR), то доступ
|
||||
2. Если для текущего экшена требуется код права
|
||||
(например, DeptPerm.MANAGE_FEED), то доступ
|
||||
разрешается только при наличии у пользователя этого права
|
||||
на указанный отдел.
|
||||
3. Идентификатор отдела извлекается из:
|
||||
@ -203,15 +203,15 @@ class AdminOrDeptAllowed(BasePermission):
|
||||
когда код не задан. По умолчанию True.
|
||||
|
||||
Примеры:
|
||||
class ManageCalendarPerm(AdminOrDeptAllowed):
|
||||
required_code = DeptPerm.MANAGE_CALENDAR
|
||||
class ManageFeedPerm(AdminOrDeptAllowed):
|
||||
required_code = DeptPerm.MANAGE_FEED
|
||||
|
||||
class DeptEventsPerm(AdminOrDeptAllowed):
|
||||
class DeptFeedPerm(AdminOrDeptAllowed):
|
||||
required_code_map = {
|
||||
"create": DeptPerm.MANAGE_CALENDAR,
|
||||
"update": DeptPerm.MANAGE_CALENDAR,
|
||||
"partial_update": DeptPerm.MANAGE_CALENDAR,
|
||||
"destroy": DeptPerm.MANAGE_CALENDAR,
|
||||
"create": DeptPerm.MANAGE_FEED,
|
||||
"update": DeptPerm.MANAGE_FEED,
|
||||
"partial_update": DeptPerm.MANAGE_FEED,
|
||||
"destroy": DeptPerm.MANAGE_FEED,
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@ -4,8 +4,13 @@
|
||||
"""
|
||||
|
||||
from rest_framework import permissions
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from schedule.models import CalendarRelation
|
||||
|
||||
from scheduling.services import (
|
||||
user_can_create_event,
|
||||
user_can_edit_calendar,
|
||||
user_can_edit_event,
|
||||
user_can_view_calendar,
|
||||
)
|
||||
|
||||
|
||||
class IsOwnerOfCalendar(permissions.BasePermission):
|
||||
@ -18,28 +23,10 @@ class IsOwnerOfCalendar(permissions.BasePermission):
|
||||
"""Проверка прав на календарь."""
|
||||
# SAFE_METHODS (GET, HEAD, OPTIONS) доступны всем участникам
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return user_can_view_calendar(request.user, obj)
|
||||
|
||||
# Только owner может изменять/удалять календарь
|
||||
return self._is_owner(obj, request.user)
|
||||
|
||||
@staticmethod
|
||||
def _is_owner(calendar, user):
|
||||
"""Проверка, является ли пользователь владельцем календаря."""
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
return CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=ct,
|
||||
object_id=user.id,
|
||||
distinction="owner",
|
||||
).exists()
|
||||
return user_can_edit_calendar(request.user, obj)
|
||||
|
||||
|
||||
class CanEditCalendar(permissions.BasePermission):
|
||||
@ -66,7 +53,7 @@ class CanEditCalendar(permissions.BasePermission):
|
||||
except Calendar.DoesNotExist:
|
||||
return False
|
||||
|
||||
return self._can_edit(calendar, request.user)
|
||||
return user_can_create_event(request.user, calendar)
|
||||
|
||||
return True
|
||||
|
||||
@ -78,28 +65,7 @@ class CanEditCalendar(permissions.BasePermission):
|
||||
|
||||
# SAFE_METHODS (GET, HEAD, OPTIONS) доступны всем участникам
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return user_can_view_calendar(request.user, calendar)
|
||||
|
||||
# Для изменения/удаления нужны права editor или owner
|
||||
return self._can_edit(calendar, request.user)
|
||||
|
||||
@staticmethod
|
||||
def _can_edit(calendar, user):
|
||||
"""Проверка, может ли пользователь редактировать события в календаре."""
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
# Проверяем роль пользователя в календаре
|
||||
try:
|
||||
relation = CalendarRelation.objects.get(
|
||||
calendar=calendar, content_type=ct, object_id=user.id
|
||||
)
|
||||
# Owner и editor могут редактировать, viewer - нет
|
||||
return relation.distinction in ["owner", "editor"]
|
||||
except CalendarRelation.DoesNotExist:
|
||||
return False
|
||||
return user_can_edit_event(request.user, event)
|
||||
|
||||
@ -16,12 +16,23 @@ from schedule.models import (
|
||||
)
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from scheduling.services import (
|
||||
get_calendar_binding,
|
||||
user_can_create_event,
|
||||
user_can_delete_event,
|
||||
user_can_edit_calendar,
|
||||
user_can_edit_event,
|
||||
user_can_manage_participants,
|
||||
)
|
||||
|
||||
|
||||
class RelatedUserSummarySerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
username = serializers.CharField()
|
||||
first_name = serializers.CharField(required=False, allow_blank=True)
|
||||
last_name = serializers.CharField(required=False, allow_blank=True)
|
||||
email = serializers.EmailField(required=False, allow_blank=True, allow_null=True)
|
||||
avatar = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||
full_name = serializers.CharField(required=False)
|
||||
role = serializers.CharField(required=False)
|
||||
|
||||
@ -54,6 +65,13 @@ class CalendarRelationSerializer(serializers.ModelSerializer):
|
||||
def get_user(self, obj):
|
||||
"""Информация о связанном пользователе."""
|
||||
if obj.content_object:
|
||||
avatar = getattr(obj.content_object, "avatar", None)
|
||||
avatar_url = None
|
||||
if avatar:
|
||||
try:
|
||||
avatar_url = avatar.url
|
||||
except Exception:
|
||||
avatar_url = str(avatar)
|
||||
return {
|
||||
"id": obj.content_object.id,
|
||||
"username": getattr(
|
||||
@ -61,6 +79,8 @@ class CalendarRelationSerializer(serializers.ModelSerializer):
|
||||
),
|
||||
"first_name": getattr(obj.content_object, "first_name", ""),
|
||||
"last_name": getattr(obj.content_object, "last_name", ""),
|
||||
"email": getattr(obj.content_object, "email", ""),
|
||||
"avatar": avatar_url,
|
||||
}
|
||||
return None
|
||||
|
||||
@ -101,6 +121,13 @@ class CalendarSerializer(serializers.ModelSerializer):
|
||||
owner = serializers.SerializerMethodField()
|
||||
participants = serializers.SerializerMethodField()
|
||||
user_role = serializers.SerializerMethodField()
|
||||
type = serializers.SerializerMethodField()
|
||||
context_type = serializers.SerializerMethodField()
|
||||
context_object_id = serializers.SerializerMethodField()
|
||||
flags = serializers.SerializerMethodField()
|
||||
can_create_events = serializers.SerializerMethodField()
|
||||
can_edit_calendar = serializers.SerializerMethodField()
|
||||
can_manage_participants = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Calendar
|
||||
@ -110,9 +137,16 @@ class CalendarSerializer(serializers.ModelSerializer):
|
||||
"title",
|
||||
"slug",
|
||||
"events_count",
|
||||
"type",
|
||||
"context_type",
|
||||
"context_object_id",
|
||||
"flags",
|
||||
"owner",
|
||||
"participants",
|
||||
"user_role",
|
||||
"can_create_events",
|
||||
"can_edit_calendar",
|
||||
"can_manage_participants",
|
||||
]
|
||||
read_only_fields = ["id", "owner", "participants", "user_role"]
|
||||
extra_kwargs = {
|
||||
@ -125,6 +159,28 @@ class CalendarSerializer(serializers.ModelSerializer):
|
||||
"""Количество событий в календаре."""
|
||||
return obj.event_set.count()
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_type(self, obj):
|
||||
binding = get_calendar_binding(obj)
|
||||
return binding.type if binding else "default"
|
||||
|
||||
@extend_schema_field(serializers.CharField(allow_null=True))
|
||||
def get_context_type(self, obj):
|
||||
binding = get_calendar_binding(obj)
|
||||
if not binding or not binding.context_content_type_id:
|
||||
return None
|
||||
return binding.context_content_type.model
|
||||
|
||||
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||
def get_context_object_id(self, obj):
|
||||
binding = get_calendar_binding(obj)
|
||||
return binding.context_object_id if binding else None
|
||||
|
||||
@extend_schema_field(serializers.JSONField())
|
||||
def get_flags(self, obj):
|
||||
binding = get_calendar_binding(obj)
|
||||
return binding.flags if binding else {}
|
||||
|
||||
@extend_schema_field(RelatedUserSummarySerializer(allow_null=True))
|
||||
def get_owner(self, obj):
|
||||
"""Получить владельца календаря."""
|
||||
@ -199,6 +255,24 @@ class CalendarSerializer(serializers.ModelSerializer):
|
||||
|
||||
return relation.distinction if relation else None
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_create_events(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return user_can_create_event(user, obj) if user else False
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_edit_calendar(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return user_can_edit_calendar(user, obj) if user else False
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_manage_participants(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return user_can_manage_participants(user, obj) if user else False
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Создать календарь с автогенерацией slug из name/title."""
|
||||
from django.utils.text import slugify
|
||||
@ -397,6 +471,8 @@ class EventSerializer(serializers.ModelSerializer):
|
||||
source="rule.description", read_only=True, allow_null=True
|
||||
)
|
||||
rule_data = serializers.SerializerMethodField()
|
||||
can_edit = serializers.SerializerMethodField()
|
||||
can_delete = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
@ -416,6 +492,8 @@ class EventSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"created_on",
|
||||
"updated_on",
|
||||
"can_edit",
|
||||
"can_delete",
|
||||
]
|
||||
read_only_fields = ["id", "created_on", "updated_on"]
|
||||
|
||||
@ -446,6 +524,18 @@ class EventSerializer(serializers.ModelSerializer):
|
||||
|
||||
return attrs
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_edit(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return user_can_edit_event(user, obj) if user else False
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_can_delete(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return user_can_delete_event(user, obj) if user else False
|
||||
|
||||
|
||||
class EventListSerializer(EventSerializer):
|
||||
"""Упрощённый сериализатор для списка событий."""
|
||||
@ -462,6 +552,8 @@ class EventListSerializer(EventSerializer):
|
||||
"color_event",
|
||||
"rule",
|
||||
"end_recurring_period",
|
||||
"can_edit",
|
||||
"can_delete",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -3,11 +3,12 @@
|
||||
Тесты для django-scheduler API endpoints.
|
||||
"""
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, timedelta
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from schedule.models import Calendar, Event, Rule
|
||||
from schedule.models import Calendar, CalendarRelation, Event, Rule
|
||||
from employees.models import Employee
|
||||
|
||||
|
||||
@ -43,10 +44,18 @@ def authenticated_client(api_client, test_user):
|
||||
@pytest.fixture
|
||||
def test_calendar(test_user):
|
||||
"""Создание тестового календаря."""
|
||||
return Calendar.objects.create(
|
||||
calendar = Calendar.objects.create(
|
||||
name="Тестовый календарь",
|
||||
slug="test-calendar"
|
||||
)
|
||||
CalendarRelation.objects.create(
|
||||
calendar=calendar,
|
||||
content_type=ContentType.objects.get_for_model(Employee),
|
||||
object_id=test_user.id,
|
||||
distinction="owner",
|
||||
inheritable=True,
|
||||
)
|
||||
return calendar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -35,6 +35,33 @@ from .serializers import (
|
||||
EventRelationSerializer,
|
||||
)
|
||||
from .permissions import IsOwnerOfCalendar, CanEditCalendar
|
||||
from scheduling.models import CalendarBinding
|
||||
from scheduling.services import (
|
||||
get_calendar_binding,
|
||||
user_can_edit_calendar,
|
||||
user_can_delete_event,
|
||||
user_can_edit_event,
|
||||
user_can_manage_participants,
|
||||
user_can_view_calendar,
|
||||
)
|
||||
|
||||
|
||||
def _admin_requested_all_scope(request) -> bool:
|
||||
user = request.user
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
return False
|
||||
return request.query_params.get("scope") == "all"
|
||||
|
||||
|
||||
def _user_accessible_calendar_ids(user):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
return CalendarRelation.objects.filter(
|
||||
content_type=ct,
|
||||
object_id=user.id,
|
||||
).values_list("calendar_id", flat=True)
|
||||
|
||||
|
||||
class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
@ -56,7 +83,10 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
- GET /api/v1/schedule/calendars/{id}/export_ical/ - экспорт в .ics
|
||||
"""
|
||||
|
||||
queryset = Calendar.objects.all()
|
||||
queryset = Calendar.objects.select_related(
|
||||
"binding",
|
||||
"binding__context_content_type",
|
||||
).all()
|
||||
serializer_class = CalendarSerializer
|
||||
permission_classes = [IsAuthenticated, IsOwnerOfCalendar]
|
||||
renderer_classes = [JSONRenderer]
|
||||
@ -66,31 +96,33 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
"""Фильтрация календарей по доступу пользователя."""
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
qs = super().get_queryset()
|
||||
qs = super().get_queryset().select_related(
|
||||
"binding",
|
||||
"binding__context_content_type",
|
||||
)
|
||||
user = self.request.user
|
||||
show_all_scope = _admin_requested_all_scope(self.request)
|
||||
|
||||
# Админы видят все
|
||||
if user.is_staff or user.is_superuser:
|
||||
return qs.order_by("name")
|
||||
|
||||
# Остальные видят только календари, где они участники
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
calendar_ids = CalendarRelation.objects.filter(
|
||||
content_type=ct, object_id=user.id
|
||||
).values_list("calendar_id", flat=True)
|
||||
|
||||
qs = qs.filter(id__in=calendar_ids)
|
||||
# Для detail/admin actions доступ админа остаётся полным.
|
||||
if (user.is_staff or user.is_superuser) and (
|
||||
self.action != "list" or show_all_scope
|
||||
):
|
||||
filtered = qs
|
||||
else:
|
||||
calendar_ids = _user_accessible_calendar_ids(user)
|
||||
visibility_filter = Q(id__in=calendar_ids)
|
||||
if self.action not in {"list", "create"}:
|
||||
visibility_filter |= Q(
|
||||
binding__type=CalendarBinding.BindingType.DEPARTMENT
|
||||
)
|
||||
filtered = qs.filter(visibility_filter)
|
||||
|
||||
# Фильтр по имени
|
||||
name = self.request.query_params.get("name")
|
||||
if name:
|
||||
qs = qs.filter(name__icontains=name)
|
||||
filtered = filtered.filter(name__icontains=name)
|
||||
|
||||
return qs.order_by("name")
|
||||
return filtered.distinct().order_by("name")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""При создании календаря автоматически добавляем creator как owner."""
|
||||
@ -99,6 +131,10 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
calendar.create_relation(
|
||||
self.request.user, distinction="owner", inheritable=True
|
||||
)
|
||||
CalendarBinding.objects.get_or_create(
|
||||
calendar=calendar,
|
||||
defaults={"type": CalendarBinding.BindingType.DEFAULT},
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="add-participant")
|
||||
def add_participant(self, request, pk=None):
|
||||
@ -112,8 +148,21 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
calendar = self.get_object()
|
||||
|
||||
if get_calendar_binding(calendar) and get_calendar_binding(
|
||||
calendar
|
||||
).type == CalendarBinding.BindingType.DEPARTMENT:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Участники календаря отдела синхронизируются "
|
||||
"автоматически."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Проверка прав (только owner может добавлять участников)
|
||||
if not self._is_owner(calendar, request.user):
|
||||
if not user_can_manage_participants(request.user, calendar):
|
||||
return Response(
|
||||
{"detail": "Только владелец может добавлять участников"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@ -142,8 +191,21 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
"""Удалить участника из календаря."""
|
||||
calendar = self.get_object()
|
||||
|
||||
if get_calendar_binding(calendar) and get_calendar_binding(
|
||||
calendar
|
||||
).type == CalendarBinding.BindingType.DEPARTMENT:
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
"Участники календаря отдела синхронизируются "
|
||||
"автоматически."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Проверка прав
|
||||
if not self._is_owner(calendar, request.user):
|
||||
if not user_can_manage_participants(request.user, calendar):
|
||||
return Response(
|
||||
{"detail": "Только владелец может удалять участников"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@ -222,7 +284,7 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
calendar = self.get_object()
|
||||
|
||||
# Проверка, что пользователь - владелец
|
||||
if not self._is_owner(calendar, request.user):
|
||||
if not user_can_edit_calendar(request.user, calendar):
|
||||
return Response(
|
||||
{
|
||||
"error": (
|
||||
@ -312,24 +374,6 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def _is_owner(self, calendar, user):
|
||||
"""Проверка, является ли пользователь владельцем календаря."""
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
return CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=ct,
|
||||
object_id=user.id,
|
||||
distinction="owner",
|
||||
).exists()
|
||||
|
||||
|
||||
class ScheduleEventViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD для django-scheduler Event.
|
||||
|
||||
@ -343,7 +387,13 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
|
||||
- DELETE /api/v1/schedule/events/{id}/ - удалить
|
||||
"""
|
||||
|
||||
queryset = Event.objects.select_related("calendar", "rule", "creator")
|
||||
queryset = Event.objects.select_related(
|
||||
"calendar",
|
||||
"calendar__binding",
|
||||
"calendar__binding__context_content_type",
|
||||
"rule",
|
||||
"creator",
|
||||
)
|
||||
permission_classes = [IsAuthenticated, CanEditCalendar]
|
||||
renderer_classes = [JSONRenderer]
|
||||
|
||||
@ -364,32 +414,54 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
qs = super().get_queryset()
|
||||
qs = super().get_queryset().select_related(
|
||||
"calendar",
|
||||
"calendar__binding",
|
||||
"calendar__binding__context_content_type",
|
||||
"rule",
|
||||
"creator",
|
||||
)
|
||||
user = self.request.user
|
||||
calendar_id = self.request.query_params.get("calendar")
|
||||
show_all_scope = _admin_requested_all_scope(self.request)
|
||||
|
||||
# Админы видят все события
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
# Полный обзор для админа включается только явным scope=all
|
||||
# либо на detail actions, где нужен прямой доступ.
|
||||
if not ((user.is_staff or user.is_superuser) and show_all_scope):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
# ID календарей где пользователь участник
|
||||
accessible_calendar_ids = CalendarRelation.objects.filter(
|
||||
content_type=ct, object_id=user.id
|
||||
).values_list("calendar_id", flat=True)
|
||||
|
||||
# ID событий где пользователь участник
|
||||
accessible_calendar_ids = _user_accessible_calendar_ids(user)
|
||||
participant_event_ids = EventRelation.objects.filter(
|
||||
content_type=ct, object_id=user.id
|
||||
content_type=ct,
|
||||
object_id=user.id,
|
||||
).values_list("event_id", flat=True)
|
||||
|
||||
# Объединяем: события из доступных календарей ИЛИ где пользователь
|
||||
# участник
|
||||
qs = qs.filter(
|
||||
visibility_filter = (
|
||||
Q(calendar_id__in=accessible_calendar_ids)
|
||||
| Q(id__in=participant_event_ids)
|
||||
)
|
||||
|
||||
if self.action != "list":
|
||||
visibility_filter |= Q(
|
||||
calendar__binding__type=CalendarBinding.BindingType.DEPARTMENT
|
||||
)
|
||||
|
||||
if calendar_id:
|
||||
try:
|
||||
calendar = Calendar.objects.select_related("binding").get(
|
||||
id=calendar_id
|
||||
)
|
||||
except Calendar.DoesNotExist:
|
||||
calendar = None
|
||||
|
||||
if calendar and user_can_view_calendar(user, calendar):
|
||||
visibility_filter |= Q(calendar_id=calendar.id)
|
||||
|
||||
qs = qs.filter(visibility_filter)
|
||||
|
||||
# Фильтр по календарю
|
||||
calendar_id = self.request.query_params.get("calendar")
|
||||
if calendar_id:
|
||||
qs = qs.filter(calendar_id=calendar_id)
|
||||
|
||||
@ -445,28 +517,29 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
|
||||
# Если указан конкретный календарь - фильтруем по нему
|
||||
if calendar_id:
|
||||
try:
|
||||
calendar = Calendar.objects.get(id=calendar_id)
|
||||
calendar = Calendar.objects.select_related("binding").get(
|
||||
id=calendar_id
|
||||
)
|
||||
calendars = [calendar]
|
||||
except Calendar.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Календарь не найден"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if not user_can_view_calendar(request.user, calendar):
|
||||
return Response(
|
||||
{"detail": "Нет доступа к календарю"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
# Если календарь не указан - берем все доступные пользователю
|
||||
# календари
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = request.user
|
||||
|
||||
if user.is_staff or user.is_superuser:
|
||||
if _admin_requested_all_scope(request):
|
||||
calendars = Calendar.objects.all()
|
||||
else:
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
calendar_ids = CalendarRelation.objects.filter(
|
||||
content_type=ct, object_id=user.id
|
||||
).values_list("calendar_id", flat=True)
|
||||
calendar_ids = _user_accessible_calendar_ids(user)
|
||||
calendars = Calendar.objects.filter(id__in=calendar_ids)
|
||||
|
||||
# Результат
|
||||
@ -495,6 +568,12 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
|
||||
"color_event": occurrence.event.color_event,
|
||||
# ID оригинального события
|
||||
"event_id": occurrence.event.id,
|
||||
"can_edit": user_can_edit_event(
|
||||
request.user, occurrence.event
|
||||
),
|
||||
"can_delete": user_can_delete_event(
|
||||
request.user, occurrence.event
|
||||
),
|
||||
"is_recurring": occurrence.event.rule_id is not None,
|
||||
"rule": occurrence.event.rule_id,
|
||||
"end_recurring_period": (
|
||||
|
||||
@ -31,8 +31,6 @@ class DeptPerm:
|
||||
MANAGE = "manage_department"
|
||||
CHANGE_HEAD = "change_department_head"
|
||||
ASSIGN_ROLE = "assign_department_role"
|
||||
MANAGE_CALENDAR = "manage_department_events"
|
||||
CREATE_POST = "publish_department_post"
|
||||
MANAGE_FEED = "manage_department_feed"
|
||||
VIEW_REQUESTCOMMENT = "view_requestcomment"
|
||||
ADD_REQUESTCOMMENT = "add_requestcomment"
|
||||
@ -44,8 +42,6 @@ class DeptPerm:
|
||||
(MANAGE, "Управлять отделом"),
|
||||
(CHANGE_HEAD, "Назначать руководителя"),
|
||||
(ASSIGN_ROLE, "Назначать роли участникам"),
|
||||
(MANAGE_CALENDAR, "Управлять календарём отдела"),
|
||||
(CREATE_POST, "Публиковать новости на странице отдела"),
|
||||
(MANAGE_FEED, "Редактировать публикации отдела"),
|
||||
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
|
||||
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.4 on 2026-04-16 11:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def remove_obsolete_department_permissions(apps, schema_editor):
|
||||
DepartmentPermission = apps.get_model("employees", "DepartmentPermission")
|
||||
DepartmentPermission.objects.filter(
|
||||
code__in=["manage_department_events", "publish_department_post"]
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0046_alter_ldapsyncqueue_operation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='departmentpermission',
|
||||
name='code',
|
||||
field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям'), ('view_request', 'Просмотр заявлений отдела'), ('can_process_requests', 'Рассмотрение заявлений отдела'), ('manage_department_equipment', 'Управлять оборудованием отдела')], max_length=64, unique=True),
|
||||
),
|
||||
migrations.RunPython(
|
||||
remove_obsolete_department_permissions,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -17,6 +17,14 @@ from .constants import (
|
||||
)
|
||||
|
||||
|
||||
def _sync_department_calendar_after_commit(department_id: int) -> None:
|
||||
from scheduling.services import (
|
||||
sync_department_calendar_binding_by_department_id,
|
||||
)
|
||||
|
||||
sync_department_calendar_binding_by_department_id(department_id)
|
||||
|
||||
|
||||
class DateRangeMixin(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -459,6 +467,12 @@ class Department(models.Model):
|
||||
},
|
||||
)
|
||||
|
||||
transaction.on_commit(
|
||||
lambda department_id=self.pk: _sync_department_calendar_after_commit(
|
||||
department_id
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def active_employees(self):
|
||||
"""
|
||||
|
||||
@ -25,6 +25,7 @@ class SchedulingConfig(AppConfig):
|
||||
|
||||
# Регистрируем сигналы для уведомлений
|
||||
import scheduling.notifications.signals # noqa: F401
|
||||
import scheduling.signals # noqa: F401
|
||||
|
||||
# Загружаем правила доступа (django-rules)
|
||||
try:
|
||||
|
||||
189
backend/scheduling/migrations/0002_calendarbinding.py
Normal file
189
backend/scheduling/migrations/0002_calendarbinding.py
Normal file
@ -0,0 +1,189 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def backfill_calendar_bindings(apps, schema_editor):
|
||||
Calendar = apps.get_model("schedule", "Calendar")
|
||||
CalendarBinding = apps.get_model("scheduling", "CalendarBinding")
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
Department = apps.get_model("employees", "Department")
|
||||
|
||||
department_ct, _ = ContentType.objects.get_or_create(
|
||||
app_label="employees",
|
||||
model="department",
|
||||
)
|
||||
department_ids = set(Department.objects.values_list("id", flat=True))
|
||||
|
||||
table_names = set(schema_editor.connection.introspection.table_names())
|
||||
legacy_department_map = {}
|
||||
if "calendar_app_calendar" in table_names:
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, owner_department_id
|
||||
FROM calendar_app_calendar
|
||||
WHERE owner_department_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
legacy_department_map = {
|
||||
old_id: department_id
|
||||
for old_id, department_id in cursor.fetchall()
|
||||
if department_id in department_ids
|
||||
}
|
||||
|
||||
candidates = defaultdict(list)
|
||||
for calendar in Calendar.objects.all().order_by("-id"):
|
||||
slug = calendar.slug or ""
|
||||
department_id = None
|
||||
priority = None
|
||||
|
||||
if slug.startswith("legacy-calendar-"):
|
||||
try:
|
||||
legacy_id = int(slug.removeprefix("legacy-calendar-"))
|
||||
except ValueError:
|
||||
legacy_id = None
|
||||
if legacy_id is not None:
|
||||
department_id = legacy_department_map.get(legacy_id)
|
||||
priority = 0 if department_id is not None else None
|
||||
elif slug.startswith("department-"):
|
||||
tail = slug.removeprefix("department-")
|
||||
if tail.isdigit():
|
||||
department_id = int(tail)
|
||||
if department_id in department_ids:
|
||||
priority = 1
|
||||
|
||||
if department_id is None or priority is None:
|
||||
continue
|
||||
|
||||
candidates[department_id].append((priority, calendar.id))
|
||||
|
||||
for department_id, rows in candidates.items():
|
||||
priority, calendar_id = sorted(rows, key=lambda item: (item[0], -item[1]))[0]
|
||||
CalendarBinding.objects.get_or_create(
|
||||
calendar_id=calendar_id,
|
||||
defaults={
|
||||
"type": "department",
|
||||
"context_content_type_id": department_ct.id,
|
||||
"context_object_id": department_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("employees", "0047_alter_departmentpermission_code"),
|
||||
("schedule", "0015_rename_indexes"),
|
||||
("scheduling", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CalendarBinding",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("default", "Обычный"),
|
||||
("department", "Отдел"),
|
||||
],
|
||||
db_index=True,
|
||||
default="default",
|
||||
max_length=32,
|
||||
verbose_name="Тип привязки",
|
||||
),
|
||||
),
|
||||
(
|
||||
"context_object_id",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="ID объекта контекста",
|
||||
),
|
||||
),
|
||||
(
|
||||
"flags",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name="Флаги",
|
||||
),
|
||||
),
|
||||
(
|
||||
"extra_data",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name="Доп. данные",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"calendar",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="binding",
|
||||
to="schedule.calendar",
|
||||
verbose_name="Календарь",
|
||||
),
|
||||
),
|
||||
(
|
||||
"context_content_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
verbose_name="Тип контекста",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Привязка календаря",
|
||||
"verbose_name_plural": "Привязки календарей",
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="calendarbinding",
|
||||
index=models.Index(
|
||||
fields=["type"], name="calendar_binding_type_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="calendarbinding",
|
||||
index=models.Index(
|
||||
fields=["context_content_type", "context_object_id"],
|
||||
name="calendar_binding_context_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="calendarbinding",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(
|
||||
("context_content_type__isnull", False),
|
||||
("context_object_id__isnull", False),
|
||||
("type", "department"),
|
||||
),
|
||||
fields=("type", "context_content_type", "context_object_id"),
|
||||
name="uniq_department_calendar_binding",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
backfill_calendar_bindings,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
72
backend/scheduling/models.py
Normal file
72
backend/scheduling/models.py
Normal file
@ -0,0 +1,72 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class CalendarBinding(models.Model):
|
||||
class BindingType(models.TextChoices):
|
||||
DEFAULT = "default", "Обычный"
|
||||
DEPARTMENT = "department", "Отдел"
|
||||
|
||||
calendar = models.OneToOneField(
|
||||
"schedule.Calendar",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="binding",
|
||||
verbose_name="Календарь",
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=32,
|
||||
choices=BindingType.choices,
|
||||
default=BindingType.DEFAULT,
|
||||
db_index=True,
|
||||
verbose_name="Тип привязки",
|
||||
)
|
||||
context_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="Тип контекста",
|
||||
)
|
||||
context_object_id = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="ID объекта контекста",
|
||||
)
|
||||
context_object = GenericForeignKey(
|
||||
"context_content_type",
|
||||
"context_object_id",
|
||||
)
|
||||
flags = models.JSONField(default=dict, blank=True, verbose_name="Флаги")
|
||||
extra_data = models.JSONField(
|
||||
default=dict, blank=True, verbose_name="Доп. данные"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Привязка календаря"
|
||||
verbose_name_plural = "Привязки календарей"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["type", "context_content_type", "context_object_id"],
|
||||
condition=Q(
|
||||
type="department",
|
||||
context_content_type__isnull=False,
|
||||
context_object_id__isnull=False,
|
||||
),
|
||||
name="uniq_department_calendar_binding",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["type"], name="calendar_binding_type_idx"),
|
||||
models.Index(
|
||||
fields=["context_content_type", "context_object_id"],
|
||||
name="calendar_binding_context_idx",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
target = self.context_object or self.calendar
|
||||
return f"{self.type}: {target}"
|
||||
378
backend/scheduling/services.py
Normal file
378
backend/scheduling/services.py
Normal file
@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment
|
||||
from schedule.models import Calendar, CalendarRelation, Event
|
||||
|
||||
from .models import CalendarBinding
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def get_calendar_binding(calendar: Calendar | None) -> CalendarBinding | None:
|
||||
if calendar is None:
|
||||
return None
|
||||
binding = getattr(calendar, "binding", None)
|
||||
if binding is not None:
|
||||
return binding
|
||||
return (
|
||||
CalendarBinding.objects.select_related("context_content_type")
|
||||
.filter(calendar=calendar)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def is_department_calendar(calendar: Calendar | None) -> bool:
|
||||
binding = get_calendar_binding(calendar)
|
||||
return bool(
|
||||
binding and binding.type == CalendarBinding.BindingType.DEPARTMENT
|
||||
)
|
||||
|
||||
|
||||
def get_department_calendar_binding(
|
||||
department: Department,
|
||||
) -> CalendarBinding | None:
|
||||
dept_ct = ContentType.objects.get_for_model(Department)
|
||||
return (
|
||||
CalendarBinding.objects.select_related("calendar")
|
||||
.filter(
|
||||
type=CalendarBinding.BindingType.DEPARTMENT,
|
||||
context_content_type=dept_ct,
|
||||
context_object_id=department.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def department_calendar_name(department: Department) -> str:
|
||||
return department.name
|
||||
|
||||
|
||||
def department_calendar_slug(department: Department) -> str:
|
||||
return f"department-{department.id}"
|
||||
|
||||
|
||||
def _unique_calendar_slug(base_slug: str) -> str:
|
||||
if not Calendar.objects.filter(slug=base_slug).exists():
|
||||
return base_slug
|
||||
|
||||
counter = 1
|
||||
while Calendar.objects.filter(slug=f"{base_slug}-{counter}").exists():
|
||||
counter += 1
|
||||
return f"{base_slug}-{counter}"
|
||||
|
||||
|
||||
def _find_existing_department_calendar(
|
||||
department: Department,
|
||||
) -> Calendar | None:
|
||||
slug = department_calendar_slug(department)
|
||||
return (
|
||||
Calendar.objects.filter(slug=slug)
|
||||
.exclude(binding__isnull=False)
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def get_or_create_department_calendar(
|
||||
department: Department,
|
||||
) -> tuple[Calendar, CalendarBinding]:
|
||||
binding = get_department_calendar_binding(department)
|
||||
if binding:
|
||||
sync_department_calendar_binding(department, binding=binding)
|
||||
return binding.calendar, binding
|
||||
|
||||
calendar = _find_existing_department_calendar(department)
|
||||
if calendar is None:
|
||||
calendar = Calendar.objects.create(
|
||||
name=department_calendar_name(department),
|
||||
slug=_unique_calendar_slug(department_calendar_slug(department)),
|
||||
)
|
||||
|
||||
dept_ct = ContentType.objects.get_for_model(Department)
|
||||
binding, _ = CalendarBinding.objects.get_or_create(
|
||||
calendar=calendar,
|
||||
defaults={
|
||||
"type": CalendarBinding.BindingType.DEPARTMENT,
|
||||
"context_content_type": dept_ct,
|
||||
"context_object_id": department.id,
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
binding.type != CalendarBinding.BindingType.DEPARTMENT
|
||||
or binding.context_content_type_id != dept_ct.id
|
||||
or binding.context_object_id != department.id
|
||||
):
|
||||
binding.type = CalendarBinding.BindingType.DEPARTMENT
|
||||
binding.context_content_type = dept_ct
|
||||
binding.context_object_id = department.id
|
||||
binding.save(
|
||||
update_fields=[
|
||||
"type",
|
||||
"context_content_type",
|
||||
"context_object_id",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
sync_department_calendar_binding(department, binding=binding)
|
||||
return binding.calendar, binding
|
||||
|
||||
|
||||
def _department_participant_ids(department: Department) -> set[int]:
|
||||
ids: set[int] = set(
|
||||
EmployeeDepartment.objects.filter(
|
||||
department=department,
|
||||
is_active=True,
|
||||
employee__is_active=True,
|
||||
).values_list("employee_id", flat=True)
|
||||
)
|
||||
|
||||
role_only_ids = RoleAssignment.objects.filter(
|
||||
role__department=department,
|
||||
is_active=True,
|
||||
employee__is_active=True,
|
||||
).values_list("employee_id", flat=True)
|
||||
ids.update(role_only_ids)
|
||||
|
||||
if department.head_id:
|
||||
ids.add(department.head_id)
|
||||
|
||||
return ids
|
||||
|
||||
|
||||
def _set_relation_role(
|
||||
calendar: Calendar, user_id: int, distinction: str
|
||||
) -> None:
|
||||
user_ct = ContentType.objects.get_for_model(User)
|
||||
relations = list(
|
||||
CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=user_ct,
|
||||
object_id=user_id,
|
||||
).order_by("id")
|
||||
)
|
||||
if relations:
|
||||
primary = relations[0]
|
||||
changed = False
|
||||
if primary.distinction != distinction:
|
||||
primary.distinction = distinction
|
||||
changed = True
|
||||
if primary.inheritable is not True:
|
||||
primary.inheritable = True
|
||||
changed = True
|
||||
if changed:
|
||||
primary.save(update_fields=["distinction", "inheritable"])
|
||||
if len(relations) > 1:
|
||||
CalendarRelation.objects.filter(
|
||||
id__in=[relation.id for relation in relations[1:]]
|
||||
).delete()
|
||||
return
|
||||
|
||||
calendar.create_relation(
|
||||
User.objects.get(pk=user_id),
|
||||
distinction=distinction,
|
||||
inheritable=True,
|
||||
)
|
||||
|
||||
|
||||
def sync_department_calendar_owner(
|
||||
department: Department, *, binding: CalendarBinding | None = None
|
||||
) -> Calendar | None:
|
||||
binding = binding or get_department_calendar_binding(department)
|
||||
if not binding:
|
||||
return None
|
||||
|
||||
calendar = binding.calendar
|
||||
desired_owner_id = department.head_id
|
||||
user_ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
owner_relations = CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=user_ct,
|
||||
distinction="owner",
|
||||
).order_by("id")
|
||||
|
||||
if desired_owner_id is None:
|
||||
owner_relations.delete()
|
||||
return calendar
|
||||
|
||||
_set_relation_role(calendar, desired_owner_id, "owner")
|
||||
|
||||
owner_relations.exclude(object_id=desired_owner_id).delete()
|
||||
return calendar
|
||||
|
||||
|
||||
def sync_department_calendar_members(
|
||||
department: Department, *, binding: CalendarBinding | None = None
|
||||
) -> Calendar | None:
|
||||
binding = binding or get_department_calendar_binding(department)
|
||||
if not binding:
|
||||
return None
|
||||
|
||||
calendar = binding.calendar
|
||||
desired_ids = _department_participant_ids(department)
|
||||
owner_id = department.head_id
|
||||
user_ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
existing_relations = list(
|
||||
CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=user_ct,
|
||||
).order_by("id")
|
||||
)
|
||||
existing_ids = {relation.object_id for relation in existing_relations}
|
||||
|
||||
if owner_id:
|
||||
_set_relation_role(calendar, owner_id, "owner")
|
||||
|
||||
for user_id in sorted(desired_ids):
|
||||
if user_id == owner_id:
|
||||
continue
|
||||
_set_relation_role(calendar, user_id, "editor")
|
||||
|
||||
stale_relation_ids = [
|
||||
relation.id
|
||||
for relation in existing_relations
|
||||
if relation.object_id not in desired_ids
|
||||
]
|
||||
if stale_relation_ids:
|
||||
CalendarRelation.objects.filter(id__in=stale_relation_ids).delete()
|
||||
|
||||
return calendar
|
||||
|
||||
|
||||
def sync_department_calendar_binding(
|
||||
department: Department, *, binding: CalendarBinding | None = None
|
||||
) -> Calendar | None:
|
||||
binding = binding or get_department_calendar_binding(department)
|
||||
if not binding:
|
||||
return None
|
||||
|
||||
calendar = binding.calendar
|
||||
desired_name = department_calendar_name(department)
|
||||
if calendar.name != desired_name:
|
||||
calendar.name = desired_name
|
||||
calendar.save(update_fields=["name"])
|
||||
|
||||
sync_department_calendar_owner(department, binding=binding)
|
||||
sync_department_calendar_members(department, binding=binding)
|
||||
return calendar
|
||||
|
||||
|
||||
def sync_department_calendar_if_bound(department: Department) -> None:
|
||||
binding = get_department_calendar_binding(department)
|
||||
if not binding:
|
||||
return
|
||||
sync_department_calendar_binding(department, binding=binding)
|
||||
|
||||
|
||||
def sync_department_calendar_binding_by_department_id(department_id: int) -> None:
|
||||
try:
|
||||
department = Department.objects.get(pk=department_id)
|
||||
except Department.DoesNotExist:
|
||||
return
|
||||
sync_department_calendar_if_bound(department)
|
||||
|
||||
|
||||
def delete_department_calendar(department_id: int) -> None:
|
||||
try:
|
||||
department = Department.objects.get(pk=department_id)
|
||||
except Department.DoesNotExist:
|
||||
department = None
|
||||
|
||||
if department:
|
||||
binding = get_department_calendar_binding(department)
|
||||
else:
|
||||
dept_ct = ContentType.objects.get_for_model(Department)
|
||||
binding = CalendarBinding.objects.filter(
|
||||
type=CalendarBinding.BindingType.DEPARTMENT,
|
||||
context_content_type=dept_ct,
|
||||
context_object_id=department_id,
|
||||
).select_related("calendar").first()
|
||||
|
||||
if binding:
|
||||
binding.calendar.delete()
|
||||
|
||||
|
||||
def user_relation_distinction(
|
||||
calendar: Calendar, user
|
||||
) -> str | None:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return None
|
||||
if user.is_staff or user.is_superuser:
|
||||
return "owner"
|
||||
|
||||
user_ct = ContentType.objects.get_for_model(User)
|
||||
relation = CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=user_ct,
|
||||
object_id=user.id,
|
||||
).order_by("id").first()
|
||||
return relation.distinction if relation else None
|
||||
|
||||
|
||||
def user_can_view_calendar(user, calendar: Calendar) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
if is_department_calendar(calendar):
|
||||
return True
|
||||
return user_relation_distinction(calendar, user) is not None
|
||||
|
||||
|
||||
def user_can_create_event(user, calendar: Calendar) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
if is_department_calendar(calendar):
|
||||
return True
|
||||
return user_relation_distinction(calendar, user) in {"owner", "editor"}
|
||||
|
||||
|
||||
def user_can_edit_calendar(user, calendar: Calendar) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
return user_relation_distinction(calendar, user) == "owner"
|
||||
|
||||
|
||||
def user_can_manage_participants(user, calendar: Calendar) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
if is_department_calendar(calendar):
|
||||
return False
|
||||
return user_relation_distinction(calendar, user) == "owner"
|
||||
|
||||
|
||||
def user_can_edit_event(user, event: Event) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
|
||||
calendar = event.calendar
|
||||
if is_department_calendar(calendar):
|
||||
relation = user_relation_distinction(calendar, user)
|
||||
if relation in {"owner", "editor"}:
|
||||
return True
|
||||
return event.creator_id == user.id
|
||||
|
||||
return user_relation_distinction(calendar, user) in {"owner", "editor"}
|
||||
|
||||
|
||||
def user_can_delete_event(user, event: Event) -> bool:
|
||||
return user_can_edit_event(user, event)
|
||||
35
backend/scheduling/signals.py
Normal file
35
backend/scheduling/signals.py
Normal file
@ -0,0 +1,35 @@
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment
|
||||
|
||||
from .services import delete_department_calendar, sync_department_calendar_if_bound
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Department)
|
||||
def delete_department_calendar_on_department_delete(sender, instance, **kwargs):
|
||||
delete_department_calendar(instance.id)
|
||||
|
||||
|
||||
@receiver(post_save, sender=EmployeeDepartment)
|
||||
def sync_department_calendar_on_member_save(sender, instance, **kwargs):
|
||||
sync_department_calendar_if_bound(instance.department)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=EmployeeDepartment)
|
||||
def sync_department_calendar_on_member_delete(sender, instance, **kwargs):
|
||||
sync_department_calendar_if_bound(instance.department)
|
||||
|
||||
|
||||
@receiver(post_save, sender=RoleAssignment)
|
||||
def sync_department_calendar_on_role_assignment_save(
|
||||
sender, instance, **kwargs
|
||||
):
|
||||
sync_department_calendar_if_bound(instance.role.department)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=RoleAssignment)
|
||||
def sync_department_calendar_on_role_assignment_delete(
|
||||
sender, instance, **kwargs
|
||||
):
|
||||
sync_department_calendar_if_bound(instance.role.department)
|
||||
352
backend/tests/api/v1/employees/test_department_calendar.py
Normal file
352
backend/tests/api/v1/employees/test_department_calendar.py
Normal file
@ -0,0 +1,352 @@
|
||||
import itertools
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from schedule.models import CalendarRelation, Event
|
||||
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentRole,
|
||||
EmployeeDepartment,
|
||||
RoleAssignment,
|
||||
)
|
||||
from scheduling.models import CalendarBinding
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
_phone_seq = itertools.count(5000)
|
||||
|
||||
|
||||
def _unique_phone() -> str:
|
||||
return f"+7999666{next(_phone_seq):04d}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
def make_user(email: str, *, first_name: str = "FN", last_name: str = "LN"):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user = User.objects.create(
|
||||
email=email,
|
||||
phone_number=_unique_phone(),
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
user.set_password("pass")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
def department_calendar_url(department_id: int) -> str:
|
||||
base = reverse("api:v1:departments-detail", args=[department_id])
|
||||
return f"{base}calendar/"
|
||||
|
||||
|
||||
def test_department_calendar_endpoint_creates_binding_and_syncs_participants(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com", first_name="Head")
|
||||
member = make_user("member@example.com", first_name="Member")
|
||||
role_only = make_user("role@example.com", first_name="Role")
|
||||
outsider = make_user("outsider@example.com", first_name="Out")
|
||||
|
||||
department = Department.objects.create(name="Finance", head=head)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
role = DepartmentRole.objects.create(
|
||||
department=department,
|
||||
name="Analyst",
|
||||
)
|
||||
RoleAssignment.objects.create(
|
||||
employee=role_only,
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
response = api_client.get(department_calendar_url(department.id))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
payload = response.json()
|
||||
assert payload["calendar_type"] == "department"
|
||||
assert payload["context_type"] == "department"
|
||||
assert payload["context_object_id"] == department.id
|
||||
|
||||
binding = CalendarBinding.objects.select_related("calendar").get(
|
||||
calendar_id=payload["calendar_id"]
|
||||
)
|
||||
assert binding.type == CalendarBinding.BindingType.DEPARTMENT
|
||||
assert binding.context_object == department
|
||||
|
||||
user_ct = ContentType.objects.get_for_model(type(head))
|
||||
relations = {
|
||||
relation.object_id: relation.distinction
|
||||
for relation in CalendarRelation.objects.filter(
|
||||
calendar=binding.calendar,
|
||||
content_type=user_ct,
|
||||
)
|
||||
}
|
||||
assert relations[head.id] == "owner"
|
||||
assert relations[member.id] == "editor"
|
||||
assert relations[role_only.id] == "editor"
|
||||
assert outsider.id not in relations
|
||||
|
||||
|
||||
def test_department_calendar_visibility_is_limited_to_synced_participants(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com")
|
||||
member = make_user("member@example.com")
|
||||
outsider = make_user("outsider@example.com")
|
||||
department = Department.objects.create(name="Accounting", head=head)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=head)
|
||||
payload = api_client.get(department_calendar_url(department.id)).json()
|
||||
calendar_id = payload["calendar_id"]
|
||||
|
||||
response = api_client.get("/api/v1/schedule/calendars/")
|
||||
calendar_ids = [item["id"] for item in response.data]
|
||||
assert calendar_id in calendar_ids
|
||||
|
||||
api_client.force_authenticate(user=member)
|
||||
response = api_client.get("/api/v1/schedule/calendars/")
|
||||
calendar_ids = [item["id"] for item in response.data]
|
||||
assert calendar_id in calendar_ids
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
response = api_client.get("/api/v1/schedule/calendars/")
|
||||
calendar_ids = [item["id"] for item in response.data]
|
||||
assert calendar_id not in calendar_ids
|
||||
|
||||
response = api_client.get(department_calendar_url(department.id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["calendar_id"] == calendar_id
|
||||
|
||||
|
||||
def test_department_calendar_event_permissions_for_non_member_and_member(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com")
|
||||
member = make_user("member@example.com")
|
||||
outsider = make_user("outsider@example.com")
|
||||
department = Department.objects.create(name="Ops", head=head)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=member)
|
||||
calendar_id = api_client.get(department_calendar_url(department.id)).data[
|
||||
"calendar_id"
|
||||
]
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
create_response = api_client.post(
|
||||
"/api/v1/schedule/events/",
|
||||
{
|
||||
"calendar": calendar_id,
|
||||
"title": "Outsider event",
|
||||
"start": "2026-04-20T09:00:00+03:00",
|
||||
"end": "2026-04-20T10:00:00+03:00",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
event_id = create_response.data["id"]
|
||||
|
||||
patch_response = api_client.patch(
|
||||
f"/api/v1/schedule/events/{event_id}/",
|
||||
{"title": "Outsider event updated"},
|
||||
format="json",
|
||||
)
|
||||
assert patch_response.status_code == status.HTTP_200_OK
|
||||
|
||||
api_client.force_authenticate(user=member)
|
||||
patch_response = api_client.patch(
|
||||
f"/api/v1/schedule/events/{event_id}/",
|
||||
{"title": "Member edited outsider event"},
|
||||
format="json",
|
||||
)
|
||||
assert patch_response.status_code == status.HTTP_200_OK
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
start = timezone.now()
|
||||
member_event = Event.objects.create(
|
||||
calendar_id=calendar_id,
|
||||
title="Member event",
|
||||
start=start,
|
||||
end=start + timedelta(hours=1),
|
||||
creator=member,
|
||||
)
|
||||
forbidden_response = api_client.patch(
|
||||
f"/api/v1/schedule/events/{member_event.id}/",
|
||||
{"title": "Outsider cannot edit this"},
|
||||
format="json",
|
||||
)
|
||||
assert forbidden_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
forbidden_delete = api_client.delete(
|
||||
f"/api/v1/schedule/events/{member_event.id}/"
|
||||
)
|
||||
assert forbidden_delete.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_department_calendar_membership_sync_removes_inactive_member(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com")
|
||||
member = make_user("member@example.com")
|
||||
department = Department.objects.create(name="Legal", head=head)
|
||||
link = EmployeeDepartment.objects.create(
|
||||
employee=member,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=head)
|
||||
calendar_id = api_client.get(department_calendar_url(department.id)).data[
|
||||
"calendar_id"
|
||||
]
|
||||
|
||||
user_ct = ContentType.objects.get_for_model(type(head))
|
||||
assert CalendarRelation.objects.filter(
|
||||
calendar_id=calendar_id,
|
||||
content_type=user_ct,
|
||||
object_id=member.id,
|
||||
).exists()
|
||||
|
||||
link.is_active = False
|
||||
link.save(update_fields=["is_active"])
|
||||
|
||||
assert not CalendarRelation.objects.filter(
|
||||
calendar_id=calendar_id,
|
||||
content_type=user_ct,
|
||||
object_id=member.id,
|
||||
).exists()
|
||||
|
||||
|
||||
def test_department_calendar_rejects_manual_participant_changes(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com")
|
||||
member = make_user("member@example.com")
|
||||
outsider = make_user("outsider@example.com")
|
||||
department = Department.objects.create(name="Audit", head=head)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=head)
|
||||
calendar_id = api_client.get(department_calendar_url(department.id)).data[
|
||||
"calendar_id"
|
||||
]
|
||||
|
||||
add_response = api_client.post(
|
||||
f"/api/v1/schedule/calendars/{calendar_id}/add-participant/",
|
||||
{"user_id": outsider.id, "role": "viewer"},
|
||||
format="json",
|
||||
)
|
||||
assert add_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "синхронизируются" in add_response.data["detail"]
|
||||
|
||||
remove_response = api_client.delete(
|
||||
f"/api/v1/schedule/calendars/{calendar_id}/remove-participant/{member.id}/"
|
||||
)
|
||||
assert remove_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "синхронизируются" in remove_response.data["detail"]
|
||||
|
||||
|
||||
def test_department_calendar_participants_include_avatar_and_email(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com", first_name="Head", last_name="User")
|
||||
head.avatar = "avatars/head-calendar.jpg"
|
||||
head.save(update_fields=["avatar"])
|
||||
|
||||
department = Department.objects.create(name="HR", head=head)
|
||||
|
||||
api_client.force_authenticate(user=head)
|
||||
calendar_id = api_client.get(department_calendar_url(department.id)).data[
|
||||
"calendar_id"
|
||||
]
|
||||
|
||||
response = api_client.get(
|
||||
f"/api/v1/schedule/calendars/{calendar_id}/participants/"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 1
|
||||
participant = response.data[0]
|
||||
assert participant["user"]["id"] == head.id
|
||||
assert participant["user"]["email"] == head.email
|
||||
assert participant["user"]["avatar"] == head.avatar.url
|
||||
assert participant["user"]["first_name"] == "Head"
|
||||
assert participant["user"]["last_name"] == "User"
|
||||
|
||||
|
||||
def test_department_calendar_occurrences_allow_outsider_but_keep_permissions(
|
||||
api_client: APIClient,
|
||||
):
|
||||
head = make_user("head@example.com")
|
||||
member = make_user("member@example.com")
|
||||
outsider = make_user("outsider@example.com")
|
||||
department = Department.objects.create(name="Treasury", head=head)
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=member)
|
||||
calendar_id = api_client.get(department_calendar_url(department.id)).data[
|
||||
"calendar_id"
|
||||
]
|
||||
|
||||
start = timezone.now().replace(microsecond=0)
|
||||
member_event = Event.objects.create(
|
||||
calendar_id=calendar_id,
|
||||
title="Member-only event",
|
||||
start=start,
|
||||
end=start + timedelta(hours=1),
|
||||
creator=member,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
response = api_client.get(
|
||||
"/api/v1/schedule/events/occurrences/",
|
||||
{
|
||||
"calendar": calendar_id,
|
||||
"start": (start - timedelta(minutes=5)).isoformat(),
|
||||
"end": (start + timedelta(hours=2)).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 1
|
||||
occurrence = response.data[0]
|
||||
assert occurrence["event_id"] == member_event.id
|
||||
assert occurrence["can_edit"] is False
|
||||
assert occurrence["can_delete"] is False
|
||||
@ -173,6 +173,8 @@ def test_perm_choices_and_perms_and_set_perms(api_client: APIClient):
|
||||
assert resp.status_code == 200
|
||||
codes = {row["code"] for row in resp.json().get("results", [])}
|
||||
assert {"manage_department", "change_department_head", "assign_department_role"}.issubset(codes)
|
||||
assert "manage_department_events" not in codes
|
||||
assert "publish_department_post" not in codes
|
||||
|
||||
# set_perms (codes)
|
||||
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])
|
||||
|
||||
@ -4,10 +4,8 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from employees.constants import DeptPerm
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentPermission,
|
||||
DepartmentRole,
|
||||
EmployeeDepartment,
|
||||
RoleAssignment,
|
||||
@ -60,27 +58,6 @@ def test_department_user_perms_includes_feed_flags_for_head(api_client: APIClien
|
||||
assert resp.json()["can_manage_feed"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_department_user_perms_resolves_role_based_feed_rights(api_client: APIClient):
|
||||
employee = make_user()
|
||||
dept = Department.objects.create(name="Закупки")
|
||||
role = DepartmentRole.objects.create(department=dept, name="Редактор ленты")
|
||||
publish_perm, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=DeptPerm.CREATE_POST,
|
||||
defaults={"name": "Публиковать новости на странице отдела"},
|
||||
)
|
||||
role.scoped_permissions.add(publish_perm)
|
||||
RoleAssignment.objects.create(employee=employee, role=role, is_active=True)
|
||||
|
||||
api_client.force_authenticate(user=employee)
|
||||
url = reverse("api:v1:departments-user-perms", args=[dept.id])
|
||||
resp = api_client.get(url)
|
||||
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.json()["can_publish_posts"] is True
|
||||
assert resp.json()["can_manage_feed"] is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_department_user_perms_allows_active_member_to_publish(api_client: APIClient):
|
||||
employee = make_user()
|
||||
|
||||
@ -2,9 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from employees.constants import DeptPerm
|
||||
from employees.models import DepartmentPermission
|
||||
from employees.models import (
|
||||
DepartmentPermission,
|
||||
DepartmentRole,
|
||||
EmployeeDepartment,
|
||||
RoleAssignment,
|
||||
@ -28,14 +27,14 @@ def _department_post_payload(department_id: int, **extra):
|
||||
return payload
|
||||
|
||||
|
||||
def _grant_department_post_role(user, department):
|
||||
def _grant_department_feed_role(user, department):
|
||||
permission, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=DeptPerm.CREATE_POST,
|
||||
defaults={"name": "Публиковать новости на странице отдела"},
|
||||
code="manage_department_feed",
|
||||
defaults={"name": "Редактировать публикации отдела"},
|
||||
)
|
||||
role = DepartmentRole.objects.create(
|
||||
department=department,
|
||||
name=f"Publisher {user.id}",
|
||||
name=f"Feed manager {user.id}",
|
||||
)
|
||||
role.scoped_permissions.add(permission)
|
||||
RoleAssignment.objects.create(
|
||||
@ -197,26 +196,6 @@ def test_active_department_member_can_create_department_post(
|
||||
assert Post.objects.filter(id=response.data["id"], department=department).exists()
|
||||
|
||||
|
||||
def test_role_assignment_can_create_department_post(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
department_factory,
|
||||
):
|
||||
user = make_user("publisher@example.com")
|
||||
department = department_factory(name="Accounting")
|
||||
_grant_department_post_role(user, department)
|
||||
|
||||
client = auth_client_factory(user)
|
||||
response = client.post(
|
||||
API_POSTS_URL,
|
||||
_department_post_payload(department.id),
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert Post.objects.filter(id=response.data["id"], department=department).exists()
|
||||
|
||||
|
||||
def test_any_role_assignment_can_create_department_post(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
@ -245,6 +224,85 @@ def test_any_role_assignment_can_create_department_post(
|
||||
assert Post.objects.filter(id=response.data["id"], department=department).exists()
|
||||
|
||||
|
||||
def test_department_post_patch_requires_manage_feed_permission(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
department_factory,
|
||||
):
|
||||
author = make_user("author-edit@example.com")
|
||||
editor = make_user("editor@example.com")
|
||||
member_without_perm = make_user("member-without-feed@example.com")
|
||||
department = department_factory(name="Accounting", head=author)
|
||||
post = Post.objects.create(
|
||||
author=author,
|
||||
department=department,
|
||||
type="department",
|
||||
title="До редактирования",
|
||||
body="body",
|
||||
)
|
||||
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member_without_perm,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
_grant_department_feed_role(editor, department)
|
||||
|
||||
member_client = auth_client_factory(member_without_perm)
|
||||
response = member_client.patch(
|
||||
f"{API_POSTS_URL}{post.id}/",
|
||||
{"title": "Не должно пройти"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
editor_client = auth_client_factory(editor)
|
||||
response = editor_client.patch(
|
||||
f"{API_POSTS_URL}{post.id}/",
|
||||
{"title": "После редактирования"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
post.refresh_from_db()
|
||||
assert post.title == "После редактирования"
|
||||
|
||||
|
||||
def test_department_post_delete_requires_manage_feed_permission(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
department_factory,
|
||||
):
|
||||
author = make_user("author-delete@example.com")
|
||||
editor = make_user("feed-editor@example.com")
|
||||
member_without_perm = make_user("member-without-delete@example.com")
|
||||
department = department_factory(name="Finance", head=author)
|
||||
post = Post.objects.create(
|
||||
author=author,
|
||||
department=department,
|
||||
type="department",
|
||||
title="Удаляемый пост",
|
||||
body="body",
|
||||
)
|
||||
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member_without_perm,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
_grant_department_feed_role(editor, department)
|
||||
|
||||
member_client = auth_client_factory(member_without_perm)
|
||||
response = member_client.delete(f"{API_POSTS_URL}{post.id}/")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Post.objects.filter(id=post.id).exists()
|
||||
|
||||
editor_client = auth_client_factory(editor)
|
||||
response = editor_client.delete(f"{API_POSTS_URL}{post.id}/")
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Post.objects.filter(id=post.id).exists()
|
||||
|
||||
|
||||
def test_list_department_posts_filtered_by_department(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
def _migrate_to(target):
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph()
|
||||
executor.migrate(target)
|
||||
return executor.loader.project_state(target).apps
|
||||
|
||||
|
||||
def _migrate_to_latest():
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph()
|
||||
executor.migrate(executor.loader.graph.leaf_nodes())
|
||||
|
||||
|
||||
def test_migration_0047_removes_obsolete_department_permissions():
|
||||
try:
|
||||
old_apps = _migrate_to(
|
||||
[("employees", "0046_alter_ldapsyncqueue_operation")]
|
||||
)
|
||||
DepartmentPermission = old_apps.get_model(
|
||||
"employees", "DepartmentPermission"
|
||||
)
|
||||
|
||||
DepartmentPermission.objects.create(
|
||||
code="manage_department_events",
|
||||
name="Управлять календарём отдела",
|
||||
)
|
||||
DepartmentPermission.objects.create(
|
||||
code="publish_department_post",
|
||||
name="Публиковать новости на странице отдела",
|
||||
)
|
||||
DepartmentPermission.objects.create(
|
||||
code="manage_department",
|
||||
name="Управлять отделом",
|
||||
)
|
||||
|
||||
_migrate_to([("employees", "0047_alter_departmentpermission_code")])
|
||||
|
||||
from employees.models import (
|
||||
DepartmentPermission as CurrentDepartmentPermission,
|
||||
)
|
||||
|
||||
codes = set(
|
||||
CurrentDepartmentPermission.objects.values_list("code", flat=True)
|
||||
)
|
||||
assert "manage_department_events" not in codes
|
||||
assert "publish_department_post" not in codes
|
||||
assert "manage_department" in codes
|
||||
finally:
|
||||
_migrate_to_latest()
|
||||
52
backend/tests/scheduling/test_calendar_binding_migration.py
Normal file
52
backend/tests/scheduling/test_calendar_binding_migration.py
Normal file
@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
def _migrate_to(target):
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph()
|
||||
executor.migrate(target)
|
||||
return executor.loader.project_state(target).apps
|
||||
|
||||
|
||||
def _migrate_to_latest():
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph()
|
||||
executor.migrate(executor.loader.graph.leaf_nodes())
|
||||
|
||||
|
||||
def test_migration_0002_backfills_department_binding_by_slug():
|
||||
try:
|
||||
old_apps = _migrate_to(
|
||||
[
|
||||
("employees", "0047_alter_departmentpermission_code"),
|
||||
("schedule", "0015_rename_indexes"),
|
||||
("scheduling", "0001_initial"),
|
||||
]
|
||||
)
|
||||
Department = old_apps.get_model("employees", "Department")
|
||||
Calendar = old_apps.get_model("schedule", "Calendar")
|
||||
|
||||
department = Department.objects.create(name="Finance")
|
||||
calendar = Calendar.objects.create(
|
||||
name="Finance",
|
||||
slug=f"department-{department.id}",
|
||||
)
|
||||
|
||||
_migrate_to([("scheduling", "0002_calendarbinding")])
|
||||
|
||||
from scheduling.models import CalendarBinding
|
||||
|
||||
binding = CalendarBinding.objects.get(calendar_id=calendar.id)
|
||||
assert binding.type == CalendarBinding.BindingType.DEPARTMENT
|
||||
assert binding.context_object_id == department.id
|
||||
assert binding.context_content_type.app_label == "employees"
|
||||
assert binding.context_content_type.model == "department"
|
||||
finally:
|
||||
_migrate_to_latest()
|
||||
@ -2,9 +2,12 @@
|
||||
"""
|
||||
Тесты для системы прав доступа календарей django-scheduler.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from schedule.models import Calendar, CalendarRelation, Event, Rule
|
||||
@ -57,6 +60,22 @@ def user3(db):
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db):
|
||||
"""Администратор для проверки scope календарей."""
|
||||
return User.objects.create_user(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="testpass123",
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
phone_number="+79991234570",
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
email_verified=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCalendarPermissions:
|
||||
"""Тесты прав доступа к календарям."""
|
||||
@ -254,10 +273,136 @@ class TestCalendarPermissions:
|
||||
assert roles[user2.id] == 'editor'
|
||||
assert roles[user3.id] == 'viewer'
|
||||
|
||||
def test_admin_list_scope_defaults_to_accessible_only(
|
||||
self, api_client, admin_user, user1
|
||||
):
|
||||
"""По умолчанию админ видит свои доступные календари, а не весь реестр."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
admin_calendar_response = api_client.post(
|
||||
'/api/v1/schedule/calendars/',
|
||||
{'name': 'Admin Calendar'}
|
||||
)
|
||||
assert admin_calendar_response.status_code == status.HTTP_201_CREATED
|
||||
admin_calendar_id = admin_calendar_response.data['id']
|
||||
|
||||
api_client.force_authenticate(user=user1)
|
||||
other_calendar_response = api_client.post(
|
||||
'/api/v1/schedule/calendars/',
|
||||
{'name': 'Other Calendar'}
|
||||
)
|
||||
assert other_calendar_response.status_code == status.HTTP_201_CREATED
|
||||
other_calendar_id = other_calendar_response.data['id']
|
||||
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
default_response = api_client.get('/api/v1/schedule/calendars/')
|
||||
default_calendars = default_response.data
|
||||
assert {cal['id'] for cal in default_calendars} == {admin_calendar_id}
|
||||
|
||||
full_response = api_client.get('/api/v1/schedule/calendars/?scope=all')
|
||||
full_calendars = full_response.data
|
||||
assert {cal['id'] for cal in full_calendars} == {
|
||||
admin_calendar_id,
|
||||
other_calendar_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRecurringEvents:
|
||||
"""Тесты повторяющихся событий."""
|
||||
|
||||
def test_admin_event_scope_matches_calendar_scope(
|
||||
self, api_client, admin_user, user1
|
||||
):
|
||||
"""У admin default list/occurrences не должны тянуть чужие календари."""
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
admin_calendar_response = api_client.post(
|
||||
'/api/v1/schedule/calendars/',
|
||||
{'name': 'Admin Calendar'}
|
||||
)
|
||||
admin_calendar_id = admin_calendar_response.data['id']
|
||||
|
||||
api_client.force_authenticate(user=user1)
|
||||
other_calendar_response = api_client.post(
|
||||
'/api/v1/schedule/calendars/',
|
||||
{'name': 'Other Calendar'}
|
||||
)
|
||||
other_calendar_id = other_calendar_response.data['id']
|
||||
|
||||
start = timezone.now().replace(microsecond=0, second=0)
|
||||
range_end = start + timedelta(hours=2)
|
||||
Event.objects.create(
|
||||
calendar_id=admin_calendar_id,
|
||||
title='Admin event',
|
||||
start=start,
|
||||
end=start + timedelta(hours=1),
|
||||
creator=admin_user,
|
||||
)
|
||||
Event.objects.create(
|
||||
calendar_id=other_calendar_id,
|
||||
title='Other event',
|
||||
start=start,
|
||||
end=start + timedelta(hours=1),
|
||||
creator=user1,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
default_events = api_client.get(
|
||||
'/api/v1/schedule/events/',
|
||||
{
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert default_events.status_code == status.HTTP_200_OK
|
||||
default_events_list = (
|
||||
default_events.data['results']
|
||||
if isinstance(default_events.data, dict)
|
||||
else default_events.data
|
||||
)
|
||||
assert {event['title'] for event in default_events_list} == {'Admin event'}
|
||||
|
||||
all_events = api_client.get(
|
||||
'/api/v1/schedule/events/',
|
||||
{
|
||||
'scope': 'all',
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert all_events.status_code == status.HTTP_200_OK
|
||||
all_events_list = (
|
||||
all_events.data['results']
|
||||
if isinstance(all_events.data, dict)
|
||||
else all_events.data
|
||||
)
|
||||
assert {event['title'] for event in all_events_list} == {
|
||||
'Admin event',
|
||||
'Other event',
|
||||
}
|
||||
|
||||
default_occurrences = api_client.get(
|
||||
'/api/v1/schedule/events/occurrences/',
|
||||
{
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert default_occurrences.status_code == status.HTTP_200_OK
|
||||
assert {event['title'] for event in default_occurrences.data} == {'Admin event'}
|
||||
|
||||
all_occurrences = api_client.get(
|
||||
'/api/v1/schedule/events/occurrences/',
|
||||
{
|
||||
'scope': 'all',
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert all_occurrences.status_code == status.HTTP_200_OK
|
||||
assert {event['title'] for event in all_occurrences.data} == {
|
||||
'Admin event',
|
||||
'Other event',
|
||||
}
|
||||
|
||||
def test_create_rule(self, api_client, user1):
|
||||
"""Создание правила повторения."""
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
/* Кастомные стили для React Big Calendar в едином стиле с проектом */
|
||||
|
||||
.calendar-shell {
|
||||
background: color-mix(in srgb, var(--surface-secondary) 82%, var(--surface-primary));
|
||||
border: 1px solid color-mix(in srgb, var(--border-subtle) 92%, transparent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rbc-calendar {
|
||||
font-family: inherit;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Панель инструментов */
|
||||
@ -9,8 +19,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 0;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0 0 1rem;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@ -22,33 +32,33 @@
|
||||
.rbc-toolbar button {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 1px 2px 0 color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
.rbc-toolbar button:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-secondary);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 20%, var(--border-strong));
|
||||
box-shadow: 0 1px 3px 0 color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
}
|
||||
|
||||
.rbc-toolbar button.rbc-active {
|
||||
background: #0ea5e9;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border-color: #0ea5e9;
|
||||
box-shadow: 0 1px 3px 0 rgba(14, 165, 233, 0.3);
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 1px 3px 0 color-mix(in srgb, var(--accent-primary) 32%, transparent);
|
||||
}
|
||||
|
||||
.rbc-toolbar-label {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.025em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -59,27 +69,36 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
color: var(--muted-foreground);
|
||||
letter-spacing: 0.025em;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: color-mix(in srgb, var(--surface-tertiary) 72%, var(--surface-secondary));
|
||||
}
|
||||
|
||||
.rbc-header + .rbc-header {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Ячейки дней */
|
||||
.rbc-day-bg {
|
||||
border: 1px solid #f3f4f6;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: color-mix(in srgb, var(--surface-primary) 82%, var(--surface-elevated));
|
||||
}
|
||||
|
||||
.rbc-day-bg + .rbc-day-bg {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-day-bg.rbc-today {
|
||||
background-color: #f0f9ff !important;
|
||||
background-color: color-mix(in srgb, var(--accent-primary) 10%, var(--surface-primary)) !important;
|
||||
}
|
||||
|
||||
.rbc-day-bg.rbc-off-range-bg {
|
||||
background-color: #fafafa;
|
||||
background-color: color-mix(in srgb, var(--surface-secondary) 88%, var(--surface-primary));
|
||||
}
|
||||
|
||||
.rbc-date-cell {
|
||||
padding: 0.25rem;
|
||||
padding: 0.35rem 0.35rem 0.15rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@ -89,7 +108,7 @@
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
@ -97,26 +116,28 @@
|
||||
}
|
||||
|
||||
.rbc-date-cell button:hover {
|
||||
background: #f3f4f6;
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.rbc-date-cell.rbc-now button {
|
||||
background: #0ea5e9;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent-primary) 24%, transparent);
|
||||
}
|
||||
|
||||
.rbc-date-cell.rbc-off-range button {
|
||||
color: #9ca3af;
|
||||
color: color-mix(in srgb, var(--muted-foreground) 68%, transparent);
|
||||
}
|
||||
|
||||
/* События */
|
||||
.rbc-event {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 14px -10px color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
}
|
||||
|
||||
.rbc-event:hover {
|
||||
@ -134,30 +155,61 @@
|
||||
|
||||
/* Селектор временных слотов */
|
||||
.rbc-slot-selection {
|
||||
background-color: rgba(14, 165, 233, 0.15);
|
||||
background-color: color-mix(in srgb, var(--accent-primary) 16%, transparent);
|
||||
}
|
||||
|
||||
/* Неделя и день */
|
||||
.rbc-time-view {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 1.2rem;
|
||||
background: color-mix(in srgb, var(--surface-primary) 72%, var(--surface-elevated));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rbc-time-header {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: color-mix(in srgb, var(--surface-tertiary) 72%, var(--surface-secondary));
|
||||
}
|
||||
|
||||
.rbc-time-header.rbc-overflowing {
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-time-header > .rbc-row:first-child,
|
||||
.rbc-time-header > .rbc-row.rbc-row-resource,
|
||||
.rbc-time-header-content > .rbc-row.rbc-row-resource {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-time-header-content {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-time-content {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-time-content > * + * > * {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-timeslot-group {
|
||||
border-left: 1px solid #e5e7eb;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
min-height: 40px;
|
||||
background: color-mix(in srgb, var(--surface-primary) 82%, var(--surface-elevated));
|
||||
}
|
||||
|
||||
.rbc-time-slot {
|
||||
border-top: 1px solid #f3f4f6;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-subtle) 82%, transparent);
|
||||
}
|
||||
|
||||
.rbc-day-slot .rbc-time-slot {
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-subtle) 82%, transparent);
|
||||
}
|
||||
|
||||
.rbc-time-view .rbc-allday-cell + .rbc-allday-cell {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-current-time-indicator {
|
||||
@ -165,45 +217,79 @@
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.rbc-time-gutter,
|
||||
.rbc-label {
|
||||
color: var(--muted-foreground);
|
||||
background: color-mix(in srgb, var(--surface-tertiary) 72%, var(--surface-secondary));
|
||||
}
|
||||
|
||||
.rbc-time-view-resources .rbc-time-gutter,
|
||||
.rbc-time-view-resources .rbc-time-header-gutter {
|
||||
background-color: color-mix(in srgb, var(--surface-tertiary) 72%, var(--surface-secondary));
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Список (agenda) */
|
||||
.rbc-agenda-view {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 1.2rem;
|
||||
background: color-mix(in srgb, var(--surface-primary) 72%, var(--surface-elevated));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rbc-agenda-view table.rbc-agenda-table {
|
||||
border-spacing: 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.rbc-agenda-content {
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-agenda-view table.rbc-agenda-table tbody > tr > td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-agenda-view table.rbc-agenda-table tbody > tr:hover {
|
||||
background-color: #f9fafb;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.rbc-agenda-date-cell {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.rbc-agenda-time-cell {
|
||||
color: #6b7280;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.rbc-agenda-event-cell {
|
||||
color: #111827;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.rbc-agenda-empty {
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
min-height: 16rem;
|
||||
padding: 1rem 1.125rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 1.2rem;
|
||||
background: color-mix(in srgb, var(--surface-primary) 82%, var(--surface-elevated));
|
||||
color: var(--muted-foreground);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Попап "показать ещё" */
|
||||
.rbc-overlay {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.95rem;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
padding: 0.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
@ -211,42 +297,53 @@
|
||||
.rbc-overlay-header {
|
||||
padding: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: var(--foreground);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Показать больше */
|
||||
.rbc-show-more {
|
||||
background: transparent;
|
||||
color: #0ea5e9;
|
||||
color: var(--accent-primary-strong);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 0.45rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.rbc-show-more:hover {
|
||||
background: #f0f9ff;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Месячный вид */
|
||||
.rbc-month-view {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 1.2rem;
|
||||
background: color-mix(in srgb, var(--surface-primary) 72%, var(--surface-elevated));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rbc-month-row {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-month-row + .rbc-month-row {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.rbc-month-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rbc-month-view,
|
||||
.rbc-time-view,
|
||||
.rbc-agenda-view {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.rbc-toolbar {
|
||||
|
||||
@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject }
|
||||
import {
|
||||
ArrowLeft,
|
||||
BadgePlus,
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@ -760,6 +761,7 @@ export default function DepartmentDetailPage() {
|
||||
const [departmentMenuOpen, setDepartmentMenuOpen] = useState(false);
|
||||
const departmentMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [openingDepartmentChat, setOpeningDepartmentChat] = useState(false);
|
||||
const [openingDepartmentCalendar, setOpeningDepartmentCalendar] = useState(false);
|
||||
|
||||
const activeMembersCount = h.members.filter((member) => member.is_active).length;
|
||||
const roleOptions = h.roles.map((role) => ({ id: role.id, name: role.name }));
|
||||
@ -838,6 +840,35 @@ export default function DepartmentDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openDepartmentCalendar = async () => {
|
||||
if (!h.department) return;
|
||||
|
||||
try {
|
||||
setOpeningDepartmentCalendar(true);
|
||||
const response = await apiClient.getDepartmentCalendar(h.department.id) as {
|
||||
calendar_id: number;
|
||||
name: string;
|
||||
calendar_type?: string;
|
||||
context_type?: string | null;
|
||||
};
|
||||
const params = new URLSearchParams({
|
||||
calendar: String(response.calendar_id),
|
||||
calendarName: response.name,
|
||||
});
|
||||
if (response.calendar_type) {
|
||||
params.set("calendarType", response.calendar_type);
|
||||
}
|
||||
if (response.context_type) {
|
||||
params.set("contextType", response.context_type);
|
||||
}
|
||||
router.push(`/calendar?${params.toString()}`);
|
||||
} catch {
|
||||
toast.error("Не удалось открыть календарь отдела");
|
||||
} finally {
|
||||
setOpeningDepartmentCalendar(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="space-y-6">
|
||||
@ -1006,7 +1037,7 @@ export default function DepartmentDetailPage() {
|
||||
<p className="app-text-muted mt-2 text-sm">
|
||||
{h.department.description}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDepartmentChat()}
|
||||
@ -1014,7 +1045,16 @@ export default function DepartmentDetailPage() {
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<MessageCircle size={16} />
|
||||
Перейти в чат отдела
|
||||
Чат отдела
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDepartmentCalendar()}
|
||||
disabled={openingDepartmentCalendar}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<CalendarDays size={16} />
|
||||
Календарь отдела
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar, dateFnsLocalizer, View, ToolbarProps, NavigateAction } from "react-big-calendar";
|
||||
import { Calendar, dateFnsLocalizer, View, ToolbarProps } from "react-big-calendar";
|
||||
import { format, parse, startOfWeek, getDay } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
import { X, Plus, Trash2, Settings, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Plus, ChevronDown, ChevronLeft, ChevronRight, Users, Pencil, Trash2 } from "lucide-react";
|
||||
import { CalendarModal } from "@/components/CalendarModal";
|
||||
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
|
||||
import { EventModal } from "@/components/EventModal";
|
||||
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
|
||||
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
|
||||
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
|
||||
import { calendarService } from "@/services/calendarService";
|
||||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||
import "../app/calendar/calendar.css";
|
||||
|
||||
@ -43,6 +45,8 @@ type CalendarEvent = {
|
||||
isOccurrence?: boolean; // Флаг для повторяющихся событий
|
||||
event_id?: number; // ID базового события для occurrence
|
||||
allDay?: boolean; // Флаг целодневного события
|
||||
can_edit?: boolean;
|
||||
can_delete?: boolean;
|
||||
};
|
||||
|
||||
// Кастомные сообщения на русском
|
||||
@ -82,20 +86,20 @@ const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps<C
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onNavigate("TODAY")}
|
||||
className="app-action-secondary px-3 py-2"
|
||||
className="app-action-secondary rounded-xl px-4 py-2"
|
||||
>
|
||||
Сегодня
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate("PREV")}
|
||||
className="app-action-secondary flex h-9 w-9 items-center justify-center p-0"
|
||||
className="app-action-secondary flex h-10 w-10 items-center justify-center rounded-xl p-0"
|
||||
title="Назад"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onNavigate("NEXT")}
|
||||
className="app-action-secondary flex h-9 w-9 items-center justify-center p-0"
|
||||
className="app-action-secondary flex h-10 w-10 items-center justify-center rounded-xl p-0"
|
||||
title="Вперёд"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
@ -107,17 +111,18 @@ const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps<C
|
||||
</div>
|
||||
|
||||
{/* Выбор вида */}
|
||||
<div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={view}
|
||||
onChange={(e) => onView(e.target.value as View)}
|
||||
className="app-select w-full"
|
||||
className="app-select w-full appearance-none rounded-xl py-2.5 pl-3 pr-10 text-sm"
|
||||
>
|
||||
<option value="month">{viewLabels.month}</option>
|
||||
<option value="week">{viewLabels.week}</option>
|
||||
<option value="day">{viewLabels.day}</option>
|
||||
<option value="agenda">{viewLabels.agenda}</option>
|
||||
</select>
|
||||
<ChevronDown size={16} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -127,7 +132,15 @@ export function BigCalendar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { calendars, selectedCalendarId, setSelectedCalendarId, reloadCalendars } = useCalendar();
|
||||
const {
|
||||
calendars,
|
||||
selectedCalendarId,
|
||||
setSelectedCalendarId,
|
||||
calendarScope,
|
||||
setCalendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
reloadCalendars,
|
||||
} = useCalendar();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<View>("month");
|
||||
@ -138,13 +151,61 @@ export function BigCalendar() {
|
||||
const [editingEvent, setEditingEvent] = useState<Partial<CalendarEvent> | null>(null);
|
||||
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
||||
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
|
||||
const [showCalendarActions, setShowCalendarActions] = useState(false);
|
||||
const [showParticipantsModal, setShowParticipantsModal] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Новые модалы для просмотра
|
||||
const [showDayEventsModal, setShowDayEventsModal] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
|
||||
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
|
||||
const eventsRequestIdRef = useRef(0);
|
||||
const linkedEventId = Number(searchParams.get("event") || "");
|
||||
const linkedCalendarId = Number(searchParams.get("calendar") || "");
|
||||
const linkedCalendarName = searchParams.get("calendarName") || "";
|
||||
const linkedCalendarType = searchParams.get("calendarType") || "";
|
||||
const linkedContextType = searchParams.get("contextType") || "";
|
||||
|
||||
const selectedCalendar = useMemo(() => {
|
||||
const calendar = calendars.find((item) => item.id === selectedCalendarId);
|
||||
if (calendar) return calendar;
|
||||
if (selectedCalendarId && linkedCalendarName) {
|
||||
return {
|
||||
id: selectedCalendarId,
|
||||
name: linkedCalendarName,
|
||||
slug: `linked-calendar-${selectedCalendarId}`,
|
||||
type: linkedCalendarType || null,
|
||||
context_type: linkedContextType || null,
|
||||
can_create_events: true,
|
||||
can_edit_calendar: false,
|
||||
can_manage_participants: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [calendars, linkedCalendarName, linkedCalendarType, linkedContextType, selectedCalendarId]);
|
||||
|
||||
const calendarOptions = useMemo(() => {
|
||||
if (!selectedCalendar) return calendars;
|
||||
if (calendars.some((item) => item.id === selectedCalendar.id)) {
|
||||
return calendars;
|
||||
}
|
||||
return [...calendars, selectedCalendar];
|
||||
}, [calendars, selectedCalendar]);
|
||||
|
||||
const canOpenParticipantsModal = Boolean(
|
||||
selectedCalendar && (
|
||||
selectedCalendar.can_manage_participants ||
|
||||
selectedCalendar.type === "department" ||
|
||||
selectedCalendar.context_type === "department"
|
||||
)
|
||||
);
|
||||
const isDepartmentCalendar = Boolean(
|
||||
selectedCalendar && (
|
||||
selectedCalendar.type === "department" ||
|
||||
selectedCalendar.context_type === "department"
|
||||
)
|
||||
);
|
||||
|
||||
const clearEventParam = useCallback(() => {
|
||||
if (!searchParams.get("event")) return;
|
||||
@ -153,8 +214,16 @@ export function BigCalendar() {
|
||||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||||
}, [pathname, router, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkedCalendarId) return;
|
||||
setSelectedCalendarId(linkedCalendarId);
|
||||
}, [linkedCalendarId, setSelectedCalendarId]);
|
||||
|
||||
// Загрузка событий
|
||||
const loadEvents = useCallback(async () => {
|
||||
const requestId = ++eventsRequestIdRef.current;
|
||||
const requestedCalendarId = selectedCalendarId;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
@ -174,12 +243,14 @@ export function BigCalendar() {
|
||||
// Если selectedCalendarId === null, загружаем все доступные события со всех календарей
|
||||
const [eventsResult, occurrencesResult] = await Promise.all([
|
||||
apiClient.getCalendarEvents({
|
||||
calendar: selectedCalendarId || undefined,
|
||||
calendar: requestedCalendarId || undefined,
|
||||
scope: canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
}),
|
||||
apiClient.getOccurrences({
|
||||
calendar: selectedCalendarId || undefined,
|
||||
calendar: requestedCalendarId || undefined,
|
||||
scope: canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
}),
|
||||
@ -217,17 +288,35 @@ export function BigCalendar() {
|
||||
color_event: resolveEventColor(occ.color_event),
|
||||
event_id: occ.event_id,
|
||||
rule: occ.rule,
|
||||
can_edit: occ.can_edit,
|
||||
can_delete: occ.can_delete,
|
||||
isOccurrence: true, // Помечаем как occurrence для корректного отображения
|
||||
}));
|
||||
|
||||
// Объединяем обычные события и occurrences
|
||||
setEvents([...regularEvents, ...occurrenceEvents]);
|
||||
let nextEvents = [...regularEvents, ...occurrenceEvents];
|
||||
|
||||
if (requestedCalendarId !== null) {
|
||||
nextEvents = nextEvents.filter((event) => event.calendar === requestedCalendarId);
|
||||
}
|
||||
|
||||
// Если параллельно ушёл более новый запрос, его ответ приоритетнее.
|
||||
if (requestId !== eventsRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents(nextEvents);
|
||||
} catch (err) {
|
||||
if (requestId !== eventsRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
console.error("Ошибка загрузки событий:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requestId === eventsRequestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [selectedCalendarId, currentDate]);
|
||||
}, [calendarScope, canUseAllCalendarsMode, currentDate, selectedCalendarId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
@ -275,6 +364,10 @@ export function BigCalendar() {
|
||||
alert("Сначала выберите календарь");
|
||||
return;
|
||||
}
|
||||
if (selectedCalendar?.can_create_events === false) {
|
||||
alert("В выбранный календарь нельзя добавлять события");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDate) return;
|
||||
|
||||
@ -297,7 +390,7 @@ export function BigCalendar() {
|
||||
// Закрываем модал просмотра дня и открываем модал создания
|
||||
setShowDayEventsModal(false);
|
||||
setShowEventModal(true);
|
||||
}, [selectedCalendarId, selectedDate]);
|
||||
}, [selectedCalendar?.can_create_events, selectedCalendarId, selectedDate]);
|
||||
|
||||
const handleSelectEvent = useCallback(async (calEvent: CalendarEvent) => {
|
||||
// Если это occurrence (повторяющееся событие), загружаем базовое событие
|
||||
@ -328,6 +421,84 @@ export function BigCalendar() {
|
||||
setShowEventModal(true);
|
||||
}, [viewingEvent]);
|
||||
|
||||
const handleCreateCalendar = useCallback(() => {
|
||||
setEditingCalendar({ name: "" });
|
||||
setShowCalendarModal(true);
|
||||
setShowCalendarActions(false);
|
||||
}, []);
|
||||
|
||||
const handleEditCalendar = useCallback(() => {
|
||||
if (!selectedCalendar?.can_edit_calendar || isDepartmentCalendar) {
|
||||
return;
|
||||
}
|
||||
setEditingCalendar({ id: selectedCalendar.id, name: selectedCalendar.name });
|
||||
setShowCalendarModal(true);
|
||||
setShowCalendarActions(false);
|
||||
}, [isDepartmentCalendar, selectedCalendar]);
|
||||
|
||||
const handleDeleteCalendar = useCallback(async () => {
|
||||
if (!selectedCalendar?.id || !selectedCalendar.can_edit_calendar || isDepartmentCalendar) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Удалить календарь "${selectedCalendar.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.deleteCalendar(selectedCalendar.id);
|
||||
calendarService.clearCache();
|
||||
setSelectedCalendarId(null);
|
||||
setShowCalendarActions(false);
|
||||
await reloadCalendars();
|
||||
loadEvents();
|
||||
} catch (error) {
|
||||
console.error("Не удалось удалить календарь:", error);
|
||||
alert("Не удалось удалить календарь");
|
||||
}
|
||||
}, [isDepartmentCalendar, loadEvents, reloadCalendars, selectedCalendar, setSelectedCalendarId]);
|
||||
|
||||
const handleExportCalendar = useCallback(async () => {
|
||||
if (!selectedCalendar?.id) return;
|
||||
|
||||
try {
|
||||
const blob = await calendarService.exportToICS(selectedCalendar.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `calendar-${selectedCalendar.id}.ics`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error("Не удалось экспортировать календарь:", error);
|
||||
alert("Не удалось экспортировать календарь");
|
||||
} finally {
|
||||
setShowCalendarActions(false);
|
||||
}
|
||||
}, [selectedCalendar]);
|
||||
|
||||
const handleImportCalendar = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!selectedCalendar?.id || !event.target.files || event.target.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = event.target.files[0];
|
||||
try {
|
||||
const result = await calendarService.importFromICS(selectedCalendar.id, file);
|
||||
alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`);
|
||||
loadEvents();
|
||||
} catch (error: any) {
|
||||
console.error("Не удалось импортировать календарь:", error);
|
||||
alert(`Ошибка импорта: ${error.message}`);
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
setShowCalendarActions(false);
|
||||
}
|
||||
}, [loadEvents, selectedCalendar]);
|
||||
|
||||
// Клик на событие из модала просмотра дня
|
||||
const handleEventClickFromDay = useCallback(async (event: any) => {
|
||||
setShowDayEventsModal(false);
|
||||
@ -371,11 +542,146 @@ export function BigCalendar() {
|
||||
<>
|
||||
<div className="app-surface rounded-2xl">
|
||||
{/* Панель выбора календаря */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<label className="text-sm font-medium text-[var(--foreground)]">Календарь:</label>
|
||||
<div className="app-divider relative z-20 border-b px-6 py-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="app-card-caption">Календарь</p>
|
||||
<p className="app-text-muted text-sm">
|
||||
{selectedCalendar
|
||||
? `События календаря «${selectedCalendar.name}»`
|
||||
: "Просмотр всех доступных событий"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end">
|
||||
{loading && (
|
||||
<div className="app-text-muted flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calendars.length > 0 && (
|
||||
<div className="relative z-30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCalendarActions((prev) => !prev)}
|
||||
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
|
||||
title="Действия с календарем"
|
||||
aria-label="Действия с календарем"
|
||||
aria-expanded={showCalendarActions}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
className={`transition-transform duration-200 ${showCalendarActions ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showCalendarActions && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[50]"
|
||||
onClick={() => setShowCalendarActions(false)}
|
||||
/>
|
||||
<div className="app-menu pointer-events-auto absolute right-0 top-full z-[80] mt-1 w-52 rounded-xl">
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateCalendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Создать календарь
|
||||
</button>
|
||||
|
||||
{selectedCalendar ? (
|
||||
<>
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (canOpenParticipantsModal) {
|
||||
setShowParticipantsModal(true);
|
||||
}
|
||||
setShowCalendarActions(false);
|
||||
}}
|
||||
disabled={!canOpenParticipantsModal}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Users size={16} />
|
||||
Участники
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCalendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
Экспорт .ics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
Импорт .ics
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".ics"
|
||||
onChange={handleImportCalendar}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{!isDepartmentCalendar ? (
|
||||
<>
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEditCalendar}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteCalendar()}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calendars.length === 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="app-text-muted text-sm">Календарей нет.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -388,84 +694,87 @@ export function BigCalendar() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<select
|
||||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||||
}}
|
||||
className="app-select max-w-xs flex-1"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<label className="app-text-muted shrink-0 text-sm font-medium">Календарь</label>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<select
|
||||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||||
}}
|
||||
className="app-select w-full min-w-0 appearance-none rounded-xl py-2.5 pl-3 pr-10 text-sm"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={16} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCalendar({ name: "" });
|
||||
setShowCalendarModal(true);
|
||||
}}
|
||||
className="app-action-secondary flex h-9 w-9 items-center justify-center p-0"
|
||||
title="Создать календарь"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
{selectedCalendarId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const cal = calendars.find((c) => c.id === selectedCalendarId);
|
||||
if (cal) {
|
||||
setEditingCalendar({ id: cal.id, name: cal.name });
|
||||
setShowCalendarModal(true);
|
||||
}
|
||||
}}
|
||||
className="app-action-secondary flex h-9 w-9 items-center justify-center p-0"
|
||||
title="Настройки календаря"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canUseAllCalendarsMode && (
|
||||
<div className="grid min-w-[12rem] grid-cols-2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-secondary)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarScope("accessible")}
|
||||
className={`min-w-0 rounded-lg px-3 py-1.5 text-center text-xs font-medium transition ${
|
||||
calendarScope === "accessible"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Доступные
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarScope("all")}
|
||||
className={`min-w-0 rounded-lg px-3 py-1.5 text-center text-xs font-medium transition ${
|
||||
calendarScope === "all"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="app-text-muted flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Календарь */}
|
||||
<div className="overflow-hidden px-6 pb-6" style={{ height: "750px" }}>
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={events}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
allDayAccessor={(event: CalendarEvent) => event.allDay || false}
|
||||
messages={messages}
|
||||
culture="ru"
|
||||
view={currentView}
|
||||
onView={(view: View) => setCurrentView(view)}
|
||||
date={currentDate}
|
||||
onNavigate={(date: Date) => setCurrentDate(date)}
|
||||
onSelectSlot={handleSelectSlot}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
selectable
|
||||
eventPropGetter={eventStyleGetter}
|
||||
popup
|
||||
components={{
|
||||
toolbar: CustomToolbar,
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-0 overflow-hidden px-6 pb-6 pt-5" style={{ height: "750px" }}>
|
||||
<div className="calendar-shell h-full rounded-[1.4rem]">
|
||||
<Calendar
|
||||
localizer={localizer}
|
||||
events={events}
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
allDayAccessor={(event: CalendarEvent) => event.allDay || false}
|
||||
messages={messages}
|
||||
culture="ru"
|
||||
view={currentView}
|
||||
onView={(view: View) => setCurrentView(view)}
|
||||
date={currentDate}
|
||||
onNavigate={(date: Date) => setCurrentDate(date)}
|
||||
onSelectSlot={handleSelectSlot}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
selectable
|
||||
eventPropGetter={eventStyleGetter}
|
||||
popup
|
||||
components={{
|
||||
toolbar: CustomToolbar,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -533,6 +842,19 @@ export function BigCalendar() {
|
||||
}}
|
||||
calendar={editingCalendar}
|
||||
/>
|
||||
|
||||
{selectedCalendar && (
|
||||
<CalendarParticipantsModal
|
||||
isOpen={showParticipantsModal}
|
||||
onClose={() => setShowParticipantsModal(false)}
|
||||
calendarId={selectedCalendar.id}
|
||||
calendarName={selectedCalendar.name}
|
||||
userRole={(selectedCalendar as any).user_role}
|
||||
canManageParticipants={selectedCalendar.can_manage_participants}
|
||||
calendarType={selectedCalendar.type}
|
||||
contextType={selectedCalendar.context_type}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -44,6 +44,9 @@ interface CalendarParticipantsModalProps {
|
||||
calendarId: number;
|
||||
calendarName: string;
|
||||
userRole?: string; // owner, editor, viewer
|
||||
canManageParticipants?: boolean;
|
||||
calendarType?: string | null;
|
||||
contextType?: string | null;
|
||||
}
|
||||
|
||||
export default function CalendarParticipantsModal({
|
||||
@ -52,6 +55,9 @@ export default function CalendarParticipantsModal({
|
||||
calendarId,
|
||||
calendarName,
|
||||
userRole,
|
||||
canManageParticipants = false,
|
||||
calendarType,
|
||||
contextType,
|
||||
}: CalendarParticipantsModalProps) {
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
@ -62,7 +68,9 @@ export default function CalendarParticipantsModal({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const isOwner = userRole === 'owner';
|
||||
const isDepartmentCalendar =
|
||||
calendarType === 'department' || contextType === 'department';
|
||||
const canManage = canManageParticipants && !isDepartmentCalendar;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@ -204,8 +212,18 @@ export default function CalendarParticipantsModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Add Participant Section - только для владельца */}
|
||||
{isOwner && (
|
||||
{isDepartmentCalendar && (
|
||||
<div className="app-surface-muted rounded-lg p-4">
|
||||
<p className="app-text-muted text-sm">
|
||||
Состав участников календаря отдела синхронизируется автоматически
|
||||
из состава отдела и назначенных ролей. Управление через эту
|
||||
модалку отключено для всех пользователей, включая администраторов.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Participant Section */}
|
||||
{canManage && (
|
||||
<div className="app-surface-muted space-y-3 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-[var(--foreground)]">
|
||||
@ -351,7 +369,8 @@ export default function CalendarParticipantsModal({
|
||||
const user = participant.user || {};
|
||||
const firstName = user.first_name || 'N';
|
||||
const lastName = user.last_name || 'A';
|
||||
const username = user.username || 'unknown';
|
||||
const username = typeof user.username === 'string' ? user.username.trim() : '';
|
||||
const secondaryLabel = user.email || (username ? `@${username}` : '');
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -376,7 +395,9 @@ export default function CalendarParticipantsModal({
|
||||
<div className="text-sm font-medium text-[var(--foreground)]">
|
||||
{firstName} {lastName}
|
||||
</div>
|
||||
<div className="app-text-muted text-xs">{user.email || `@${username}`}</div>
|
||||
{secondaryLabel ? (
|
||||
<div className="app-text-muted text-xs">{secondaryLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -392,7 +413,7 @@ export default function CalendarParticipantsModal({
|
||||
participant.distinction === 'editor' ? 'Редактор' : 'Просмотр'}
|
||||
</span>
|
||||
|
||||
{isOwner && participant.distinction !== 'owner' && (
|
||||
{canManage && participant.distinction !== 'owner' && (
|
||||
<button
|
||||
onClick={() => handleRemoveParticipant(participant.user.id)}
|
||||
disabled={loading}
|
||||
|
||||
@ -98,6 +98,8 @@ export function ViewEventDetailsModal({
|
||||
|
||||
const capitalizedStart = formatDateTime(event.start).charAt(0).toUpperCase() + formatDateTime(event.start).slice(1);
|
||||
const capitalizedEnd = formatDateTime(event.end).charAt(0).toUpperCase() + formatDateTime(event.end).slice(1);
|
||||
const canEdit = event.can_edit !== false;
|
||||
const canDelete = event.can_delete !== false;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -105,24 +107,28 @@ export function ViewEventDetailsModal({
|
||||
onClose={onClose}
|
||||
title={event.title}
|
||||
size="sm"
|
||||
footer={
|
||||
footer={canEdit || canDelete ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
) : undefined}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Event meta */}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Users } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Pencil, Trash2, Users } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
|
||||
import { calendarService, formatDateKey, type CalendarEvent } from "@/services/calendarService";
|
||||
@ -11,7 +13,14 @@ import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-col
|
||||
interface CalendarCardProps {
|
||||
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
|
||||
onOpenEventModal: (event: any, date?: Date) => void;
|
||||
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
|
||||
onOpenParticipantsModal: (calendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
}) => void;
|
||||
eventsRefreshTrigger: number;
|
||||
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
|
||||
setSidebarEvents: (events: CalendarEvent[]) => void;
|
||||
@ -37,7 +46,19 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
setSidebarEvents,
|
||||
onCalendarChange,
|
||||
}: CalendarCardProps) {
|
||||
const { calendars, selectedCalendarId, setSelectedCalendarId } = useCalendar();
|
||||
const {
|
||||
calendars,
|
||||
selectedCalendarId,
|
||||
setSelectedCalendarId,
|
||||
calendarScope,
|
||||
setCalendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
reloadCalendars,
|
||||
} = useCalendar();
|
||||
const searchParams = useSearchParams();
|
||||
const linkedCalendarName = searchParams.get("calendarName") || "";
|
||||
const linkedCalendarType = searchParams.get("calendarType") || "";
|
||||
const linkedContextType = searchParams.get("contextType") || "";
|
||||
|
||||
const [monthDate, setMonthDate] = useState(() => {
|
||||
const now = new Date();
|
||||
@ -49,10 +70,52 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
const [eventsViewMode, setEventsViewMode] = useState<"week" | "month">("week");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedCalendar = useMemo(() => {
|
||||
const calendar = calendars.find((item) => item.id === selectedCalendarId);
|
||||
if (calendar) return calendar;
|
||||
if (selectedCalendarId && linkedCalendarName) {
|
||||
return {
|
||||
id: selectedCalendarId,
|
||||
name: linkedCalendarName,
|
||||
slug: `linked-calendar-${selectedCalendarId}`,
|
||||
type: linkedCalendarType || null,
|
||||
context_type: linkedContextType || null,
|
||||
can_create_events: true,
|
||||
can_edit_calendar: false,
|
||||
can_manage_participants: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [calendars, linkedCalendarName, linkedCalendarType, linkedContextType, selectedCalendarId]);
|
||||
|
||||
const calendarOptions = useMemo(() => {
|
||||
if (!selectedCalendar) return calendars;
|
||||
if (calendars.some((item) => item.id === selectedCalendar.id)) {
|
||||
return calendars;
|
||||
}
|
||||
return [...calendars, selectedCalendar];
|
||||
}, [calendars, selectedCalendar]);
|
||||
|
||||
const canOpenParticipantsModal = Boolean(
|
||||
selectedCalendar && (
|
||||
selectedCalendar.can_manage_participants ||
|
||||
selectedCalendar.type === "department" ||
|
||||
selectedCalendar.context_type === "department"
|
||||
)
|
||||
);
|
||||
const isDepartmentCalendar = Boolean(
|
||||
selectedCalendar && (
|
||||
selectedCalendar.type === "department" ||
|
||||
selectedCalendar.context_type === "department"
|
||||
)
|
||||
);
|
||||
|
||||
// Загружаем события через хук
|
||||
const { events, loading, error } = useCalendarEvents({
|
||||
monthDate,
|
||||
selectedCalendarId,
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
eventsRefreshTrigger,
|
||||
onEventsLoaded: setSidebarEvents,
|
||||
});
|
||||
@ -81,6 +144,9 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
}, [onOpenCalendarModal]);
|
||||
|
||||
const handleDayClick = useCallback((date: Date) => {
|
||||
if (selectedCalendar?.can_create_events === false) {
|
||||
return;
|
||||
}
|
||||
setSelectedDate(date);
|
||||
const startDate = new Date(date);
|
||||
startDate.setHours(10, 0, 0, 0);
|
||||
@ -96,7 +162,7 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
color_event: DEFAULT_EVENT_COLOR,
|
||||
};
|
||||
onOpenEventModal(newEvent, date);
|
||||
}, [selectedCalendarId, onOpenEventModal]);
|
||||
}, [onOpenEventModal, selectedCalendar?.can_create_events, selectedCalendarId]);
|
||||
|
||||
const handleEventClick = useCallback(async (event: CalendarEvent) => {
|
||||
// Если это occurrence (повторяющееся событие), загружаем базовое событие
|
||||
@ -153,6 +219,33 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
setShowCalendarMenu(false);
|
||||
}, [selectedCalendarId]);
|
||||
|
||||
const handleDeleteCalendar = useCallback(async () => {
|
||||
if (!selectedCalendar?.id || !selectedCalendar.can_edit_calendar) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Удалить календарь "${selectedCalendar.name}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.deleteCalendar(selectedCalendar.id);
|
||||
calendarService.clearCache();
|
||||
setSelectedCalendarId(null);
|
||||
setEventsRefreshTrigger((prev) => prev + 1);
|
||||
await reloadCalendars();
|
||||
} catch (error) {
|
||||
console.error("Не удалось удалить календарь:", error);
|
||||
alert("Не удалось удалить календарь");
|
||||
} finally {
|
||||
setShowCalendarMenu(false);
|
||||
}
|
||||
}, [
|
||||
reloadCalendars,
|
||||
selectedCalendar,
|
||||
setEventsRefreshTrigger,
|
||||
setSelectedCalendarId,
|
||||
]);
|
||||
|
||||
// Дни с событиями
|
||||
const eventDays = useMemo(() => {
|
||||
const days = new Set<string>();
|
||||
@ -245,71 +338,56 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
<div className="app-surface rounded-2xl p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="app-text-muted text-[11px] font-semibold uppercase tracking-wide">Календарь</p>
|
||||
<button
|
||||
onClick={handleCreateCalendar}
|
||||
className="app-icon-button flex h-6 w-6 items-center justify-center rounded-full transition"
|
||||
title="Создать календарь"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{calendars.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--border-strong)] bg-[var(--surface-secondary)] p-3">
|
||||
<p className="mb-2 text-xs font-medium text-[var(--foreground)]">Календарей пока нет</p>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleCreateCalendar}
|
||||
className="app-action-primary w-full rounded-md px-3 py-1.5 text-xs font-medium"
|
||||
onClick={() => setShowCalendarMenu(!showCalendarMenu)}
|
||||
className="app-icon-button flex h-6 w-6 items-center justify-center rounded-full transition"
|
||||
title="Меню календаря"
|
||||
aria-expanded={showCalendarMenu}
|
||||
>
|
||||
Создать первый календарь
|
||||
{showCalendarMenu ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||||
}}
|
||||
className="app-select flex-1 rounded-lg px-2.5 py-2 text-xs"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedCalendarId && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCalendarMenu(!showCalendarMenu)}
|
||||
className="app-icon-button flex h-9 w-9 items-center justify-center rounded-lg transition"
|
||||
title="Меню календаря"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showCalendarMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[50]"
|
||||
onClick={() => setShowCalendarMenu(false)}
|
||||
/>
|
||||
<div className="app-menu absolute right-0 top-full z-[60] mt-1 w-48 rounded-lg">
|
||||
<div className="py-1">
|
||||
{showCalendarMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[50]"
|
||||
onClick={() => setShowCalendarMenu(false)}
|
||||
/>
|
||||
<div className="app-menu absolute right-0 top-full z-[60] mt-1 w-48 rounded-lg">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCreateCalendar();
|
||||
setShowCalendarMenu(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M12 5v14"></path>
|
||||
</svg>
|
||||
Создать календарь
|
||||
</button>
|
||||
|
||||
{selectedCalendar ? (
|
||||
<>
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const cal = calendars.find(c => c.id === selectedCalendarId);
|
||||
if (cal) {
|
||||
onOpenParticipantsModal({ id: cal.id, name: cal.name, user_role: (cal as any).user_role });
|
||||
if (canOpenParticipantsModal) {
|
||||
onOpenParticipantsModal({
|
||||
id: selectedCalendar.id,
|
||||
name: selectedCalendar.name,
|
||||
user_role: (selectedCalendar as any).user_role,
|
||||
can_manage_participants: selectedCalendar.can_manage_participants,
|
||||
type: selectedCalendar.type,
|
||||
context_type: selectedCalendar.context_type,
|
||||
});
|
||||
}
|
||||
setShowCalendarMenu(false);
|
||||
}}
|
||||
disabled={!canOpenParticipantsModal}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Users size={16} />
|
||||
@ -330,6 +408,7 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
disabled={!selectedCalendar?.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@ -350,29 +429,102 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const cal = calendars.find(c => c.id === selectedCalendarId);
|
||||
if (cal) {
|
||||
onOpenCalendarModal(cal);
|
||||
}
|
||||
setShowCalendarMenu(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
Настройки
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isDepartmentCalendar ? (
|
||||
<>
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedCalendar.can_edit_calendar) {
|
||||
onOpenCalendarModal(selectedCalendar);
|
||||
}
|
||||
setShowCalendarMenu(false);
|
||||
}}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleDeleteCalendar();
|
||||
}}
|
||||
disabled={!selectedCalendar.can_edit_calendar}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{calendars.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[var(--border-strong)] bg-[var(--surface-secondary)] p-3">
|
||||
<p className="mb-2 text-xs font-medium text-[var(--foreground)]">Календарей пока нет</p>
|
||||
<button
|
||||
onClick={handleCreateCalendar}
|
||||
className="app-action-primary w-full rounded-md px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
Создать первый календарь
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{canUseAllCalendarsMode && (
|
||||
<div className="grid w-full grid-cols-2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-secondary)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarScope("accessible")}
|
||||
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
|
||||
calendarScope === "accessible"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Доступные
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCalendarScope("all")}
|
||||
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
|
||||
calendarScope === "all"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||||
}}
|
||||
className="app-select w-full appearance-none rounded-xl py-2.5 pl-3 pr-10 text-sm"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -434,14 +586,14 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
|
||||
{/* События — неделя / месяц */}
|
||||
<div className="app-surface rounded-2xl p-5">
|
||||
<div className="mb-3 flex items-center gap-1 rounded-lg bg-[var(--surface-secondary)] p-0.5">
|
||||
<div className="mb-3 grid w-full grid-cols-2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-secondary)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEventsViewMode("week")}
|
||||
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition ${
|
||||
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
|
||||
eventsViewMode === "week"
|
||||
? "bg-[var(--surface-elevated)] text-[var(--foreground)] shadow-[var(--shadow-card)]"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Неделя
|
||||
@ -449,10 +601,10 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEventsViewMode("month")}
|
||||
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition ${
|
||||
className={`min-w-0 rounded-lg px-2.5 py-1 text-center text-[11px] font-medium transition ${
|
||||
eventsViewMode === "month"
|
||||
? "bg-[var(--surface-elevated)] text-[var(--foreground)] shadow-[var(--shadow-card)]"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||||
: "app-text-muted hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Месяц
|
||||
|
||||
@ -10,7 +10,14 @@ interface CalendarSidebarProps {
|
||||
event: Partial<CalendarEvent> & { id?: number; calendar?: number | null },
|
||||
date?: Date,
|
||||
) => void;
|
||||
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
|
||||
onOpenParticipantsModal: (calendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
}) => void;
|
||||
eventsRefreshTrigger: number;
|
||||
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
|
||||
setSidebarEvents: (events: CalendarEvent[]) => void;
|
||||
|
||||
@ -25,6 +25,9 @@ export function CalendarModals(props: CalendarModalsProps) {
|
||||
calendarId={props.participantsCalendar.id}
|
||||
calendarName={props.participantsCalendar.name}
|
||||
userRole={props.participantsCalendar.user_role}
|
||||
canManageParticipants={props.participantsCalendar.can_manage_participants}
|
||||
calendarType={props.participantsCalendar.type}
|
||||
contextType={props.participantsCalendar.context_type}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -104,7 +104,14 @@ interface CalendarDrawerProps {
|
||||
onClose: () => void;
|
||||
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
|
||||
onOpenEventModal: (event: Partial<CalendarEvent> & { id?: number }, date?: Date) => void;
|
||||
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
|
||||
onOpenParticipantsModal: (calendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
}) => void;
|
||||
eventsRefreshTrigger: number;
|
||||
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
|
||||
setSidebarEvents: (events: CalendarEvent[]) => void;
|
||||
|
||||
@ -3,18 +3,17 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import type { Calendar } from "@/types/api";
|
||||
|
||||
type Calendar = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
events_count?: number;
|
||||
};
|
||||
export type CalendarScope = "accessible" | "all";
|
||||
|
||||
type CalendarContextType = {
|
||||
calendars: Calendar[];
|
||||
selectedCalendarId: number | null;
|
||||
setSelectedCalendarId: (id: number | null) => void;
|
||||
calendarScope: CalendarScope;
|
||||
setCalendarScope: (scope: CalendarScope) => void;
|
||||
canUseAllCalendarsMode: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reloadCalendars: () => Promise<void>;
|
||||
@ -26,23 +25,29 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useUser();
|
||||
const [calendars, setCalendars] = useState<Calendar[]>([]);
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
||||
const [calendarScope, setCalendarScope] = useState<CalendarScope>("accessible");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const initialLoadDone = useRef(false);
|
||||
const canUseAllCalendarsMode = Boolean(
|
||||
user?.auth?.is_staff || user?.auth?.is_superuser
|
||||
);
|
||||
|
||||
const loadCalendars = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await apiClient.getCalendars();
|
||||
|
||||
const result = await apiClient.getCalendars({
|
||||
scope:
|
||||
canUseAllCalendarsMode && calendarScope === "all"
|
||||
? "all"
|
||||
: undefined,
|
||||
});
|
||||
// API возвращает пагинированный ответ: {count, results}
|
||||
const cals = Array.isArray(result) ? result : (result?.results || []);
|
||||
setCalendars(cals);
|
||||
|
||||
// По умолчанию показываем "Все события" (selectedCalendarId === null)
|
||||
// Не выбираем автоматически первый календарь
|
||||
|
||||
|
||||
initialLoadDone.current = true;
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки календарей:", err);
|
||||
@ -50,13 +55,18 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadCalendars();
|
||||
} else {
|
||||
setCalendars([]);
|
||||
setSelectedCalendarId(null);
|
||||
setCalendarScope("accessible");
|
||||
initialLoadDone.current = false;
|
||||
}
|
||||
}, [user, loadCalendars]);
|
||||
@ -67,6 +77,9 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
|
||||
calendars,
|
||||
selectedCalendarId,
|
||||
setSelectedCalendarId,
|
||||
calendarScope,
|
||||
setCalendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
loading,
|
||||
error,
|
||||
reloadCalendars: loadCalendars,
|
||||
|
||||
@ -9,6 +9,8 @@ import { calendarService, type CalendarEvent, startOfWeekMonday } from "@/servic
|
||||
interface UseCalendarEventsOptions {
|
||||
monthDate: Date;
|
||||
selectedCalendarId: number | null;
|
||||
calendarScope: "accessible" | "all";
|
||||
canUseAllCalendarsMode: boolean;
|
||||
eventsRefreshTrigger: number;
|
||||
onEventsLoaded?: (events: CalendarEvent[]) => void;
|
||||
}
|
||||
@ -22,13 +24,15 @@ interface UseCalendarEventsResult {
|
||||
export function useCalendarEvents({
|
||||
monthDate,
|
||||
selectedCalendarId,
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
eventsRefreshTrigger,
|
||||
onEventsLoaded,
|
||||
}: UseCalendarEventsOptions): UseCalendarEventsResult {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -45,9 +49,7 @@ export function useCalendarEvents({
|
||||
const fetchEnd = weekEndExclusive > end ? weekEndExclusive : end;
|
||||
|
||||
async function loadEvents() {
|
||||
// Защита от множественных одновременных запросов
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
const requestId = ++requestIdRef.current;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
@ -57,6 +59,8 @@ export function useCalendarEvents({
|
||||
fetchStart,
|
||||
fetchEnd,
|
||||
selectedCalendarId,
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
eventsRefreshTrigger
|
||||
);
|
||||
|
||||
@ -65,6 +69,10 @@ export function useCalendarEvents({
|
||||
onEventsLoaded?.(loadedEvents);
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
const message = err instanceof Error ? err.message : String(err || "");
|
||||
const isNetworkLike = message === "NetworkError" || /fetch failed/i.test(message);
|
||||
@ -77,9 +85,8 @@ export function useCalendarEvents({
|
||||
setEvents([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
if (!cancelled && requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -89,7 +96,14 @@ export function useCalendarEvents({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [monthDate, selectedCalendarId, eventsRefreshTrigger, onEventsLoaded]);
|
||||
}, [
|
||||
monthDate,
|
||||
selectedCalendarId,
|
||||
calendarScope,
|
||||
canUseAllCalendarsMode,
|
||||
eventsRefreshTrigger,
|
||||
onEventsLoaded,
|
||||
]);
|
||||
|
||||
return { events, loading, error };
|
||||
}
|
||||
|
||||
@ -13,7 +13,14 @@ export interface CalendarModalsState {
|
||||
showEventDetailsModal: boolean;
|
||||
editingCalendar: { id?: number; name: string } | null;
|
||||
editingEvent: CalendarEvent | null;
|
||||
participantsCalendar: { id: number; name: string; user_role?: string } | null;
|
||||
participantsCalendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
} | null;
|
||||
selectedDateForModal: Date | null;
|
||||
viewingEvent: CalendarEvent | null;
|
||||
sidebarEvents: CalendarEvent[];
|
||||
@ -30,7 +37,14 @@ export function useCalendarModals() {
|
||||
|
||||
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
|
||||
const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null);
|
||||
const [participantsCalendar, setParticipantsCalendar] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
} | null>(null);
|
||||
const [selectedDateForModal, setSelectedDateForModal] = useState<Date | null>(null);
|
||||
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
|
||||
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
|
||||
@ -123,7 +137,14 @@ export function useCalendarModals() {
|
||||
setEventsRefreshTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handleOpenParticipantsModal = useCallback((calendar: { id: number; name: string; user_role?: string }) => {
|
||||
const handleOpenParticipantsModal = useCallback((calendar: {
|
||||
id: number;
|
||||
name: string;
|
||||
user_role?: string;
|
||||
can_manage_participants?: boolean;
|
||||
type?: string | null;
|
||||
context_type?: string | null;
|
||||
}) => {
|
||||
setParticipantsCalendar(calendar);
|
||||
setShowParticipantsModal(true);
|
||||
}, []);
|
||||
|
||||
@ -3,11 +3,12 @@ import type { RequestFn, GetTokenFn } from './utils';
|
||||
|
||||
export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
return {
|
||||
getCalendarEvents: (params?: { start?: string; end?: string; calendar?: number }) => {
|
||||
getCalendarEvents: (params?: { start?: string; end?: string; calendar?: number; scope?: "all" }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.start) qp.append('start', params.start);
|
||||
if (params?.end) qp.append('end', params.end);
|
||||
if (params?.calendar) qp.append('calendar', params.calendar.toString());
|
||||
if (params?.scope) qp.append('scope', params.scope);
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/schedule/events/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
@ -18,7 +19,12 @@ export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/schedule/events/my-events/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getCalendars: () => request('/api/v1/schedule/calendars/'),
|
||||
getCalendars: (params?: { scope?: "all" }) => {
|
||||
const qp = new URLSearchParams();
|
||||
if (params?.scope) qp.append('scope', params.scope);
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/schedule/calendars/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
getCalendar: (id: number) => request(`/api/v1/schedule/calendars/${id}/`),
|
||||
createCalendar: (data: { name: string; slug?: string }) => request('/api/v1/schedule/calendars/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateCalendar: (id: number, data: { name?: string; slug?: string }) => request(`/api/v1/schedule/calendars/${id}/`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
@ -63,11 +69,12 @@ export function createCalendarApi(request: RequestFn, getToken: GetTokenFn) {
|
||||
request(`/api/v1/schedule/rules/${ruleId}/`, { method: 'PUT', body: JSON.stringify({ ...data, params: data.params ? JSON.stringify(data.params) : '{}' }) }),
|
||||
getRules: () => request('/api/v1/schedule/rules/'),
|
||||
getRule: (ruleId: number) => request(`/api/v1/schedule/rules/${ruleId}/`),
|
||||
getOccurrences: (params: { start: string; end: string; calendar?: number }) => {
|
||||
getOccurrences: (params: { start: string; end: string; calendar?: number; scope?: "all" }) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('start', params.start);
|
||||
qp.append('end', params.end);
|
||||
if (params.calendar) qp.append('calendar', params.calendar.toString());
|
||||
if (params.scope) qp.append('scope', params.scope);
|
||||
return request(`/api/v1/schedule/events/occurrences/?${qp.toString()}`);
|
||||
},
|
||||
};
|
||||
|
||||
@ -71,6 +71,8 @@ export function createEmployeesApi(request: RequestFn) {
|
||||
request(`/api/v1/departments/${id}/members/`),
|
||||
getDepartmentDiscussionChat: (id: number | string) =>
|
||||
request(`/api/v1/departments/${id}/discussion-chat/`),
|
||||
getDepartmentCalendar: (id: number | string) =>
|
||||
request(`/api/v1/departments/${id}/calendar/`),
|
||||
getDepartmentUserPerms: (id: number | string) =>
|
||||
request(`/api/v1/departments/${id}/user-perms/`),
|
||||
setDepartmentHead: (id: number | string, head_id: number | null) =>
|
||||
|
||||
@ -19,6 +19,8 @@ export interface CalendarEvent {
|
||||
event_id?: number;
|
||||
description?: string;
|
||||
calendar?: number;
|
||||
can_edit?: boolean;
|
||||
can_delete?: boolean;
|
||||
}
|
||||
|
||||
interface EventsCache {
|
||||
@ -60,9 +62,13 @@ class CalendarService {
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
calendarId: number | null,
|
||||
calendarScope: "accessible" | "all",
|
||||
canUseAllCalendarsMode: boolean,
|
||||
refreshTrigger: number
|
||||
): Promise<CalendarEvent[]> {
|
||||
const cacheKey = `${formatDateKey(startDate)}_${formatDateKey(endDate)}_${calendarId}_${refreshTrigger}`;
|
||||
const apiScope =
|
||||
canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined;
|
||||
const cacheKey = `${formatDateKey(startDate)}_${formatDateKey(endDate)}_${calendarId}_${calendarScope}_${refreshTrigger}`;
|
||||
|
||||
// Проверяем кэш
|
||||
if (eventsCache && eventsCache.key === cacheKey && Date.now() - eventsCache.timestamp < CACHE_TTL) {
|
||||
@ -75,11 +81,13 @@ class CalendarService {
|
||||
start: formatDateKey(startDate),
|
||||
end: formatDateKey(endDate),
|
||||
calendar: calendarId || undefined,
|
||||
scope: apiScope,
|
||||
}),
|
||||
apiClient.getOccurrences({
|
||||
start: formatDateKey(startDate),
|
||||
end: formatDateKey(endDate),
|
||||
calendar: calendarId || undefined,
|
||||
scope: apiScope,
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -91,7 +99,11 @@ class CalendarService {
|
||||
const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring);
|
||||
|
||||
// Объединяем оба типа событий
|
||||
const allEvents = [...regularEvents, ...recurringOccurrences];
|
||||
let allEvents = [...regularEvents, ...recurringOccurrences];
|
||||
|
||||
if (calendarId !== null) {
|
||||
allEvents = allEvents.filter((event) => event.calendar === calendarId);
|
||||
}
|
||||
|
||||
// Сохраняем в кэш
|
||||
eventsCache = {
|
||||
|
||||
@ -716,6 +716,8 @@ export interface CalendarEvent {
|
||||
creator?: number | null;
|
||||
created_on?: string;
|
||||
updated_on?: string;
|
||||
can_edit?: boolean;
|
||||
can_delete?: boolean;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
@ -723,6 +725,14 @@ export interface Calendar {
|
||||
name: string; // django-scheduler использует name (не title)
|
||||
slug: string;
|
||||
events_count?: number;
|
||||
type?: string;
|
||||
context_object_id?: number | null;
|
||||
context_type?: string | null;
|
||||
flags?: Record<string, any>;
|
||||
user_role?: string | null;
|
||||
can_create_events?: boolean;
|
||||
can_edit_calendar?: boolean;
|
||||
can_manage_participants?: boolean;
|
||||
}
|
||||
|
||||
// Notifications types (v2 API)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user