feat: add department calendar bindings and cleanup dept permissions

This commit is contained in:
lizerk 2026-04-16 15:18:43 +03:00
parent f40196f39c
commit 5700b53f13
27 changed files with 1498 additions and 186 deletions

View File

@ -36,6 +36,7 @@ from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from scheduling.services import get_or_create_department_calendar
from ...permissions import (
AdminOrActionOrModelPerms,
@ -110,6 +111,7 @@ class DepartmentViewSet(viewsets.ModelViewSet):
return [AdminOrActionOrModelPerms()]
if self.action in {
"members",
"calendar",
"discussion_chat",
"user_perms",
"list",
@ -500,6 +502,28 @@ class DepartmentViewSet(viewsets.ModelViewSet):
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["get"], url_path="calendar")
def calendar(self, request, pk=None):
"""Возвращает calendar отдела для входа из detail page."""
dept = self.get_object()
calendar, binding = get_or_create_department_calendar(dept)
return Response(
{
"calendar_id": calendar.id,
"calendar_type": binding.type,
"name": calendar.name,
"slug": calendar.slug,
"context_type": (
binding.context_content_type.model
if binding.context_content_type_id
else None
),
"context_object_id": binding.context_object_id,
"flags": binding.flags,
},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["get"], url_path="user-perms")
def user_perms(self, request, pk=None):
"""GET /api/v1/departments/{id}/user-perms/.

View File

@ -176,8 +176,8 @@ class AdminOrDeptAllowed(BasePermission):
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права
(например, DeptPerm.MANAGE_CALENDAR), то доступ
2. Если для текущего экшена требуется код права
(например, DeptPerm.MANAGE_FEED), то доступ
разрешается только при наличии у пользователя этого права
на указанный отдел.
3. Идентификатор отдела извлекается из:
@ -203,15 +203,15 @@ class AdminOrDeptAllowed(BasePermission):
когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class ManageFeedPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_FEED
class DeptEventsPerm(AdminOrDeptAllowed):
class DeptFeedPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
"create": DeptPerm.MANAGE_FEED,
"update": DeptPerm.MANAGE_FEED,
"partial_update": DeptPerm.MANAGE_FEED,
"destroy": DeptPerm.MANAGE_FEED,
}
"""

View File

@ -4,8 +4,13 @@
"""
from rest_framework import permissions
from django.contrib.contenttypes.models import ContentType
from schedule.models import CalendarRelation
from scheduling.services import (
user_can_create_event,
user_can_edit_calendar,
user_can_edit_event,
user_can_view_calendar,
)
class IsOwnerOfCalendar(permissions.BasePermission):
@ -18,28 +23,10 @@ class IsOwnerOfCalendar(permissions.BasePermission):
"""Проверка прав на календарь."""
# SAFE_METHODS (GET, HEAD, OPTIONS) доступны всем участникам
if request.method in permissions.SAFE_METHODS:
return True
return user_can_view_calendar(request.user, obj)
# Только owner может изменять/удалять календарь
return self._is_owner(obj, request.user)
@staticmethod
def _is_owner(calendar, user):
"""Проверка, является ли пользователь владельцем календаря."""
if user.is_staff or user.is_superuser:
return True
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
return CalendarRelation.objects.filter(
calendar=calendar,
content_type=ct,
object_id=user.id,
distinction="owner",
).exists()
return user_can_edit_calendar(request.user, obj)
class CanEditCalendar(permissions.BasePermission):
@ -66,7 +53,7 @@ class CanEditCalendar(permissions.BasePermission):
except Calendar.DoesNotExist:
return False
return self._can_edit(calendar, request.user)
return user_can_create_event(request.user, calendar)
return True
@ -78,28 +65,7 @@ class CanEditCalendar(permissions.BasePermission):
# SAFE_METHODS (GET, HEAD, OPTIONS) доступны всем участникам
if request.method in permissions.SAFE_METHODS:
return True
return user_can_view_calendar(request.user, calendar)
# Для изменения/удаления нужны права editor или owner
return self._can_edit(calendar, request.user)
@staticmethod
def _can_edit(calendar, user):
"""Проверка, может ли пользователь редактировать события в календаре."""
if user.is_staff or user.is_superuser:
return True
from django.contrib.auth import get_user_model
User = get_user_model()
ct = ContentType.objects.get_for_model(User)
# Проверяем роль пользователя в календаре
try:
relation = CalendarRelation.objects.get(
calendar=calendar, content_type=ct, object_id=user.id
)
# Owner и editor могут редактировать, viewer - нет
return relation.distinction in ["owner", "editor"]
except CalendarRelation.DoesNotExist:
return False
return user_can_edit_event(request.user, event)

View File

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

View File

@ -3,11 +3,12 @@
Тесты для django-scheduler API endpoints.
"""
import pytest
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from datetime import datetime, timedelta
from rest_framework.test import APIClient
from rest_framework import status
from schedule.models import Calendar, Event, Rule
from schedule.models import Calendar, CalendarRelation, Event, Rule
from employees.models import Employee
@ -43,10 +44,18 @@ def authenticated_client(api_client, test_user):
@pytest.fixture
def test_calendar(test_user):
"""Создание тестового календаря."""
return Calendar.objects.create(
calendar = Calendar.objects.create(
name="Тестовый календарь",
slug="test-calendar"
)
CalendarRelation.objects.create(
calendar=calendar,
content_type=ContentType.objects.get_for_model(Employee),
object_id=test_user.id,
distinction="owner",
inheritable=True,
)
return calendar
@pytest.fixture

View File

@ -35,6 +35,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": (

View File

@ -31,8 +31,6 @@ class DeptPerm:
MANAGE = "manage_department"
CHANGE_HEAD = "change_department_head"
ASSIGN_ROLE = "assign_department_role"
MANAGE_CALENDAR = "manage_department_events"
CREATE_POST = "publish_department_post"
MANAGE_FEED = "manage_department_feed"
VIEW_REQUESTCOMMENT = "view_requestcomment"
ADD_REQUESTCOMMENT = "add_requestcomment"
@ -44,8 +42,6 @@ class DeptPerm:
(MANAGE, "Управлять отделом"),
(CHANGE_HEAD, "Назначать руководителя"),
(ASSIGN_ROLE, "Назначать роли участникам"),
(MANAGE_CALENDAR, "Управлять календарём отдела"),
(CREATE_POST, "Публиковать новости на странице отдела"),
(MANAGE_FEED, "Редактировать публикации отдела"),
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.4 on 2026-04-16 11:09
from django.db import migrations, models
def remove_obsolete_department_permissions(apps, schema_editor):
DepartmentPermission = apps.get_model("employees", "DepartmentPermission")
DepartmentPermission.objects.filter(
code__in=["manage_department_events", "publish_department_post"]
).delete()
class Migration(migrations.Migration):
dependencies = [
('employees', '0046_alter_ldapsyncqueue_operation'),
]
operations = [
migrations.AlterField(
model_name='departmentpermission',
name='code',
field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям'), ('view_request', 'Просмотр заявлений отдела'), ('can_process_requests', 'Рассмотрение заявлений отдела'), ('manage_department_equipment', 'Управлять оборудованием отдела')], max_length=64, unique=True),
),
migrations.RunPython(
remove_obsolete_department_permissions,
migrations.RunPython.noop,
),
]

View File

@ -17,6 +17,14 @@ from .constants import (
)
def _sync_department_calendar_after_commit(department_id: int) -> None:
from scheduling.services import (
sync_department_calendar_binding_by_department_id,
)
sync_department_calendar_binding_by_department_id(department_id)
class DateRangeMixin(models.Model):
class Meta:
abstract = True
@ -459,6 +467,12 @@ class Department(models.Model):
},
)
transaction.on_commit(
lambda department_id=self.pk: _sync_department_calendar_after_commit(
department_id
)
)
@property
def active_employees(self):
"""

View File

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

View File

@ -0,0 +1,189 @@
from collections import defaultdict
from django.db import migrations, models
import django.db.models.deletion
def backfill_calendar_bindings(apps, schema_editor):
Calendar = apps.get_model("schedule", "Calendar")
CalendarBinding = apps.get_model("scheduling", "CalendarBinding")
ContentType = apps.get_model("contenttypes", "ContentType")
Department = apps.get_model("employees", "Department")
department_ct, _ = ContentType.objects.get_or_create(
app_label="employees",
model="department",
)
department_ids = set(Department.objects.values_list("id", flat=True))
table_names = set(schema_editor.connection.introspection.table_names())
legacy_department_map = {}
if "calendar_app_calendar" in table_names:
with schema_editor.connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, owner_department_id
FROM calendar_app_calendar
WHERE owner_department_id IS NOT NULL
"""
)
legacy_department_map = {
old_id: department_id
for old_id, department_id in cursor.fetchall()
if department_id in department_ids
}
candidates = defaultdict(list)
for calendar in Calendar.objects.all().order_by("-id"):
slug = calendar.slug or ""
department_id = None
priority = None
if slug.startswith("legacy-calendar-"):
try:
legacy_id = int(slug.removeprefix("legacy-calendar-"))
except ValueError:
legacy_id = None
if legacy_id is not None:
department_id = legacy_department_map.get(legacy_id)
priority = 0 if department_id is not None else None
elif slug.startswith("department-"):
tail = slug.removeprefix("department-")
if tail.isdigit():
department_id = int(tail)
if department_id in department_ids:
priority = 1
if department_id is None or priority is None:
continue
candidates[department_id].append((priority, calendar.id))
for department_id, rows in candidates.items():
priority, calendar_id = sorted(rows, key=lambda item: (item[0], -item[1]))[0]
CalendarBinding.objects.get_or_create(
calendar_id=calendar_id,
defaults={
"type": "department",
"context_content_type_id": department_ct.id,
"context_object_id": department_id,
},
)
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("employees", "0047_alter_departmentpermission_code"),
("schedule", "0015_rename_indexes"),
("scheduling", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="CalendarBinding",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"type",
models.CharField(
choices=[
("default", "Обычный"),
("department", "Отдел"),
],
db_index=True,
default="default",
max_length=32,
verbose_name="Тип привязки",
),
),
(
"context_object_id",
models.PositiveIntegerField(
blank=True,
null=True,
verbose_name="ID объекта контекста",
),
),
(
"flags",
models.JSONField(
blank=True,
default=dict,
verbose_name="Флаги",
),
),
(
"extra_data",
models.JSONField(
blank=True,
default=dict,
verbose_name="Доп. данные",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"calendar",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="binding",
to="schedule.calendar",
verbose_name="Календарь",
),
),
(
"context_content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
verbose_name="Тип контекста",
),
),
],
options={
"verbose_name": "Привязка календаря",
"verbose_name_plural": "Привязки календарей",
},
),
migrations.AddIndex(
model_name="calendarbinding",
index=models.Index(
fields=["type"], name="calendar_binding_type_idx"
),
),
migrations.AddIndex(
model_name="calendarbinding",
index=models.Index(
fields=["context_content_type", "context_object_id"],
name="calendar_binding_context_idx",
),
),
migrations.AddConstraint(
model_name="calendarbinding",
constraint=models.UniqueConstraint(
condition=models.Q(
("context_content_type__isnull", False),
("context_object_id__isnull", False),
("type", "department"),
),
fields=("type", "context_content_type", "context_object_id"),
name="uniq_department_calendar_binding",
),
),
migrations.RunPython(
backfill_calendar_bindings,
migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,72 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
class CalendarBinding(models.Model):
class BindingType(models.TextChoices):
DEFAULT = "default", "Обычный"
DEPARTMENT = "department", "Отдел"
calendar = models.OneToOneField(
"schedule.Calendar",
on_delete=models.CASCADE,
related_name="binding",
verbose_name="Календарь",
)
type = models.CharField(
max_length=32,
choices=BindingType.choices,
default=BindingType.DEFAULT,
db_index=True,
verbose_name="Тип привязки",
)
context_content_type = models.ForeignKey(
ContentType,
null=True,
blank=True,
on_delete=models.CASCADE,
verbose_name="Тип контекста",
)
context_object_id = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name="ID объекта контекста",
)
context_object = GenericForeignKey(
"context_content_type",
"context_object_id",
)
flags = models.JSONField(default=dict, blank=True, verbose_name="Флаги")
extra_data = models.JSONField(
default=dict, blank=True, verbose_name="Доп. данные"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Привязка календаря"
verbose_name_plural = "Привязки календарей"
constraints = [
models.UniqueConstraint(
fields=["type", "context_content_type", "context_object_id"],
condition=Q(
type="department",
context_content_type__isnull=False,
context_object_id__isnull=False,
),
name="uniq_department_calendar_binding",
),
]
indexes = [
models.Index(fields=["type"], name="calendar_binding_type_idx"),
models.Index(
fields=["context_content_type", "context_object_id"],
name="calendar_binding_context_idx",
),
]
def __str__(self) -> str:
target = self.context_object or self.calendar
return f"{self.type}: {target}"

View File

@ -0,0 +1,378 @@
from __future__ import annotations
from collections import defaultdict
from typing import Iterable
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from employees.models import Department, EmployeeDepartment, RoleAssignment
from schedule.models import Calendar, CalendarRelation, Event
from .models import CalendarBinding
User = get_user_model()
def get_calendar_binding(calendar: Calendar | None) -> CalendarBinding | None:
if calendar is None:
return None
binding = getattr(calendar, "binding", None)
if binding is not None:
return binding
return (
CalendarBinding.objects.select_related("context_content_type")
.filter(calendar=calendar)
.first()
)
def is_department_calendar(calendar: Calendar | None) -> bool:
binding = get_calendar_binding(calendar)
return bool(
binding and binding.type == CalendarBinding.BindingType.DEPARTMENT
)
def get_department_calendar_binding(
department: Department,
) -> CalendarBinding | None:
dept_ct = ContentType.objects.get_for_model(Department)
return (
CalendarBinding.objects.select_related("calendar")
.filter(
type=CalendarBinding.BindingType.DEPARTMENT,
context_content_type=dept_ct,
context_object_id=department.id,
)
.first()
)
def department_calendar_name(department: Department) -> str:
return 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)

