diff --git a/backend/api/v1/employees/views/departments.py b/backend/api/v1/employees/views/departments.py index fc0ed460..90d429dc 100644 --- a/backend/api/v1/employees/views/departments.py +++ b/backend/api/v1/employees/views/departments.py @@ -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/. diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py index 6d76678e..4d88ae1b 100644 --- a/backend/api/v1/permissions.py +++ b/backend/api/v1/permissions.py @@ -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, } """ diff --git a/backend/api/v1/schedule/permissions.py b/backend/api/v1/schedule/permissions.py index c028ff51..d8fc1c41 100644 --- a/backend/api/v1/schedule/permissions.py +++ b/backend/api/v1/schedule/permissions.py @@ -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) diff --git a/backend/api/v1/schedule/serializers.py b/backend/api/v1/schedule/serializers.py index a847b3ae..84dbe40f 100644 --- a/backend/api/v1/schedule/serializers.py +++ b/backend/api/v1/schedule/serializers.py @@ -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", ] diff --git a/backend/api/v1/schedule/tests.py b/backend/api/v1/schedule/tests.py index b3265d59..90f8245c 100644 --- a/backend/api/v1/schedule/tests.py +++ b/backend/api/v1/schedule/tests.py @@ -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 diff --git a/backend/api/v1/schedule/views.py b/backend/api/v1/schedule/views.py index 226a5126..781a0f07 100644 --- a/backend/api/v1/schedule/views.py +++ b/backend/api/v1/schedule/views.py @@ -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": ( diff --git a/backend/employees/constants.py b/backend/employees/constants.py index 99fa11e7..5ad14439 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -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, "Добавление коментариев по заявлениям"), diff --git a/backend/employees/migrations/0047_alter_departmentpermission_code.py b/backend/employees/migrations/0047_alter_departmentpermission_code.py new file mode 100644 index 00000000..43a4df6b --- /dev/null +++ b/backend/employees/migrations/0047_alter_departmentpermission_code.py @@ -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, + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index d9947757..fb50a0d4 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -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): """ diff --git a/backend/scheduling/apps.py b/backend/scheduling/apps.py index cf77fc8a..9af6a090 100644 --- a/backend/scheduling/apps.py +++ b/backend/scheduling/apps.py @@ -25,6 +25,7 @@ class SchedulingConfig(AppConfig): # Регистрируем сигналы для уведомлений import scheduling.notifications.signals # noqa: F401 + import scheduling.signals # noqa: F401 # Загружаем правила доступа (django-rules) try: diff --git a/backend/scheduling/migrations/0002_calendarbinding.py b/backend/scheduling/migrations/0002_calendarbinding.py new file mode 100644 index 00000000..a4b740a1 --- /dev/null +++ b/backend/scheduling/migrations/0002_calendarbinding.py @@ -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, + ), + ] diff --git a/backend/scheduling/models.py b/backend/scheduling/models.py new file mode 100644 index 00000000..bd22c1e2 --- /dev/null +++ b/backend/scheduling/models.py @@ -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}" diff --git a/backend/scheduling/services.py b/backend/scheduling/services.py new file mode 100644 index 00000000..2e5cafd8 --- /dev/null +++ b/backend/scheduling/services.py @@ -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) diff --git a/backend/scheduling/signals.py b/backend/scheduling/signals.py new file mode 100644 index 00000000..cbffb533 --- /dev/null +++ b/backend/scheduling/signals.py @@ -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) diff --git a/backend/tests/api/v1/employees/test_department_calendar.py b/backend/tests/api/v1/employees/test_department_calendar.py new file mode 100644 index 00000000..636c8077 --- /dev/null +++ b/backend/tests/api/v1/employees/test_department_calendar.py @@ -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() diff --git a/backend/tests/api/v1/employees/test_department_roles.py b/backend/tests/api/v1/employees/test_department_roles.py index 5fc24e0c..afb61c15 100644 --- a/backend/tests/api/v1/employees/test_department_roles.py +++ b/backend/tests/api/v1/employees/test_department_roles.py @@ -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]) diff --git a/backend/tests/api/v1/employees/test_department_user_perms.py b/backend/tests/api/v1/employees/test_department_user_perms.py index c2fdac0b..a2da73d4 100644 --- a/backend/tests/api/v1/employees/test_department_user_perms.py +++ b/backend/tests/api/v1/employees/test_department_user_perms.py @@ -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() diff --git a/backend/tests/api/v1/feed/test_posts.py b/backend/tests/api/v1/feed/test_posts.py index 34010492..aaf49586 100644 --- a/backend/tests/api/v1/feed/test_posts.py +++ b/backend/tests/api/v1/feed/test_posts.py @@ -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, diff --git a/backend/tests/employees/test_department_permission_migration.py b/backend/tests/employees/test_department_permission_migration.py new file mode 100644 index 00000000..84b2a641 --- /dev/null +++ b/backend/tests/employees/test_department_permission_migration.py @@ -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 diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx index 03fcd8d1..c6c2b2c7 100644 --- a/frontend/src/app/departments/[id]/page.tsx +++ b/frontend/src/app/departments/[id]/page.tsx @@ -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(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 (
@@ -1006,7 +1029,7 @@ export default function DepartmentDetailPage() {

{h.department.description}

-
+
+
diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index 7254ab22..7c1da2fc 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -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(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" > - {calendars.map((cal) => ( + {calendarOptions.map((cal) => ( @@ -416,10 +455,10 @@ export function BigCalendar() { - {selectedCalendarId && ( + {selectedCalendarId && selectedCalendar?.can_edit_calendar && ( - + {canEdit ? ( + + ) : null} + {canDelete ? ( + + ) : null} - } + ) : undefined} >
{/* Event meta */} diff --git a/frontend/src/components/calendar/CalendarCard.tsx b/frontend/src/components/calendar/CalendarCard.tsx index 81f98d4f..10e0c140 100644 --- a/frontend/src/components/calendar/CalendarCard.tsx +++ b/frontend/src/components/calendar/CalendarCard.tsx @@ -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(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" > - {calendars.map((cal) => ( + {calendarOptions.map((cal) => ( ))} - {selectedCalendarId && ( + {selectedCalendar && (