Merge branch 'develop'

This commit is contained in:
lizerk 2026-04-16 17:27:39 +03:00
commit 8beb8b3471
37 changed files with 2722 additions and 459 deletions

View File

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

View File

@ -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,
}
"""

View File

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

View File

@ -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",
]

View File

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

View File

@ -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": (

View File

@ -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, "Добавление коментариев по заявлениям"),

View File

@ -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,
),
]

View File

@ -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):
"""

View File

@ -25,6 +25,7 @@ class SchedulingConfig(AppConfig):
# Регистрируем сигналы для уведомлений
import scheduling.notifications.signals # noqa: F401
import scheduling.signals # noqa: F401
# Загружаем правила доступа (django-rules)
try:

View 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,
),
]

View 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}"

View 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)

View 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)

View 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

View File

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

View File

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

View File

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

View File

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

View 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()

View File

@ -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):
"""Создание правила повторения."""

View File

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

View File

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

View File

@ -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}
/>
)}
</>
);
}

View File

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

View File

@ -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 */}

View File

@ -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)]"
}`}
>
Месяц

View File

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

View File

@ -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}
/>
)}

View File

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

View File

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

View File

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

View File

@ -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);
}, []);

View File

@ -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()}`);
},
};

View File

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

View File

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

View File

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