View File

@ -0,0 +1,35 @@
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from employees.models import Department, EmployeeDepartment, RoleAssignment
from .services import delete_department_calendar, sync_department_calendar_if_bound
@receiver(post_delete, sender=Department)
def delete_department_calendar_on_department_delete(sender, instance, **kwargs):
delete_department_calendar(instance.id)
@receiver(post_save, sender=EmployeeDepartment)
def sync_department_calendar_on_member_save(sender, instance, **kwargs):
sync_department_calendar_if_bound(instance.department)
@receiver(post_delete, sender=EmployeeDepartment)
def sync_department_calendar_on_member_delete(sender, instance, **kwargs):
sync_department_calendar_if_bound(instance.department)
@receiver(post_save, sender=RoleAssignment)
def sync_department_calendar_on_role_assignment_save(
sender, instance, **kwargs
):
sync_department_calendar_if_bound(instance.role.department)
@receiver(post_delete, sender=RoleAssignment)
def sync_department_calendar_on_role_assignment_delete(
sender, instance, **kwargs
):
sync_department_calendar_if_bound(instance.role.department)

View File

@ -0,0 +1,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()

View File

@ -173,6 +173,8 @@ def test_perm_choices_and_perms_and_set_perms(api_client: APIClient):
assert resp.status_code == 200
codes = {row["code"] for row in resp.json().get("results", [])}
assert {"manage_department", "change_department_head", "assign_department_role"}.issubset(codes)
assert "manage_department_events" not in codes
assert "publish_department_post" not in codes
# set_perms (codes)
url_set = reverse("api:v1:department-roles-set-perms", args=[role.id])

