feat: add department calendar bindings and cleanup dept permissions
This commit is contained in:
parent
f40196f39c
commit
5700b53f13
@ -36,6 +36,7 @@ from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from scheduling.services import get_or_create_department_calendar
|
||||
|
||||
from ...permissions import (
|
||||
AdminOrActionOrModelPerms,
|
||||
@ -110,6 +111,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
return [AdminOrActionOrModelPerms()]
|
||||
if self.action in {
|
||||
"members",
|
||||
"calendar",
|
||||
"discussion_chat",
|
||||
"user_perms",
|
||||
"list",
|
||||
@ -500,6 +502,28 @@ class DepartmentViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="calendar")
|
||||
def calendar(self, request, pk=None):
|
||||
"""Возвращает calendar отдела для входа из detail page."""
|
||||
dept = self.get_object()
|
||||
calendar, binding = get_or_create_department_calendar(dept)
|
||||
return Response(
|
||||
{
|
||||
"calendar_id": calendar.id,
|
||||
"calendar_type": binding.type,
|
||||
"name": calendar.name,
|
||||
"slug": calendar.slug,
|
||||
"context_type": (
|
||||
binding.context_content_type.model
|
||||
if binding.context_content_type_id
|
||||
else None
|
||||
),
|
||||
"context_object_id": binding.context_object_id,
|
||||
"flags": binding.flags,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="user-perms")
|
||||
def user_perms(self, request, pk=None):
|
||||
"""GET /api/v1/departments/{id}/user-perms/.
|
||||
|
||||
@ -176,8 +176,8 @@ class AdminOrDeptAllowed(BasePermission):
|
||||
|
||||
Поведение:
|
||||
1. Если пользователь superuser/staff — доступ всегда разрешён.
|
||||
2. Если для текущего экшена требуется код права
|
||||
(например, DeptPerm.MANAGE_CALENDAR), то доступ
|
||||
2. Если для текущего экшена требуется код права
|
||||
(например, DeptPerm.MANAGE_FEED), то доступ
|
||||
разрешается только при наличии у пользователя этого права
|
||||
на указанный отдел.
|
||||
3. Идентификатор отдела извлекается из:
|
||||
@ -203,15 +203,15 @@ class AdminOrDeptAllowed(BasePermission):
|
||||
когда код не задан. По умолчанию True.
|
||||
|
||||
Примеры:
|
||||
class ManageCalendarPerm(AdminOrDeptAllowed):
|
||||
required_code = DeptPerm.MANAGE_CALENDAR
|
||||
class ManageFeedPerm(AdminOrDeptAllowed):
|
||||
required_code = DeptPerm.MANAGE_FEED
|
||||
|
||||
class DeptEventsPerm(AdminOrDeptAllowed):
|
||||
class DeptFeedPerm(AdminOrDeptAllowed):
|
||||
required_code_map = {
|
||||
"create": DeptPerm.MANAGE_CALENDAR,
|
||||
"update": DeptPerm.MANAGE_CALENDAR,
|
||||
"partial_update": DeptPerm.MANAGE_CALENDAR,
|
||||
"destroy": DeptPerm.MANAGE_CALENDAR,
|
||||
"create": DeptPerm.MANAGE_FEED,
|
||||
"update": DeptPerm.MANAGE_FEED,
|
||||
"partial_update": DeptPerm.MANAGE_FEED,
|
||||
"destroy": DeptPerm.MANAGE_FEED,
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@ -4,8 +4,13 @@
|
||||
"""
|
||||
|
||||
from rest_framework import permissions
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from schedule.models import CalendarRelation
|
||||
|
||||
from scheduling.services import (
|
||||
user_can_create_event,
|
||||
user_can_edit_calendar,
|
||||
user_can_edit_event,
|
||||
user_can_view_calendar,
|
||||
)
|
||||
|
||||
|
||||
class IsOwnerOfCalendar(permissions.BasePermission):
|
||||
@ -18,28 +23,10 @@ class IsOwnerOfCalendar(permissions.BasePermission):
|
||||
"""Проверка прав на календарь."""
|
||||
# SAFE_METHODS (GET, HEAD, OPTIONS) доступны всем участникам
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return user_can_view_calendar(request.user, obj)
|
||||
|
||||
# Только owner может изменять/удалять календарь
|
||||
return self._is_owner(obj, request.user)
|
||||
|
||||
@staticmethod
|
||||
def _is_owner(calendar, user):
|
||||
"""Проверка, является ли пользователь владельцем календаря."""
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
return CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=ct,
|
||||
object_id=user.id,
|
||||
distinction="owner",
|
||||
).exists()
|
||||
return user_can_edit_calendar(request.user, obj)
|
||||
|
||||
|
||||
class CanEditCalendar(permissions.BasePermission):
|
||||
@ -66,7 +53,7 @@ class CanEditCalendar(permissions.BasePermission):
|
||||
except Calendar.DoesNotExist:
|
||||
return False
|
||||
|
||||
return self._can_edit(calendar, request.user)
|
||||
return user_can_create_event(request.user, calendar)
|
||||
|
||||
return True
|
||||
|
||||
@ -78,28 +65,7 @@ class CanEditCalendar(permissions.BasePermission):
|
||||
|
||||
# SAFE_METHODS (GET, HEAD, OPTIONS) доступны всем участникам
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return user_can_view_calendar(request.user, calendar)
|
||||
|
||||
# Для изменения/удаления нужны права editor или owner
|
||||
return self._can_edit(calendar, request.user)
|
||||
|
||||
@staticmethod
|
||||
def _can_edit(calendar, user):
|
||||
"""Проверка, может ли пользователь редактировать события в календаре."""
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
# Проверяем роль пользователя в календаре
|
||||
try:
|
||||
relation = CalendarRelation.objects.get(
|
||||
calendar=calendar, content_type=ct, object_id=user.id
|
||||
)
|
||||
# Owner и editor могут редактировать, viewer - нет
|
||||
return relation.distinction in ["owner", "editor"]
|
||||
except CalendarRelation.DoesNotExist:
|
||||
return False
|
||||
return user_can_edit_event(request.user, event)
|
||||
|
||||
@ -16,6 +16,15 @@ 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()
|
||||
@ -101,6 +110,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 +126,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 +148,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 +244,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 +460,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 +481,8 @@ class EventSerializer(serializers.ModelSerializer):
|
||||
"creator",
|
||||
"created_on",
|
||||
"updated_on",
|
||||
"can_edit",
|
||||
"can_delete",
|
||||
]
|
||||
read_only_fields = ["id", "created_on", "updated_on"]
|
||||
|
||||
@ -446,6 +513,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 +541,8 @@ class EventListSerializer(EventSerializer):
|
||||
"color_event",
|
||||
"rule",
|
||||
"end_recurring_period",
|
||||
"can_edit",
|
||||
"can_delete",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -3,11 +3,12 @@
|
||||
Тесты для django-scheduler API endpoints.
|
||||
"""
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, timedelta
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
from schedule.models import Calendar, Event, Rule
|
||||
from schedule.models import Calendar, CalendarRelation, Event, Rule
|
||||
from employees.models import Employee
|
||||
|
||||
|
||||
@ -43,10 +44,18 @@ def authenticated_client(api_client, test_user):
|
||||
@pytest.fixture
|
||||
def test_calendar(test_user):
|
||||
"""Создание тестового календаря."""
|
||||
return Calendar.objects.create(
|
||||
calendar = Calendar.objects.create(
|
||||
name="Тестовый календарь",
|
||||
slug="test-calendar"
|
||||
)
|
||||
CalendarRelation.objects.create(
|
||||
calendar=calendar,
|
||||
content_type=ContentType.objects.get_for_model(Employee),
|
||||
object_id=test_user.id,
|
||||
distinction="owner",
|
||||
inheritable=True,
|
||||
)
|
||||
return calendar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -35,6 +35,15 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
@ -56,7 +65,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]
|
||||
@ -70,27 +82,35 @@ class ScheduleCalendarViewSet(viewsets.ModelViewSet):
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
qs = super().get_queryset()
|
||||
qs = super().get_queryset().select_related(
|
||||
"binding",
|
||||
"binding__context_content_type",
|
||||
)
|
||||
user = self.request.user
|
||||
|
||||
# Админы видят все
|
||||
if user.is_staff or user.is_superuser:
|
||||
return qs.order_by("name")
|
||||
filtered = qs
|
||||
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)
|
||||
|
||||
# Остальные видят только календари, где они участники
|
||||
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)
|
||||
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 +119,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 +136,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 +179,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 +272,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 +362,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 +375,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,8 +402,15 @@ 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")
|
||||
|
||||
# Админы видят все события
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
@ -383,13 +428,30 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# Объединяем: события из доступных календарей ИЛИ где пользователь
|
||||
# участник
|
||||
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,13 +507,20 @@ 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:
|
||||
# Если календарь не указан - берем все доступные пользователю
|
||||
# календари
|
||||
@ -495,6 +564,12 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
|
||||
"color_event": occurrence.event.color_event,
|
||||
# ID оригинального события
|
||||
"event_id": occurrence.event.id,
|
||||
"can_edit": user_can_edit_event(
|
||||
request.user, occurrence.event
|
||||
),
|
||||
"can_delete": user_can_delete_event(
|
||||
request.user, occurrence.event
|
||||
),
|
||||
"is_recurring": occurrence.event.rule_id is not None,
|
||||
"rule": occurrence.event.rule_id,
|
||||
"end_recurring_period": (
|
||||
|
||||
@ -31,8 +31,6 @@ class DeptPerm:
|
||||
MANAGE = "manage_department"
|
||||
CHANGE_HEAD = "change_department_head"
|
||||
ASSIGN_ROLE = "assign_department_role"
|
||||
MANAGE_CALENDAR = "manage_department_events"
|
||||
CREATE_POST = "publish_department_post"
|
||||
MANAGE_FEED = "manage_department_feed"
|
||||
VIEW_REQUESTCOMMENT = "view_requestcomment"
|
||||
ADD_REQUESTCOMMENT = "add_requestcomment"
|
||||
@ -44,8 +42,6 @@ class DeptPerm:
|
||||
(MANAGE, "Управлять отделом"),
|
||||
(CHANGE_HEAD, "Назначать руководителя"),
|
||||
(ASSIGN_ROLE, "Назначать роли участникам"),
|
||||
(MANAGE_CALENDAR, "Управлять календарём отдела"),
|
||||
(CREATE_POST, "Публиковать новости на странице отдела"),
|
||||
(MANAGE_FEED, "Редактировать публикации отдела"),
|
||||
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
|
||||
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.4 on 2026-04-16 11:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def remove_obsolete_department_permissions(apps, schema_editor):
|
||||
DepartmentPermission = apps.get_model("employees", "DepartmentPermission")
|
||||
DepartmentPermission.objects.filter(
|
||||
code__in=["manage_department_events", "publish_department_post"]
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('employees', '0046_alter_ldapsyncqueue_operation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='departmentpermission',
|
||||
name='code',
|
||||
field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям'), ('view_request', 'Просмотр заявлений отдела'), ('can_process_requests', 'Рассмотрение заявлений отдела'), ('manage_department_equipment', 'Управлять оборудованием отдела')], max_length=64, unique=True),
|
||||
),
|
||||
migrations.RunPython(
|
||||
remove_obsolete_department_permissions,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -17,6 +17,14 @@ from .constants import (
|
||||
)
|
||||
|
||||
|
||||
def _sync_department_calendar_after_commit(department_id: int) -> None:
|
||||
from scheduling.services import (
|
||||
sync_department_calendar_binding_by_department_id,
|
||||
)
|
||||
|
||||
sync_department_calendar_binding_by_department_id(department_id)
|
||||
|
||||
|
||||
class DateRangeMixin(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -459,6 +467,12 @@ class Department(models.Model):
|
||||
},
|
||||
)
|
||||
|
||||
transaction.on_commit(
|
||||
lambda department_id=self.pk: _sync_department_calendar_after_commit(
|
||||
department_id
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def active_employees(self):
|
||||
"""
|
||||
|
||||
@ -25,6 +25,7 @@ class SchedulingConfig(AppConfig):
|
||||
|
||||
# Регистрируем сигналы для уведомлений
|
||||
import scheduling.notifications.signals # noqa: F401
|
||||
import scheduling.signals # noqa: F401
|
||||
|
||||
# Загружаем правила доступа (django-rules)
|
||||
try:
|
||||
|
||||
189
backend/scheduling/migrations/0002_calendarbinding.py
Normal file
189
backend/scheduling/migrations/0002_calendarbinding.py
Normal file
@ -0,0 +1,189 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def backfill_calendar_bindings(apps, schema_editor):
|
||||
Calendar = apps.get_model("schedule", "Calendar")
|
||||
CalendarBinding = apps.get_model("scheduling", "CalendarBinding")
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
Department = apps.get_model("employees", "Department")
|
||||
|
||||
department_ct, _ = ContentType.objects.get_or_create(
|
||||
app_label="employees",
|
||||
model="department",
|
||||
)
|
||||
department_ids = set(Department.objects.values_list("id", flat=True))
|
||||
|
||||
table_names = set(schema_editor.connection.introspection.table_names())
|
||||
legacy_department_map = {}
|
||||
if "calendar_app_calendar" in table_names:
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, owner_department_id
|
||||
FROM calendar_app_calendar
|
||||
WHERE owner_department_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
legacy_department_map = {
|
||||
old_id: department_id
|
||||
for old_id, department_id in cursor.fetchall()
|
||||
if department_id in department_ids
|
||||
}
|
||||
|
||||
candidates = defaultdict(list)
|
||||
for calendar in Calendar.objects.all().order_by("-id"):
|
||||
slug = calendar.slug or ""
|
||||
department_id = None
|
||||
priority = None
|
||||
|
||||
if slug.startswith("legacy-calendar-"):
|
||||
try:
|
||||
legacy_id = int(slug.removeprefix("legacy-calendar-"))
|
||||
except ValueError:
|
||||
legacy_id = None
|
||||
if legacy_id is not None:
|
||||
department_id = legacy_department_map.get(legacy_id)
|
||||
priority = 0 if department_id is not None else None
|
||||
elif slug.startswith("department-"):
|
||||
tail = slug.removeprefix("department-")
|
||||
if tail.isdigit():
|
||||
department_id = int(tail)
|
||||
if department_id in department_ids:
|
||||
priority = 1
|
||||
|
||||
if department_id is None or priority is None:
|
||||
continue
|
||||
|
||||
candidates[department_id].append((priority, calendar.id))
|
||||
|
||||
for department_id, rows in candidates.items():
|
||||
priority, calendar_id = sorted(rows, key=lambda item: (item[0], -item[1]))[0]
|
||||
CalendarBinding.objects.get_or_create(
|
||||
calendar_id=calendar_id,
|
||||
defaults={
|
||||
"type": "department",
|
||||
"context_content_type_id": department_ct.id,
|
||||
"context_object_id": department_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("employees", "0047_alter_departmentpermission_code"),
|
||||
("schedule", "0015_rename_indexes"),
|
||||
("scheduling", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CalendarBinding",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("default", "Обычный"),
|
||||
("department", "Отдел"),
|
||||
],
|
||||
db_index=True,
|
||||
default="default",
|
||||
max_length=32,
|
||||
verbose_name="Тип привязки",
|
||||
),
|
||||
),
|
||||
(
|
||||
"context_object_id",
|
||||
models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="ID объекта контекста",
|
||||
),
|
||||
),
|
||||
(
|
||||
"flags",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name="Флаги",
|
||||
),
|
||||
),
|
||||
(
|
||||
"extra_data",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name="Доп. данные",
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"calendar",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="binding",
|
||||
to="schedule.calendar",
|
||||
verbose_name="Календарь",
|
||||
),
|
||||
),
|
||||
(
|
||||
"context_content_type",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
verbose_name="Тип контекста",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Привязка календаря",
|
||||
"verbose_name_plural": "Привязки календарей",
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="calendarbinding",
|
||||
index=models.Index(
|
||||
fields=["type"], name="calendar_binding_type_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="calendarbinding",
|
||||
index=models.Index(
|
||||
fields=["context_content_type", "context_object_id"],
|
||||
name="calendar_binding_context_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="calendarbinding",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(
|
||||
("context_content_type__isnull", False),
|
||||
("context_object_id__isnull", False),
|
||||
("type", "department"),
|
||||
),
|
||||
fields=("type", "context_content_type", "context_object_id"),
|
||||
name="uniq_department_calendar_binding",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
backfill_calendar_bindings,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
72
backend/scheduling/models.py
Normal file
72
backend/scheduling/models.py
Normal file
@ -0,0 +1,72 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class CalendarBinding(models.Model):
|
||||
class BindingType(models.TextChoices):
|
||||
DEFAULT = "default", "Обычный"
|
||||
DEPARTMENT = "department", "Отдел"
|
||||
|
||||
calendar = models.OneToOneField(
|
||||
"schedule.Calendar",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="binding",
|
||||
verbose_name="Календарь",
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=32,
|
||||
choices=BindingType.choices,
|
||||
default=BindingType.DEFAULT,
|
||||
db_index=True,
|
||||
verbose_name="Тип привязки",
|
||||
)
|
||||
context_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="Тип контекста",
|
||||
)
|
||||
context_object_id = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="ID объекта контекста",
|
||||
)
|
||||
context_object = GenericForeignKey(
|
||||
"context_content_type",
|
||||
"context_object_id",
|
||||
)
|
||||
flags = models.JSONField(default=dict, blank=True, verbose_name="Флаги")
|
||||
extra_data = models.JSONField(
|
||||
default=dict, blank=True, verbose_name="Доп. данные"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Привязка календаря"
|
||||
verbose_name_plural = "Привязки календарей"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["type", "context_content_type", "context_object_id"],
|
||||
condition=Q(
|
||||
type="department",
|
||||
context_content_type__isnull=False,
|
||||
context_object_id__isnull=False,
|
||||
),
|
||||
name="uniq_department_calendar_binding",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["type"], name="calendar_binding_type_idx"),
|
||||
models.Index(
|
||||
fields=["context_content_type", "context_object_id"],
|
||||
name="calendar_binding_context_idx",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
target = self.context_object or self.calendar
|
||||
return f"{self.type}: {target}"
|
||||
378
backend/scheduling/services.py
Normal file
378
backend/scheduling/services.py
Normal file
@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment
|
||||
from schedule.models import Calendar, CalendarRelation, Event
|
||||
|
||||
from .models import CalendarBinding
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def get_calendar_binding(calendar: Calendar | None) -> CalendarBinding | None:
|
||||
if calendar is None:
|
||||
return None
|
||||
binding = getattr(calendar, "binding", None)
|
||||
if binding is not None:
|
||||
return binding
|
||||
return (
|
||||
CalendarBinding.objects.select_related("context_content_type")
|
||||
.filter(calendar=calendar)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def is_department_calendar(calendar: Calendar | None) -> bool:
|
||||
binding = get_calendar_binding(calendar)
|
||||
return bool(
|
||||
binding and binding.type == CalendarBinding.BindingType.DEPARTMENT
|
||||
)
|
||||
|
||||
|
||||
def get_department_calendar_binding(
|
||||
department: Department,
|
||||
) -> CalendarBinding | None:
|
||||
dept_ct = ContentType.objects.get_for_model(Department)
|
||||
return (
|
||||
CalendarBinding.objects.select_related("calendar")
|
||||
.filter(
|
||||
type=CalendarBinding.BindingType.DEPARTMENT,
|
||||
context_content_type=dept_ct,
|
||||
context_object_id=department.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def department_calendar_name(department: Department) -> str:
|
||||
return f"Календарь отдела: {department.name}"
|
||||
|
||||
|
||||
def department_calendar_slug(department: Department) -> str:
|
||||
return f"department-{department.id}"
|
||||
|
||||
|
||||
def _unique_calendar_slug(base_slug: str) -> str:
|
||||
if not Calendar.objects.filter(slug=base_slug).exists():
|
||||
return base_slug
|
||||
|
||||
counter = 1
|
||||
while Calendar.objects.filter(slug=f"{base_slug}-{counter}").exists():
|
||||
counter += 1
|
||||
return f"{base_slug}-{counter}"
|
||||
|
||||
|
||||
def _find_existing_department_calendar(
|
||||
department: Department,
|
||||
) -> Calendar | None:
|
||||
slug = department_calendar_slug(department)
|
||||
return (
|
||||
Calendar.objects.filter(slug=slug)
|
||||
.exclude(binding__isnull=False)
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def get_or_create_department_calendar(
|
||||
department: Department,
|
||||
) -> tuple[Calendar, CalendarBinding]:
|
||||
binding = get_department_calendar_binding(department)
|
||||
if binding:
|
||||
sync_department_calendar_binding(department, binding=binding)
|
||||
return binding.calendar, binding
|
||||
|
||||
calendar = _find_existing_department_calendar(department)
|
||||
if calendar is None:
|
||||
calendar = Calendar.objects.create(
|
||||
name=department_calendar_name(department),
|
||||
slug=_unique_calendar_slug(department_calendar_slug(department)),
|
||||
)
|
||||
|
||||
dept_ct = ContentType.objects.get_for_model(Department)
|
||||
binding, _ = CalendarBinding.objects.get_or_create(
|
||||
calendar=calendar,
|
||||
defaults={
|
||||
"type": CalendarBinding.BindingType.DEPARTMENT,
|
||||
"context_content_type": dept_ct,
|
||||
"context_object_id": department.id,
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
binding.type != CalendarBinding.BindingType.DEPARTMENT
|
||||
or binding.context_content_type_id != dept_ct.id
|
||||
or binding.context_object_id != department.id
|
||||
):
|
||||
binding.type = CalendarBinding.BindingType.DEPARTMENT
|
||||
binding.context_content_type = dept_ct
|
||||
binding.context_object_id = department.id
|
||||
binding.save(
|
||||
update_fields=[
|
||||
"type",
|
||||
"context_content_type",
|
||||
"context_object_id",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
sync_department_calendar_binding(department, binding=binding)
|
||||
return binding.calendar, binding
|
||||
|
||||
|
||||
def _department_participant_ids(department: Department) -> set[int]:
|
||||
ids: set[int] = set(
|
||||
EmployeeDepartment.objects.filter(
|
||||
department=department,
|
||||
is_active=True,
|
||||
employee__is_active=True,
|
||||
).values_list("employee_id", flat=True)
|
||||
)
|
||||
|
||||
role_only_ids = RoleAssignment.objects.filter(
|
||||
role__department=department,
|
||||
is_active=True,
|
||||
employee__is_active=True,
|
||||
).values_list("employee_id", flat=True)
|
||||
ids.update(role_only_ids)
|
||||
|
||||
if department.head_id:
|
||||
ids.add(department.head_id)
|
||||
|
||||
return ids
|
||||
|
||||
|
||||
def _set_relation_role(
|
||||
calendar: Calendar, user_id: int, distinction: str
|
||||
) -> None:
|
||||
user_ct = ContentType.objects.get_for_model(User)
|
||||
relations = list(
|
||||
CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=user_ct,
|
||||
object_id=user_id,
|
||||
).order_by("id")
|
||||
)
|
||||
if relations:
|
||||
primary = relations[0]
|
||||
changed = False
|
||||
if primary.distinction != distinction:
|
||||
primary.distinction = distinction
|
||||
changed = True
|
||||
if primary.inheritable is not True:
|
||||
primary.inheritable = True
|
||||
changed = True
|
||||
if changed:
|
||||
primary.save(update_fields=["distinction", "inheritable"])
|
||||
if len(relations) > 1:
|
||||
CalendarRelation.objects.filter(
|
||||
id__in=[relation.id for relation in relations[1:]]
|
||||
).delete()
|
||||
return
|
||||
|
||||
calendar.create_relation(
|
||||
User.objects.get(pk=user_id),
|
||||
distinction=distinction,
|
||||
inheritable=True,
|
||||
)
|
||||
|
||||
|
||||
def sync_department_calendar_owner(
|
||||
department: Department, *, binding: CalendarBinding | None = None
|
||||
) -> Calendar | None:
|
||||
binding = binding or get_department_calendar_binding(department)
|
||||
if not binding:
|
||||
return None
|
||||
|
||||
calendar = binding.calendar
|
||||
desired_owner_id = department.head_id
|
||||
user_ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
owner_relations = CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=user_ct,
|
||||
distinction="owner",
|
||||
).order_by("id")
|
||||
|
||||
if desired_owner_id is None:
|
||||
owner_relations.delete()
|
||||
return calendar
|
||||
|
||||
_set_relation_role(calendar, desired_owner_id, "owner")
|
||||
|
||||
owner_relations.exclude(object_id=desired_owner_id).delete()
|
||||
return calendar
|
||||
|
||||
|
||||
def sync_department_calendar_members(
|
||||
department: Department, *, binding: CalendarBinding | None = None
|
||||
) -> Calendar | None:
|
||||
binding = binding or get_department_calendar_binding(department)
|
||||
if not binding:
|
||||
return None
|
||||
|
||||
calendar = binding.calendar
|
||||
desired_ids = _department_participant_ids(department)
|
||||
owner_id = department.head_id
|
||||
user_ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
existing_relations = list(
|
||||
CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=user_ct,
|
||||
).order_by("id")
|
||||
)
|
||||
existing_ids = {relation.object_id for relation in existing_relations}
|
||||
|
||||
if owner_id:
|
||||
_set_relation_role(calendar, owner_id, "owner")
|
||||
|
||||
for user_id in sorted(desired_ids):
|
||||
if user_id == owner_id:
|
||||
continue
|
||||
_set_relation_role(calendar, user_id, "editor")
|
||||
|
||||
stale_relation_ids = [
|
||||
relation.id
|
||||
for relation in existing_relations
|
||||
if relation.object_id not in desired_ids
|
||||
]
|
||||
if stale_relation_ids:
|
||||
CalendarRelation.objects.filter(id__in=stale_relation_ids).delete()
|
||||
|
||||
return calendar
|
||||
|
||||
|
||||
def sync_department_calendar_binding(
|
||||
department: Department, *, binding: CalendarBinding | None = None
|
||||
) -> Calendar | None:
|
||||
binding = binding or get_department_calendar_binding(department)
|
||||
if not binding:
|
||||
return None
|
||||
|
||||
calendar = binding.calendar
|
||||
desired_name = department_calendar_name(department)
|
||||
if calendar.name != desired_name:
|
||||
calendar.name = desired_name
|
||||
calendar.save(update_fields=["name"])
|
||||
|
||||
sync_department_calendar_owner(department, binding=binding)
|
||||
sync_department_calendar_members(department, binding=binding)
|
||||
return calendar
|
||||
|
||||
|
||||
def sync_department_calendar_if_bound(department: Department) -> None:
|
||||
binding = get_department_calendar_binding(department)
|
||||
if not binding:
|
||||
return
|
||||
sync_department_calendar_binding(department, binding=binding)
|
||||
|
||||
|
||||
def sync_department_calendar_binding_by_department_id(department_id: int) -> None:
|
||||
try:
|
||||
department = Department.objects.get(pk=department_id)
|
||||
except Department.DoesNotExist:
|
||||
return
|
||||
sync_department_calendar_if_bound(department)
|
||||
|
||||
|
||||
def delete_department_calendar(department_id: int) -> None:
|
||||
try:
|
||||
department = Department.objects.get(pk=department_id)
|
||||
except Department.DoesNotExist:
|
||||
department = None
|
||||
|
||||
if department:
|
||||
binding = get_department_calendar_binding(department)
|
||||
else:
|
||||
dept_ct = ContentType.objects.get_for_model(Department)
|
||||
binding = CalendarBinding.objects.filter(
|
||||
type=CalendarBinding.BindingType.DEPARTMENT,
|
||||
context_content_type=dept_ct,
|
||||
context_object_id=department_id,
|
||||
).select_related("calendar").first()
|
||||
|
||||
if binding:
|
||||
binding.calendar.delete()
|
||||
|
||||
|
||||
def user_relation_distinction(
|
||||
calendar: Calendar, user
|
||||
) -> str | None:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return None
|
||||
if user.is_staff or user.is_superuser:
|
||||
return "owner"
|
||||
|
||||
user_ct = ContentType.objects.get_for_model(User)
|
||||
relation = CalendarRelation.objects.filter(
|
||||
calendar=calendar,
|
||||
content_type=user_ct,
|
||||
object_id=user.id,
|
||||
).order_by("id").first()
|
||||
return relation.distinction if relation else None
|
||||
|
||||
|
||||
def user_can_view_calendar(user, calendar: Calendar) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
if is_department_calendar(calendar):
|
||||
return True
|
||||
return user_relation_distinction(calendar, user) is not None
|
||||
|
||||
|
||||
def user_can_create_event(user, calendar: Calendar) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
if is_department_calendar(calendar):
|
||||
return True
|
||||
return user_relation_distinction(calendar, user) in {"owner", "editor"}
|
||||
|
||||
|
||||
def user_can_edit_calendar(user, calendar: Calendar) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
return user_relation_distinction(calendar, user) == "owner"
|
||||
|
||||
|
||||
def user_can_manage_participants(user, calendar: Calendar) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
if is_department_calendar(calendar):
|
||||
return False
|
||||
return user_relation_distinction(calendar, user) == "owner"
|
||||
|
||||
|
||||
def user_can_edit_event(user, event: Event) -> bool:
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_staff or user.is_superuser:
|
||||
return True
|
||||
|
||||
calendar = event.calendar
|
||||
if is_department_calendar(calendar):
|
||||
relation = user_relation_distinction(calendar, user)
|
||||
if relation in {"owner", "editor"}:
|
||||
return True
|
||||
return event.creator_id == user.id
|
||||
|
||||
return user_relation_distinction(calendar, user) in {"owner", "editor"}
|
||||
|
||||
|
||||
def user_can_delete_event(user, event: Event) -> bool:
|
||||
return user_can_edit_event(user, event)
|
||||
35
backend/scheduling/signals.py
Normal file
35
backend/scheduling/signals.py
Normal file
@ -0,0 +1,35 @@
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from employees.models import Department, EmployeeDepartment, RoleAssignment
|
||||
|
||||
from .services import delete_department_calendar, sync_department_calendar_if_bound
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Department)
|
||||
def delete_department_calendar_on_department_delete(sender, instance, **kwargs):
|
||||
delete_department_calendar(instance.id)
|
||||
|
||||
|
||||
@receiver(post_save, sender=EmployeeDepartment)
|
||||
def sync_department_calendar_on_member_save(sender, instance, **kwargs):
|
||||
sync_department_calendar_if_bound(instance.department)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=EmployeeDepartment)
|
||||
def sync_department_calendar_on_member_delete(sender, instance, **kwargs):
|
||||
sync_department_calendar_if_bound(instance.department)
|
||||
|
||||
|
||||
@receiver(post_save, sender=RoleAssignment)
|
||||
def sync_department_calendar_on_role_assignment_save(
|
||||
sender, instance, **kwargs
|
||||
):
|
||||
sync_department_calendar_if_bound(instance.role.department)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=RoleAssignment)
|
||||
def sync_department_calendar_on_role_assignment_delete(
|
||||
sender, instance, **kwargs
|
||||
):
|
||||
sync_department_calendar_if_bound(instance.role.department)
|
||||
246
backend/tests/api/v1/employees/test_department_calendar.py
Normal file
246
backend/tests/api/v1/employees/test_department_calendar.py
Normal file
@ -0,0 +1,246 @@
|
||||
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()
|
||||
@ -173,6 +173,8 @@ def test_perm_choices_and_perms_and_set_perms(api_client: APIClient):
|
||||
assert resp.status_code == 200
|
||||
codes = {row["code"] for row in resp.json().get("results", [])}
|
||||
assert {"manage_department", "change_department_head", "assign_department_role"}.issubset(codes)
|
||||
assert "manage_department_events" not in codes
|
||||
assert "publish_department_post" not in codes
|
||||
|
||||
# set_perms (codes)
|
||||
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])
|
||||
|
||||
@ -4,10 +4,8 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from employees.constants import DeptPerm
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentPermission,
|
||||
DepartmentRole,
|
||||
EmployeeDepartment,
|
||||
RoleAssignment,
|
||||
@ -60,27 +58,6 @@ def test_department_user_perms_includes_feed_flags_for_head(api_client: APIClien
|
||||
assert resp.json()["can_manage_feed"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_department_user_perms_resolves_role_based_feed_rights(api_client: APIClient):
|
||||
employee = make_user()
|
||||
dept = Department.objects.create(name="Закупки")
|
||||
role = DepartmentRole.objects.create(department=dept, name="Редактор ленты")
|
||||
publish_perm, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=DeptPerm.CREATE_POST,
|
||||
defaults={"name": "Публиковать новости на странице отдела"},
|
||||
)
|
||||
role.scoped_permissions.add(publish_perm)
|
||||
RoleAssignment.objects.create(employee=employee, role=role, is_active=True)
|
||||
|
||||
api_client.force_authenticate(user=employee)
|
||||
url = reverse("api:v1:departments-user-perms", args=[dept.id])
|
||||
resp = api_client.get(url)
|
||||
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.json()["can_publish_posts"] is True
|
||||
assert resp.json()["can_manage_feed"] is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_department_user_perms_allows_active_member_to_publish(api_client: APIClient):
|
||||
employee = make_user()
|
||||
|
||||
@ -2,9 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from employees.constants import DeptPerm
|
||||
from employees.models import DepartmentPermission
|
||||
from employees.models import (
|
||||
DepartmentPermission,
|
||||
DepartmentRole,
|
||||
EmployeeDepartment,
|
||||
RoleAssignment,
|
||||
@ -28,14 +27,14 @@ def _department_post_payload(department_id: int, **extra):
|
||||
return payload
|
||||
|
||||
|
||||
def _grant_department_post_role(user, department):
|
||||
def _grant_department_feed_role(user, department):
|
||||
permission, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=DeptPerm.CREATE_POST,
|
||||
defaults={"name": "Публиковать новости на странице отдела"},
|
||||
code="manage_department_feed",
|
||||
defaults={"name": "Редактировать публикации отдела"},
|
||||
)
|
||||
role = DepartmentRole.objects.create(
|
||||
department=department,
|
||||
name=f"Publisher {user.id}",
|
||||
name=f"Feed manager {user.id}",
|
||||
)
|
||||
role.scoped_permissions.add(permission)
|
||||
RoleAssignment.objects.create(
|
||||
@ -197,26 +196,6 @@ def test_active_department_member_can_create_department_post(
|
||||
assert Post.objects.filter(id=response.data["id"], department=department).exists()
|
||||
|
||||
|
||||
def test_role_assignment_can_create_department_post(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
department_factory,
|
||||
):
|
||||
user = make_user("publisher@example.com")
|
||||
department = department_factory(name="Accounting")
|
||||
_grant_department_post_role(user, department)
|
||||
|
||||
client = auth_client_factory(user)
|
||||
response = client.post(
|
||||
API_POSTS_URL,
|
||||
_department_post_payload(department.id),
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert Post.objects.filter(id=response.data["id"], department=department).exists()
|
||||
|
||||
|
||||
def test_any_role_assignment_can_create_department_post(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
@ -245,6 +224,85 @@ def test_any_role_assignment_can_create_department_post(
|
||||
assert Post.objects.filter(id=response.data["id"], department=department).exists()
|
||||
|
||||
|
||||
def test_department_post_patch_requires_manage_feed_permission(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
department_factory,
|
||||
):
|
||||
author = make_user("author-edit@example.com")
|
||||
editor = make_user("editor@example.com")
|
||||
member_without_perm = make_user("member-without-feed@example.com")
|
||||
department = department_factory(name="Accounting", head=author)
|
||||
post = Post.objects.create(
|
||||
author=author,
|
||||
department=department,
|
||||
type="department",
|
||||
title="До редактирования",
|
||||
body="body",
|
||||
)
|
||||
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member_without_perm,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
_grant_department_feed_role(editor, department)
|
||||
|
||||
member_client = auth_client_factory(member_without_perm)
|
||||
response = member_client.patch(
|
||||
f"{API_POSTS_URL}{post.id}/",
|
||||
{"title": "Не должно пройти"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
editor_client = auth_client_factory(editor)
|
||||
response = editor_client.patch(
|
||||
f"{API_POSTS_URL}{post.id}/",
|
||||
{"title": "После редактирования"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
post.refresh_from_db()
|
||||
assert post.title == "После редактирования"
|
||||
|
||||
|
||||
def test_department_post_delete_requires_manage_feed_permission(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
department_factory,
|
||||
):
|
||||
author = make_user("author-delete@example.com")
|
||||
editor = make_user("feed-editor@example.com")
|
||||
member_without_perm = make_user("member-without-delete@example.com")
|
||||
department = department_factory(name="Finance", head=author)
|
||||
post = Post.objects.create(
|
||||
author=author,
|
||||
department=department,
|
||||
type="department",
|
||||
title="Удаляемый пост",
|
||||
body="body",
|
||||
)
|
||||
|
||||
EmployeeDepartment.objects.create(
|
||||
employee=member_without_perm,
|
||||
department=department,
|
||||
is_active=True,
|
||||
)
|
||||
_grant_department_feed_role(editor, department)
|
||||
|
||||
member_client = auth_client_factory(member_without_perm)
|
||||
response = member_client.delete(f"{API_POSTS_URL}{post.id}/")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Post.objects.filter(id=post.id).exists()
|
||||
|
||||
editor_client = auth_client_factory(editor)
|
||||
response = editor_client.delete(f"{API_POSTS_URL}{post.id}/")
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Post.objects.filter(id=post.id).exists()
|
||||
|
||||
|
||||
def test_list_department_posts_filtered_by_department(
|
||||
auth_client_factory,
|
||||
make_user,
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
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 test_migration_0047_removes_obsolete_department_permissions():
|
||||
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
|
||||
@ -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,27 @@ 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;
|
||||
};
|
||||
const params = new URLSearchParams({
|
||||
calendar: String(response.calendar_id),
|
||||
calendarName: response.name,
|
||||
});
|
||||
router.push(`/calendar?${params.toString()}`);
|
||||
} catch {
|
||||
toast.error("Не удалось открыть календарь отдела");
|
||||
} finally {
|
||||
setOpeningDepartmentCalendar(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="space-y-6">
|
||||
@ -1006,7 +1029,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()}
|
||||
@ -1016,6 +1039,15 @@ export default function DepartmentDetailPage() {
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { Calendar, dateFnsLocalizer, View, ToolbarProps, NavigateAction } 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 } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
@ -43,6 +43,8 @@ type CalendarEvent = {
|
||||
isOccurrence?: boolean; // Флаг для повторяющихся событий
|
||||
event_id?: number; // ID базового события для occurrence
|
||||
allDay?: boolean; // Флаг целодневного события
|
||||
can_edit?: boolean;
|
||||
can_delete?: boolean;
|
||||
};
|
||||
|
||||
// Кастомные сообщения на русском
|
||||
@ -145,6 +147,32 @@ export function BigCalendar() {
|
||||
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
|
||||
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
|
||||
const linkedEventId = Number(searchParams.get("event") || "");
|
||||
const linkedCalendarId = Number(searchParams.get("calendar") || "");
|
||||
const linkedCalendarName = searchParams.get("calendarName") || "";
|
||||
|
||||
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}`,
|
||||
can_create_events: true,
|
||||
can_edit_calendar: false,
|
||||
can_manage_participants: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [calendars, linkedCalendarName, selectedCalendarId]);
|
||||
|
||||
const calendarOptions = useMemo(() => {
|
||||
if (!selectedCalendar) return calendars;
|
||||
if (calendars.some((item) => item.id === selectedCalendar.id)) {
|
||||
return calendars;
|
||||
}
|
||||
return [...calendars, selectedCalendar];
|
||||
}, [calendars, selectedCalendar]);
|
||||
|
||||
const clearEventParam = useCallback(() => {
|
||||
if (!searchParams.get("event")) return;
|
||||
@ -153,6 +181,11 @@ 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 () => {
|
||||
try {
|
||||
@ -217,6 +250,8 @@ 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 для корректного отображения
|
||||
}));
|
||||
|
||||
@ -275,6 +310,10 @@ export function BigCalendar() {
|
||||
alert("Сначала выберите календарь");
|
||||
return;
|
||||
}
|
||||
if (selectedCalendar?.can_create_events === false) {
|
||||
alert("В выбранный календарь нельзя добавлять события");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDate) return;
|
||||
|
||||
@ -297,7 +336,7 @@ export function BigCalendar() {
|
||||
// Закрываем модал просмотра дня и открываем модал создания
|
||||
setShowDayEventsModal(false);
|
||||
setShowEventModal(true);
|
||||
}, [selectedCalendarId, selectedDate]);
|
||||
}, [selectedCalendar?.can_create_events, selectedCalendarId, selectedDate]);
|
||||
|
||||
const handleSelectEvent = useCallback(async (calEvent: CalendarEvent) => {
|
||||
// Если это occurrence (повторяющееся событие), загружаем базовое событие
|
||||
@ -398,7 +437,7 @@ export function BigCalendar() {
|
||||
className="app-select max-w-xs flex-1"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendars.map((cal) => (
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
@ -416,10 +455,10 @@ export function BigCalendar() {
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
{selectedCalendarId && (
|
||||
{selectedCalendarId && selectedCalendar?.can_edit_calendar && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const cal = calendars.find((c) => c.id === selectedCalendarId);
|
||||
const cal = selectedCalendar;
|
||||
if (cal) {
|
||||
setEditingCalendar({ id: cal.id, name: cal.name });
|
||||
setShowCalendarModal(true);
|
||||
|
||||
@ -98,6 +98,8 @@ export function ViewEventDetailsModal({
|
||||
|
||||
const capitalizedStart = formatDateTime(event.start).charAt(0).toUpperCase() + formatDateTime(event.start).slice(1);
|
||||
const capitalizedEnd = formatDateTime(event.end).charAt(0).toUpperCase() + formatDateTime(event.end).slice(1);
|
||||
const canEdit = event.can_edit !== false;
|
||||
const canDelete = event.can_delete !== false;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -105,24 +107,28 @@ export function ViewEventDetailsModal({
|
||||
onClose={onClose}
|
||||
title={event.title}
|
||||
size="sm"
|
||||
footer={
|
||||
footer={canEdit || canDelete ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
) : undefined}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Event meta */}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ChevronLeft, ChevronRight, Plus, Users } from "lucide-react";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
|
||||
@ -38,6 +39,8 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
onCalendarChange,
|
||||
}: CalendarCardProps) {
|
||||
const { calendars, selectedCalendarId, setSelectedCalendarId } = useCalendar();
|
||||
const searchParams = useSearchParams();
|
||||
const linkedCalendarName = searchParams.get("calendarName") || "";
|
||||
|
||||
const [monthDate, setMonthDate] = useState(() => {
|
||||
const now = new Date();
|
||||
@ -49,6 +52,30 @@ 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}`,
|
||||
can_create_events: true,
|
||||
can_edit_calendar: false,
|
||||
can_manage_participants: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [calendars, linkedCalendarName, selectedCalendarId]);
|
||||
|
||||
const calendarOptions = useMemo(() => {
|
||||
if (!selectedCalendar) return calendars;
|
||||
if (calendars.some((item) => item.id === selectedCalendar.id)) {
|
||||
return calendars;
|
||||
}
|
||||
return [...calendars, selectedCalendar];
|
||||
}, [calendars, selectedCalendar]);
|
||||
|
||||
// Загружаем события через хук
|
||||
const { events, loading, error } = useCalendarEvents({
|
||||
monthDate,
|
||||
@ -81,6 +108,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 +126,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 (повторяющееся событие), загружаем базовое событие
|
||||
@ -274,13 +304,13 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
className="app-select flex-1 rounded-lg px-2.5 py-2 text-xs"
|
||||
>
|
||||
<option value="">📅 Все события</option>
|
||||
{calendars.map((cal) => (
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>
|
||||
{cal.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedCalendarId && (
|
||||
{selectedCalendar && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCalendarMenu(!showCalendarMenu)}
|
||||
@ -304,12 +334,12 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
<div className="py-1">
|
||||
<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 (selectedCalendar?.can_manage_participants) {
|
||||
onOpenParticipantsModal({ id: selectedCalendar.id, name: selectedCalendar.name, user_role: (selectedCalendar as any).user_role });
|
||||
}
|
||||
setShowCalendarMenu(false);
|
||||
}}
|
||||
disabled={!selectedCalendar?.can_manage_participants}
|
||||
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 +360,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">
|
||||
@ -353,12 +384,12 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
<div className="app-divider my-1 border-t"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const cal = calendars.find(c => c.id === selectedCalendarId);
|
||||
if (cal) {
|
||||
onOpenCalendarModal(cal);
|
||||
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)]"
|
||||
>
|
||||
<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">
|
||||
|
||||
@ -3,13 +3,7 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
|
||||
type Calendar = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
events_count?: number;
|
||||
};
|
||||
import type { Calendar } from "@/types/api";
|
||||
|
||||
type CalendarContextType = {
|
||||
calendars: Calendar[];
|
||||
|
||||
@ -71,6 +71,8 @@ export function createEmployeesApi(request: RequestFn) {
|
||||
request(`/api/v1/departments/${id}/members/`),
|
||||
getDepartmentDiscussionChat: (id: number | string) =>
|
||||
request(`/api/v1/departments/${id}/discussion-chat/`),
|
||||
getDepartmentCalendar: (id: number | string) =>
|
||||
request(`/api/v1/departments/${id}/calendar/`),
|
||||
getDepartmentUserPerms: (id: number | string) =>
|
||||
request(`/api/v1/departments/${id}/user-perms/`),
|
||||
setDepartmentHead: (id: number | string, head_id: number | null) =>
|
||||
|
||||
@ -19,6 +19,8 @@ export interface CalendarEvent {
|
||||
event_id?: number;
|
||||
description?: string;
|
||||
calendar?: number;
|
||||
can_edit?: boolean;
|
||||
can_delete?: boolean;
|
||||
}
|
||||
|
||||
interface EventsCache {
|
||||
|
||||
@ -716,6 +716,8 @@ export interface CalendarEvent {
|
||||
creator?: number | null;
|
||||
created_on?: string;
|
||||
updated_on?: string;
|
||||
can_edit?: boolean;
|
||||
can_delete?: boolean;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
@ -723,6 +725,14 @@ export interface Calendar {
|
||||
name: string; // django-scheduler использует name (не title)
|
||||
slug: string;
|
||||
events_count?: number;
|
||||
type?: string;
|
||||
context_object_id?: number | null;
|
||||
context_type?: string | null;
|
||||
flags?: Record<string, any>;
|
||||
user_role?: string | null;
|
||||
can_create_events?: boolean;
|
||||
can_edit_calendar?: boolean;
|
||||
can_manage_participants?: boolean;
|
||||
}
|
||||
|
||||
// Notifications types (v2 API)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user