View File

@ -4,10 +4,8 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from employees.constants import DeptPerm
from employees.models import (
Department,
DepartmentPermission,
DepartmentRole,
EmployeeDepartment,
RoleAssignment,
@ -60,27 +58,6 @@ def test_department_user_perms_includes_feed_flags_for_head(api_client: APIClien
assert resp.json()["can_manage_feed"] is True
@pytest.mark.django_db
def test_department_user_perms_resolves_role_based_feed_rights(api_client: APIClient):
employee = make_user()
dept = Department.objects.create(name="Закупки")
role = DepartmentRole.objects.create(department=dept, name="Редактор ленты")
publish_perm, _ = DepartmentPermission.objects.get_or_create(
code=DeptPerm.CREATE_POST,
defaults={"name": "Публиковать новости на странице отдела"},
)
role.scoped_permissions.add(publish_perm)
RoleAssignment.objects.create(employee=employee, role=role, is_active=True)
api_client.force_authenticate(user=employee)
url = reverse("api:v1:departments-user-perms", args=[dept.id])
resp = api_client.get(url)
assert resp.status_code == status.HTTP_200_OK
assert resp.json()["can_publish_posts"] is True
assert resp.json()["can_manage_feed"] is False
@pytest.mark.django_db
def test_department_user_perms_allows_active_member_to_publish(api_client: APIClient):
employee = make_user()

View File

@ -2,9 +2,8 @@ from __future__ import annotations
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from employees.constants import DeptPerm
from employees.models import DepartmentPermission
from employees.models import (
DepartmentPermission,
DepartmentRole,
EmployeeDepartment,
RoleAssignment,
@ -28,14 +27,14 @@ def _department_post_payload(department_id: int, **extra):
return payload
def _grant_department_post_role(user, department):
def _grant_department_feed_role(user, department):
permission, _ = DepartmentPermission.objects.get_or_create(
code=DeptPerm.CREATE_POST,
defaults={"name": "Публиковать новости на странице отдела"},
code="manage_department_feed",
defaults={"name": "Редактировать публикации отдела"},
)
role = DepartmentRole.objects.create(
department=department,
name=f"Publisher {user.id}",
name=f"Feed manager {user.id}",
)
role.scoped_permissions.add(permission)
RoleAssignment.objects.create(
@ -197,26 +196,6 @@ def test_active_department_member_can_create_department_post(
assert Post.objects.filter(id=response.data["id"], department=department).exists()
def test_role_assignment_can_create_department_post(
auth_client_factory,
make_user,
department_factory,
):
user = make_user("publisher@example.com")
department = department_factory(name="Accounting")
_grant_department_post_role(user, department)
client = auth_client_factory(user)
response = client.post(
API_POSTS_URL,
_department_post_payload(department.id),
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert Post.objects.filter(id=response.data["id"], department=department).exists()
def test_any_role_assignment_can_create_department_post(
auth_client_factory,
make_user,
@ -245,6 +224,85 @@ def test_any_role_assignment_can_create_department_post(
assert Post.objects.filter(id=response.data["id"], department=department).exists()
def test_department_post_patch_requires_manage_feed_permission(
auth_client_factory,
make_user,
department_factory,
):
author = make_user("author-edit@example.com")
editor = make_user("editor@example.com")
member_without_perm = make_user("member-without-feed@example.com")
department = department_factory(name="Accounting", head=author)
post = Post.objects.create(
author=author,
department=department,
type="department",
title="До редактирования",
body="body",
)
EmployeeDepartment.objects.create(
employee=member_without_perm,
department=department,
is_active=True,
)
_grant_department_feed_role(editor, department)
member_client = auth_client_factory(member_without_perm)
response = member_client.patch(
f"{API_POSTS_URL}{post.id}/",
{"title": "Не должно пройти"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
editor_client = auth_client_factory(editor)
response = editor_client.patch(
f"{API_POSTS_URL}{post.id}/",
{"title": "После редактирования"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
post.refresh_from_db()
assert post.title == "После редактирования"
def test_department_post_delete_requires_manage_feed_permission(
auth_client_factory,
make_user,
department_factory,
):
author = make_user("author-delete@example.com")
editor = make_user("feed-editor@example.com")
member_without_perm = make_user("member-without-delete@example.com")
department = department_factory(name="Finance", head=author)
post = Post.objects.create(
author=author,
department=department,
type="department",
title="Удаляемый пост",
body="body",
)
EmployeeDepartment.objects.create(
employee=member_without_perm,
department=department,
is_active=True,
)
_grant_department_feed_role(editor, department)
member_client = auth_client_factory(member_without_perm)
response = member_client.delete(f"{API_POSTS_URL}{post.id}/")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert Post.objects.filter(id=post.id).exists()
editor_client = auth_client_factory(editor)
response = editor_client.delete(f"{API_POSTS_URL}{post.id}/")
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Post.objects.filter(id=post.id).exists()
def test_list_department_posts_filtered_by_department(
auth_client_factory,
make_user,

View File

@ -0,0 +1,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

View File

@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject }
import {
ArrowLeft,
BadgePlus,
CalendarDays,
Check,
ChevronDown,
ChevronRight,
@ -760,6 +761,7 @@ export default function DepartmentDetailPage() {
const [departmentMenuOpen, setDepartmentMenuOpen] = useState(false);
const departmentMenuRef = useRef<HTMLDivElement | null>(null);
const [openingDepartmentChat, setOpeningDepartmentChat] = useState(false);
const [openingDepartmentCalendar, setOpeningDepartmentCalendar] = useState(false);
const activeMembersCount = h.members.filter((member) => member.is_active).length;
const roleOptions = h.roles.map((role) => ({ id: role.id, name: role.name }));
@ -838,6 +840,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>

View File

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

View File

@ -98,6 +98,8 @@ export function ViewEventDetailsModal({
const capitalizedStart = formatDateTime(event.start).charAt(0).toUpperCase() + formatDateTime(event.start).slice(1);
const capitalizedEnd = formatDateTime(event.end).charAt(0).toUpperCase() + formatDateTime(event.end).slice(1);
const canEdit = event.can_edit !== false;
const canDelete = event.can_delete !== false;
return (
<Modal
@ -105,24 +107,28 @@ export function ViewEventDetailsModal({
onClose={onClose}
title={event.title}
size="sm"
footer={
footer={canEdit || canDelete ? (
<div className="flex gap-2">
<button
onClick={onEdit}
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
>
<Edit2 size={16} />
Редактировать
</button>
<button
onClick={onDelete}
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
{canEdit ? (
<button
onClick={onEdit}
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
>
<Edit2 size={16} />
Редактировать
</button>
) : null}
{canDelete ? (
<button
onClick={onDelete}
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
title="Удалить событие"
>
<Trash2 size={16} />
</button>
) : null}
</div>
}
) : undefined}
>
<div className="space-y-4">
{/* Event meta */}

View File

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

View File

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

View File

@ -71,6 +71,8 @@ export function createEmployeesApi(request: RequestFn) {
request(`/api/v1/departments/${id}/members/`),
getDepartmentDiscussionChat: (id: number | string) =>
request(`/api/v1/departments/${id}/discussion-chat/`),
getDepartmentCalendar: (id: number | string) =>
request(`/api/v1/departments/${id}/calendar/`),
getDepartmentUserPerms: (id: number | string) =>
request(`/api/v1/departments/${id}/user-perms/`),
setDepartmentHead: (id: number | string, head_id: number | null) =>

View File

@ -19,6 +19,8 @@ export interface CalendarEvent {
event_id?: number;
description?: string;
calendar?: number;
can_edit?: boolean;
can_delete?: boolean;
}
interface EventsCache {

View File

@ -716,6 +716,8 @@ export interface CalendarEvent {
creator?: number | null;
created_on?: string;
updated_on?: string;
can_edit?: boolean;
can_delete?: boolean;
}
export interface Calendar {
@ -723,6 +725,14 @@ export interface Calendar {
name: string; // django-scheduler использует name (не title)
slug: string;
events_count?: number;
type?: string;
context_object_id?: number | null;
context_type?: string | null;
flags?: Record<string, any>;
user_role?: string | null;
can_create_events?: boolean;
can_edit_calendar?: boolean;
can_manage_participants?: boolean;
}
// Notifications types (v2 API)