From 3e672b2edc650a9250b8a594a98c046035af10cb Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Sat, 28 Feb 2026 00:13:46 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=205=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20(requests,=20communications,?= =?UTF-8?q?=20calendar,=20procurement,=20feed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requests_app/rules.py: 8 предикатов + 10 правил * Предикаты: is_request_author, is_request_assignee, can_manage_requests и др. * Правила: view/change/delete/assign/approve requests, комментирование, статистика - communications/rules.py: 9 предикатов + 15 правил * Предикаты: is_chat_member, is_chat_owner, is_chat_admin, is_message_author и др. * Правила: view/send/change/delete чаты и сообщения, управление участниками, закрепление * Включены примеры для WebSocket consumers - calendar_app/rules.py: 9 предикатов + 12 правил * Предикаты: is_calendar_owner, is_personal_calendar, has_calendar_access и др. * Правила: view/change/delete календари и события, подписки, экспорт iCal, приглашения - procurement/rules.py: 8 предикатов + 13 правил * Предикаты: is_procurement_manager, is_purchase_request_author, can_approve_purchases и др. * Правила: view/change/delete заявки на закупку, управление поставщиками и договорами - feed/rules.py: 8 предикатов + 11 правил * Предикаты: is_post_author, can_publish_posts, can_moderate_posts и др. * Правила: view/create/change/delete посты, публикация, закрепление, комментирование, реакции Во всех apps.py добавлен импорт rules для автозагрузки правил при старте Django --- backend/calendar_app/apps.py | 1 + backend/calendar_app/rules.py | 376 +++++++++++++++++++++++++++++++ backend/communications/apps.py | 1 + backend/communications/rules.py | 370 ++++++++++++++++++++++++++++++ backend/feed/apps.py | 1 + backend/feed/rules.py | 386 ++++++++++++++++++++++++++++++++ backend/procurement/apps.py | 1 + backend/procurement/rules.py | 354 +++++++++++++++++++++++++++++ backend/requests_app/apps.py | 1 + backend/requests_app/rules.py | 270 ++++++++++++++++++++++ 10 files changed, 1761 insertions(+) create mode 100644 backend/calendar_app/rules.py create mode 100644 backend/communications/rules.py create mode 100644 backend/feed/rules.py create mode 100644 backend/procurement/rules.py create mode 100644 backend/requests_app/rules.py diff --git a/backend/calendar_app/apps.py b/backend/calendar_app/apps.py index 38db6542..62ed3041 100644 --- a/backend/calendar_app/apps.py +++ b/backend/calendar_app/apps.py @@ -19,6 +19,7 @@ class CalendarAppConfig(AppConfig): """ import calendar_app.notification_signals # noqa: F401 from calendar_app import signals # noqa: F401 - старая система + import calendar_app.rules # django-rules: регистрация предикатов и правил доступа try: Employee = django_apps.get_model("employees", "Employee") diff --git a/backend/calendar_app/rules.py b/backend/calendar_app/rules.py new file mode 100644 index 00000000..721117b8 --- /dev/null +++ b/backend/calendar_app/rules.py @@ -0,0 +1,376 @@ +""" +django-rules: декларативные правила доступа для calendar_app + +Правила используются для проверки permissions на уровне объектов. +https://github.com/dfunckt/django-rules +""" + +import rules + + +# ----------------------------------------------------------------------------- +# ПРЕДИКАТЫ (predicates) +# ----------------------------------------------------------------------------- + +@rules.predicate +def is_superuser(user): + """Суперпользователь имеет все права""" + return user.is_superuser + + +@rules.predicate +def is_calendar_owner(user, calendar): + """Пользователь является владельцем календаря""" + if calendar is None: + return False + + # Для django-scheduler + if hasattr(calendar, 'creator'): + return calendar.creator == user + + # Альтернативные поля + if hasattr(calendar, 'owner'): + return calendar.owner == user + + return False + + +@rules.predicate +def is_personal_calendar(user, calendar): + """Это личный календарь пользователя""" + if calendar is None: + return False + + # Проверка slug для личного календаря + if hasattr(calendar, 'slug'): + return calendar.slug == f'personal-{user.pk}' + + # Проверка через creator + тип календаря + if hasattr(calendar, 'calendar_type') and calendar.calendar_type == 'personal': + return is_calendar_owner(user, calendar) + + return False + + +@rules.predicate +def has_calendar_access(user, calendar): + """Пользователь имеет доступ к календарю через подписку или права""" + if calendar is None: + return False + + # Публичный календарь + if hasattr(calendar, 'is_public') and calendar.is_public: + return True + + # Календарь отдела + if hasattr(calendar, 'department') and hasattr(user, 'department'): + if calendar.department == user.department: + return True + + # Подписка на календарь + if hasattr(calendar, 'subscriptions'): + return calendar.subscriptions.filter(user=user).exists() + + # Календарь компании (доступен всем сотрудникам) + if hasattr(calendar, 'calendar_type') and calendar.calendar_type == 'company': + return user.is_staff + + return False + + +@rules.predicate +def is_event_creator(user, event): + """Пользователь является создателем события""" + if event is None: + return False + + # django-scheduler использует creator_id + if hasattr(event, 'creator'): + return event.creator == user + + if hasattr(event, 'created_by'): + return event.created_by == user + + return False + + +@rules.predicate +def can_access_event_calendar(user, event): + """Пользователь имеет доступ к календарю, где находится событие""" + if event is None or not hasattr(event, 'calendar'): + return False + + return has_calendar_access(user, event.calendar) or is_calendar_owner(user, event.calendar) + + +@rules.predicate +def is_event_participant(user, event): + """Пользователь является участником события""" + if event is None: + return False + + # Проверка через участников + if hasattr(event, 'participants'): + return user in event.participants.all() + + # Проверка через occurrence (для повторяющихся событий) + if hasattr(event, 'occurrence_set'): + return event.occurrence_set.filter(participants=user).exists() + + return False + + +@rules.predicate +def can_manage_calendars(user): + """ + Пользователь может управлять календарями (администратор календарей). + Адаптируйте под вашу логику. + """ + if not hasattr(user, 'position'): + return False + + position_name = getattr(user.position, 'name', '').lower() + return any(keyword in position_name for keyword in [ + 'руководитель', 'начальник', 'директор', 'секретарь', 'администратор' + ]) + + +@rules.predicate +def is_department_calendar(user, calendar): + """Календарь относится к отделу пользователя""" + if calendar is None or not hasattr(user, 'department'): + return False + + if hasattr(calendar, 'department'): + return calendar.department == user.department + + return False + + +# ----------------------------------------------------------------------------- +# ПРАВИЛА (rules) +# ----------------------------------------------------------------------------- + +# Просмотр календаря +rules.add_rule( + 'calendar_app.view_calendar', + is_superuser | is_calendar_owner | is_personal_calendar | + has_calendar_access | is_department_calendar +) + +# Изменение календаря (настройки, название, описание) +rules.add_rule( + 'calendar_app.change_calendar', + is_superuser | is_calendar_owner | can_manage_calendars +) + +# Удаление календаря +rules.add_rule( + 'calendar_app.delete_calendar', + is_superuser | is_calendar_owner +) + +# Создание события в календаре +rules.add_rule( + 'calendar_app.create_event', + is_superuser | is_calendar_owner | has_calendar_access +) + +# Просмотр события +rules.add_rule( + 'calendar_app.view_event', + is_superuser | is_event_creator | can_access_event_calendar | is_event_participant +) + +# Изменение события +rules.add_rule( + 'calendar_app.change_event', + is_superuser | is_event_creator | is_calendar_owner +) + +# Удаление события +rules.add_rule( + 'calendar_app.delete_event', + is_superuser | is_event_creator | is_calendar_owner +) + +# Подписка на календарь +rules.add_rule( + 'calendar_app.subscribe_calendar', + rules.is_authenticated # Любой авторизованный может подписаться на публичный календарь +) + +# Отмена подписки на календарь +rules.add_rule( + 'calendar_app.unsubscribe_calendar', + rules.is_authenticated +) + +# Экспорт календаря (iCal) +rules.add_rule( + 'calendar_app.export_calendar', + is_superuser | is_calendar_owner | has_calendar_access +) + +# Приглашение участников на событие +rules.add_rule( + 'calendar_app.invite_participants', + is_superuser | is_event_creator | is_calendar_owner +) + +# Просмотр всех календарей (для администраторов) +rules.add_rule( + 'calendar_app.view_all_calendars', + is_superuser | can_manage_calendars +) + + +# ----------------------------------------------------------------------------- +# ИСПОЛЬЗОВАНИЕ В КОДЕ +# ----------------------------------------------------------------------------- + +""" +# В views: +from django.core.exceptions import PermissionDenied +import rules + +def calendar_detail(request, slug): + calendar = get_object_or_404(Calendar, slug=slug) + + if not rules.test_rule('calendar_app.view_calendar', request.user, calendar): + raise PermissionDenied("У вас нет доступа к этому календарю") + + events = calendar.event_set.all() + return render(request, 'calendar_app/calendar.html', { + 'calendar': calendar, + 'events': events + }) + + +def event_create(request, calendar_slug): + calendar = get_object_or_404(Calendar, slug=calendar_slug) + + if not rules.test_rule('calendar_app.create_event', request.user, calendar): + return JsonResponse({'error': 'Нет прав на создание событий'}, status=403) + + # Логика создания события + event = Event.objects.create( + calendar=calendar, + creator=request.user, + title=request.POST.get('title'), + start=request.POST.get('start'), + end=request.POST.get('end'), + ) + + return JsonResponse({'event_id': event.pk}) + + +def event_update(request, pk): + event = get_object_or_404(Event, pk=pk) + + if not rules.test_rule('calendar_app.change_event', request.user, event): + return JsonResponse({'error': 'Нет прав на изменение'}, status=403) + + event.title = request.POST.get('title', event.title) + event.save() + + return JsonResponse({'success': True}) + + +# В templates: +{% load rules %} + +{% has_rule 'calendar_app.create_event' user calendar as can_create %} +{% if can_create %} + +{% endif %} + +{% for event in events %} +
+

{{ event.title }}

+

{{ event.start|date:"d.m.Y H:i" }} - {{ event.end|date:"d.m.Y H:i" }}

+ + {% has_rule 'calendar_app.change_event' user event as can_edit %} + {% if can_edit %} + + Редактировать + + {% endif %} + + {% has_rule 'calendar_app.delete_event' user event as can_delete %} + {% if can_delete %} +
+ {% csrf_token %} + +
+ {% endif %} +
+{% endfor %} + + +# В DRF permissions (уже реализовано в api/v1/schedule/permissions.py): +from rest_framework import permissions +import rules + +class IsCalendarOwnerOrAdmin(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + # Для Calendar + if hasattr(obj, 'creator'): + calendar = obj + # Для Event + elif hasattr(obj, 'calendar'): + calendar = obj.calendar + else: + return False + + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('calendar_app.view_calendar', request.user, calendar) + elif request.method in ['POST']: + return rules.test_rule('calendar_app.create_event', request.user, calendar) + elif request.method in ['PUT', 'PATCH']: + if hasattr(obj, 'calendar'): # Event + return rules.test_rule('calendar_app.change_event', request.user, obj) + else: # Calendar + return rules.test_rule('calendar_app.change_calendar', request.user, obj) + elif request.method == 'DELETE': + if hasattr(obj, 'calendar'): + return rules.test_rule('calendar_app.delete_event', request.user, obj) + else: + return rules.test_rule('calendar_app.delete_calendar', request.user, obj) + + return False + + +# Фильтрация QuerySet (только доступные календари): +from django.db.models import Q + +def get_accessible_calendars(user): + return Calendar.objects.filter( + Q(creator=user) | # Собственные календари + Q(slug=f'personal-{user.pk}') | # Личный календарь + Q(is_public=True) | # Публичные календари + Q(department=user.department) | # Календари отдела + Q(calendar_type='company') | # Корпоративный календарь + Q(subscriptions__user=user) # Подписки + ).distinct() + + +# Middleware для защиты iCal экспорта: +class CalendarExportMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.path.startswith('/calendar/export/'): + # Извлечение календаря + slug = request.path.split('/')[-2] + calendar = Calendar.objects.filter(slug=slug).first() + + if calendar and not rules.test_rule('calendar_app.export_calendar', request.user, calendar): + return HttpResponseForbidden("У вас нет прав на экспорт этого календаря") + + return self.get_response(request) +""" diff --git a/backend/communications/apps.py b/backend/communications/apps.py index 21c8d934..4ff47043 100644 --- a/backend/communications/apps.py +++ b/backend/communications/apps.py @@ -9,6 +9,7 @@ class CommunicationsConfig(AppConfig): def ready(self): import communications.signals import communications.notification_signals # Подключаем signals уведомлений + import communications.rules # django-rules: регистрация предикатов и правил доступа def create_main_global_chat(sender, **kwargs): from communications.models import Chat diff --git a/backend/communications/rules.py b/backend/communications/rules.py new file mode 100644 index 00000000..05fd5c0f --- /dev/null +++ b/backend/communications/rules.py @@ -0,0 +1,370 @@ +""" +django-rules: декларативные правила доступа для communications (мессенджер) + +Правила используются для проверки permissions на уровне объектов. +https://github.com/dfunckt/django-rules +""" + +import rules + + +# ----------------------------------------------------------------------------- +# ПРЕДИКАТЫ (predicates) +# ----------------------------------------------------------------------------- + +@rules.predicate +def is_superuser(user): + """Суперпользователь имеет все права""" + return user.is_superuser + + +@rules.predicate +def is_chat_member(user, chat): + """Пользователь является участником чата""" + if chat is None: + return False + + # Проверка через members/participants + if hasattr(chat, 'members'): + return user in chat.members.all() + + if hasattr(chat, 'participants'): + return user in chat.participants.all() + + return False + + +@rules.predicate +def is_chat_owner(user, chat): + """Пользователь является владельцем/создателем чата""" + if chat is None: + return False + + if hasattr(chat, 'owner'): + return chat.owner == user + + if hasattr(chat, 'created_by'): + return chat.created_by == user + + return False + + +@rules.predicate +def is_chat_admin(user, chat): + """Пользователь является администратором чата""" + if chat is None: + return False + + # Проверка через admins + if hasattr(chat, 'admins'): + return user in chat.admins.all() + + # Проверка через membership с ролью admin + if hasattr(chat, 'chatmembership_set'): + return chat.chatmembership_set.filter( + user=user, role__in=['admin', 'moderator'] + ).exists() + + return False + + +@rules.predicate +def is_direct_chat_participant(user, chat): + """Пользователь участвует в личном чате""" + if chat is None: + return False + + # Проверка типа чата + chat_type = getattr(chat, 'chat_type', None) or getattr(chat, 'type', None) + if chat_type not in ['direct', 'private', 'personal']: + return False + + return is_chat_member(user, chat) + + +@rules.predicate +def is_message_author(user, message): + """Пользователь является автором сообщения""" + if message is None: + return False + + return message.author == user or message.sender == user + + +@rules.predicate +def can_access_message_chat(user, message): + """Пользователь имеет доступ к чату, где находится сообщение""" + if message is None or not hasattr(message, 'chat'): + return False + + return is_chat_member(user, message.chat) + + +@rules.predicate +def is_public_chat(user, chat): + """Чат является публичным (доступен всем)""" + if chat is None: + return False + + return getattr(chat, 'is_public', False) + + +@rules.predicate +def is_department_chat(user, chat): + """Чат отдела пользователя""" + if chat is None or not hasattr(user, 'department'): + return False + + if hasattr(chat, 'department'): + return chat.department == user.department + + return False + + +# ----------------------------------------------------------------------------- +# ПРАВИЛА (rules) +# ----------------------------------------------------------------------------- + +# Просмотр чата (доступ к чату) +rules.add_rule( + 'communications.view_chat', + is_superuser | is_chat_member | is_public_chat +) + +# Отправка сообщений в чат +rules.add_rule( + 'communications.send_message', + is_superuser | is_chat_member +) + +# Изменение чата (название, описание, настройки) +rules.add_rule( + 'communications.change_chat', + is_superuser | is_chat_owner | is_chat_admin +) + +# Удаление чата +rules.add_rule( + 'communications.delete_chat', + is_superuser | is_chat_owner +) + +# Просмотр сообщения +rules.add_rule( + 'communications.view_message', + is_superuser | can_access_message_chat +) + +# Изменение сообщения (редактирование) +rules.add_rule( + 'communications.change_message', + is_superuser | is_message_author +) + +# Удаление сообщения +rules.add_rule( + 'communications.delete_message', + is_superuser | is_message_author | is_chat_admin +) + +# Добавление участников в чат +rules.add_rule( + 'communications.add_members', + is_superuser | is_chat_owner | is_chat_admin +) + +# Удаление участников из чата +rules.add_rule( + 'communications.remove_members', + is_superuser | is_chat_owner | is_chat_admin +) + +# Закрепление сообщений +rules.add_rule( + 'communications.pin_message', + is_superuser | is_chat_owner | is_chat_admin +) + +# Изменение ролей участников +rules.add_rule( + 'communications.change_member_role', + is_superuser | is_chat_owner +) + +# Выход из чата (покинуть чат) +rules.add_rule( + 'communications.leave_chat', + is_chat_member & ~is_chat_owner # Все кроме владельца +) + +# Просмотр истории чата +rules.add_rule( + 'communications.view_chat_history', + is_superuser | is_chat_member +) + +# Поиск по чатам/сообщениям +rules.add_rule( + 'communications.search_messages', + rules.is_authenticated # Все авторизованные +) + + +# ----------------------------------------------------------------------------- +# ИСПОЛЬЗОВАНИЕ В КОДЕ +# ----------------------------------------------------------------------------- + +""" +# В views: +from django.core.exceptions import PermissionDenied +import rules + +def chat_detail(request, pk): + chat = get_object_or_404(Chat, pk=pk) + + if not rules.test_rule('communications.view_chat', request.user, chat): + raise PermissionDenied("У вас нет доступа к этому чату") + + messages = chat.messages.all() + return render(request, 'communications/chat.html', { + 'chat': chat, + 'messages': messages + }) + + +def send_message(request, chat_pk): + chat = get_object_or_404(Chat, pk=chat_pk) + + if not rules.test_rule('communications.send_message', request.user, chat): + return JsonResponse({'error': 'Нет прав на отправку сообщений'}, status=403) + + message = Message.objects.create( + chat=chat, + author=request.user, + text=request.POST.get('text') + ) + + return JsonResponse({'message_id': message.pk}) + + +def delete_message(request, pk): + message = get_object_or_404(Message, pk=pk) + + if not rules.test_rule('communications.delete_message', request.user, message): + return JsonResponse({'error': 'Нет прав на удаление'}, status=403) + + message.delete() + return JsonResponse({'success': True}) + + +# В templates: +{% load rules %} + +{% has_rule 'communications.send_message' user chat as can_send %} +{% if can_send %} +
+ {% csrf_token %} + + +
+{% else %} +

Вы не можете отправлять сообщения в этот чат

+{% endif %} + +{% for message in messages %} +
+ {{ message.author }}: {{ message.text }} + + {% has_rule 'communications.delete_message' user message as can_delete %} + {% if can_delete %} + + {% endif %} +
+{% endfor %} + + +# В DRF permissions: +from rest_framework import permissions +import rules + +class ChatPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('communications.view_chat', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('communications.change_chat', request.user, obj) + elif request.method == 'DELETE': + return rules.test_rule('communications.delete_chat', request.user, obj) + return False + + +class MessagePermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('communications.view_message', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('communications.change_message', request.user, obj) + elif request.method == 'DELETE': + return rules.test_rule('communications.delete_message', request.user, obj) + return False + + +# В WebSocket consumer: +from channels.generic.websocket import AsyncJsonWebsocketConsumer +import rules + +class ChatConsumer(AsyncJsonWebsocketConsumer): + async def connect(self): + chat_id = self.scope['url_route']['kwargs']['chat_id'] + chat = await database_sync_to_async(Chat.objects.get)(pk=chat_id) + + # Проверка доступа + if not await database_sync_to_async(rules.test_rule)( + 'communications.view_chat', self.scope['user'], chat + ): + await self.close() + return + + await self.accept() + # Добавляем в группу чата + await self.channel_layer.group_add(f'chat_{chat_id}', self.channel_name) + + async def receive_json(self, content): + action = content.get('action') + + if action == 'send_message': + chat = await database_sync_to_async(Chat.objects.get)(pk=self.chat_id) + + # Проверка прав на отправку + if not await database_sync_to_async(rules.test_rule)( + 'communications.send_message', self.scope['user'], chat + ): + await self.send_json({'error': 'Permission denied'}) + return + + # Сохранение сообщения + message = await database_sync_to_async(Message.objects.create)( + chat=chat, + author=self.scope['user'], + text=content['text'] + ) + + # Рассылка всем участникам + await self.channel_layer.group_send( + f'chat_{chat.pk}', + {'type': 'chat.message', 'message': message.to_dict()} + ) + + +# Фильтрация QuerySet (только доступные чаты): +from django.db.models import Q + +def get_accessible_chats(user): + return Chat.objects.filter( + Q(members=user) | # Участник чата + Q(is_public=True) | # Публичный чат + Q(department=user.department) # Чат отдела + ).distinct() +""" diff --git a/backend/feed/apps.py b/backend/feed/apps.py index a63d5cfc..4d10c85c 100644 --- a/backend/feed/apps.py +++ b/backend/feed/apps.py @@ -8,3 +8,4 @@ class FeedConfig(AppConfig): def ready(self): # Подключаем signals для уведомлений import feed.notification_signals # noqa + import feed.rules # django-rules: регистрация предикатов и правил доступа diff --git a/backend/feed/rules.py b/backend/feed/rules.py new file mode 100644 index 00000000..2bbe69a9 --- /dev/null +++ b/backend/feed/rules.py @@ -0,0 +1,386 @@ +""" +django-rules: декларативные правила доступа для feed (лента новостей) + +Правила используются для проверки permissions на уровне объектов. +https://github.com/dfunckt/django-rules +""" + +import rules + + +# ----------------------------------------------------------------------------- +# ПРЕДИКАТЫ (predicates) +# ----------------------------------------------------------------------------- + +@rules.predicate +def is_superuser(user): + """Суперпользователь имеет все права""" + return user.is_superuser + + +@rules.predicate +def is_post_author(user, post): + """Пользователь является автором поста""" + if post is None: + return False + + return post.author == user or post.created_by == user + + +@rules.predicate +def can_publish_posts(user): + """ + Пользователь может публиковать посты в ленте. + Адаптируйте под вашу логику (по должности, группе и т.д.) + """ + if not hasattr(user, 'position'): + return False + + position_name = getattr(user.position, 'name', '').lower() + + # Руководители, PR-менеджеры, секретари могут публиковать + return any(keyword in position_name for keyword in [ + 'руководитель', 'начальник', 'директор', 'секретарь', + 'pr', 'маркетинг', 'коммуникац' + ]) + + +@rules.predicate +def can_moderate_posts(user): + """Пользователь может модерировать посты (удалять чужие, редактировать)""" + return can_publish_posts(user) or is_superuser(user) + + +@rules.predicate +def is_public_post(user, post): + """Пост является публичным (доступен всем)""" + if post is None: + return False + + return getattr(post, 'is_public', True) + + +@rules.predicate +def is_department_post(user, post): + """Пост относится к отделу пользователя""" + if post is None or not hasattr(user, 'department'): + return False + + if hasattr(post, 'department'): + return post.department == user.department + + if hasattr(post, 'target_departments'): + return user.department in post.target_departments.all() + + return False + + +@rules.predicate +def is_comment_author(user, comment): + """Пользователь является автором комментария""" + if comment is None: + return False + + return comment.author == user or comment.created_by == user + + +@rules.predicate +def can_access_comment_post(user, comment): + """Пользователь имеет доступ к посту, где находится комментарий""" + if comment is None or not hasattr(comment, 'post'): + return False + + post = comment.post + return is_public_post(user, post) or is_department_post(user, post) + + +@rules.predicate +def is_post_pinned(user, post): + """Пост закреплён (может видеть любой сотрудник)""" + if post is None: + return False + + return getattr(post, 'is_pinned', False) + + +# ----------------------------------------------------------------------------- +# ПРАВИЛА (rules) +# ----------------------------------------------------------------------------- + +# Просмотр поста +rules.add_rule( + 'feed.view_post', + is_superuser | is_public_post | is_department_post | is_post_pinned | + is_post_author # Автор всегда видит свой пост, даже если он не опубликован +) + +# Создание поста +rules.add_rule( + 'feed.create_post', + is_superuser | can_publish_posts +) + +# Изменение поста +rules.add_rule( + 'feed.change_post', + is_superuser | is_post_author | can_moderate_posts +) + +# Удаление поста +rules.add_rule( + 'feed.delete_post', + is_superuser | is_post_author | can_moderate_posts +) + +# Публикация поста (изменение статуса draft → published) +rules.add_rule( + 'feed.publish_post', + is_superuser | (is_post_author & can_publish_posts) | can_moderate_posts +) + +# Закрепление поста +rules.add_rule( + 'feed.pin_post', + is_superuser | can_moderate_posts +) + +# Добавление комментария к посту +rules.add_rule( + 'feed.comment_post', + rules.is_authenticated # Любой авторизованный может комментировать +) + +# Просмотр комментария +rules.add_rule( + 'feed.view_comment', + is_superuser | can_access_comment_post +) + +# Изменение комментария +rules.add_rule( + 'feed.change_comment', + is_superuser | is_comment_author +) + +# Удаление комментария +rules.add_rule( + 'feed.delete_comment', + is_superuser | is_comment_author | can_moderate_posts +) + +# Лайк/реакция на пост +rules.add_rule( + 'feed.react_to_post', + rules.is_authenticated # Любой авторизованный может ставить реакции +) + +# Просмотр черновиков (draft posts) +rules.add_rule( + 'feed.view_drafts', + is_superuser | can_publish_posts +) + +# Просмотр статистики по постам +rules.add_rule( + 'feed.view_statistics', + is_superuser | can_publish_posts +) + + +# ----------------------------------------------------------------------------- +# ИСПОЛЬЗОВАНИЕ В КОДЕ +# ----------------------------------------------------------------------------- + +""" +# В views: +from django.core.exceptions import PermissionDenied +import rules + +def post_detail(request, pk): + post = get_object_or_404(Post, pk=pk) + + if not rules.test_rule('feed.view_post', request.user, post): + raise PermissionDenied("У вас нет доступа к этому посту") + + comments = post.comments.all() + return render(request, 'feed/post_detail.html', { + 'post': post, + 'comments': comments + }) + + +def post_create(request): + if not rules.test_rule('feed.create_post', request.user, None): + raise PermissionDenied("У вас нет прав на создание постов") + + if request.method == 'POST': + post = Post.objects.create( + author=request.user, + title=request.POST.get('title'), + content=request.POST.get('content'), + status='draft' + ) + return redirect('feed:post_detail', pk=post.pk) + + return render(request, 'feed/post_create.html') + + +def post_publish(request, pk): + post = get_object_or_404(Post, pk=pk) + + if not rules.test_rule('feed.publish_post', request.user, post): + return JsonResponse({'error': 'Нет прав на публикацию'}, status=403) + + post.status = 'published' + post.published_at = timezone.now() + post.save() + + return JsonResponse({'success': True}) + + +def add_comment(request, post_pk): + post = get_object_or_404(Post, pk=post_pk) + + if not rules.test_rule('feed.comment_post', request.user, None): + return JsonResponse({'error': 'Нет прав на комментирование'}, status=403) + + comment = Comment.objects.create( + post=post, + author=request.user, + text=request.POST.get('text') + ) + + return JsonResponse({'comment_id': comment.pk}) + + +# В templates: +{% load rules %} + +{# Список постов #} +{% for post in posts %} + {% has_rule 'feed.view_post' user post as can_view %} + {% if can_view %} +
+
+

{{ post.title }}

+

{{ post.content|truncatewords:50 }}

+

Автор: {{ post.author }}, {{ post.created_at|date:"d.m.Y H:i" }}

+ + + Читать далее + + + {% has_rule 'feed.change_post' user post as can_edit %} + {% if can_edit %} + + Редактировать + + {% endif %} + + {% has_rule 'feed.delete_post' user post as can_delete %} + {% if can_delete %} +
+ {% csrf_token %} + +
+ {% endif %} + + {% has_rule 'feed.pin_post' user post as can_pin %} + {% if can_pin and not post.is_pinned %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+ {% endif %} +{% endfor %} + +{# Создание поста #} +{% has_rule 'feed.create_post' user None as can_create %} +{% if can_create %} + + Создать пост + +{% endif %} + +{# Комментарии #} +{% has_rule 'feed.comment_post' user None as can_comment %} +{% if can_comment %} +
+ {% csrf_token %} + + +
+{% endif %} + + +# В DRF permissions: +from rest_framework import permissions +import rules + +class PostPermission(permissions.BasePermission): + def has_permission(self, request, view): + if request.method == 'POST': + return rules.test_rule('feed.create_post', request.user, None) + return True + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('feed.view_post', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('feed.change_post', request.user, obj) + elif request.method == 'DELETE': + return rules.test_rule('feed.delete_post', request.user, obj) + return False + + +class CommentPermission(permissions.BasePermission): + def has_permission(self, request, view): + if request.method == 'POST': + return rules.test_rule('feed.comment_post', request.user, None) + return True + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('feed.view_comment', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('feed.change_comment', request.user, obj) + elif request.method == 'DELETE': + return rules.test_rule('feed.delete_comment', request.user, obj) + return False + + +# Фильтрация QuerySet (только доступные посты): +from django.db.models import Q + +def get_accessible_posts(user): + return Post.objects.filter( + Q(status='published') & ( # Только опубликованные И (публичные ИЛИ для отдела) + Q(is_public=True) | + Q(department=user.department) | + Q(target_departments=user.department) + ) | + Q(author=user) # + собственные посты (включая черновики) + ).distinct().order_by('-created_at') + + +# API endpoint для лайков: +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def like_post(request, pk): + post = get_object_or_404(Post, pk=pk) + + if not rules.test_rule('feed.react_to_post', request.user, None): + return Response({'error': 'Permission denied'}, status=403) + + like, created = PostLike.objects.get_or_create(post=post, user=request.user) + + if not created: + like.delete() + return Response({'liked': False, 'likes_count': post.likes.count()}) + + return Response({'liked': True, 'likes_count': post.likes.count()}) +""" diff --git a/backend/procurement/apps.py b/backend/procurement/apps.py index 56689084..2be13deb 100644 --- a/backend/procurement/apps.py +++ b/backend/procurement/apps.py @@ -9,3 +9,4 @@ class ProcurementConfig(AppConfig): def ready(self): """Подключение сигналов при загрузке приложения.""" import procurement.signals # noqa: F401 + import procurement.rules # django-rules: регистрация предикатов и правил доступа diff --git a/backend/procurement/rules.py b/backend/procurement/rules.py new file mode 100644 index 00000000..5d8194c5 --- /dev/null +++ b/backend/procurement/rules.py @@ -0,0 +1,354 @@ +""" +django-rules: декларативные правила доступа для procurement (закупки) + +Правила используются для проверки permissions на уровне объектов. +https://github.com/dfunckt/django-rules +""" + +import rules + + +# ----------------------------------------------------------------------------- +# ПРЕДИКАТЫ (predicates) +# ----------------------------------------------------------------------------- + +@rules.predicate +def is_superuser(user): + """Суперпользователь имеет все права""" + return user.is_superuser + + +@rules.predicate +def is_procurement_manager(user): + """ + Пользователь является менеджером по закупкам. + Адаптируйте под вашу логику (по должности, группе и т.д.) + """ + if not hasattr(user, 'position'): + return False + + position_name = getattr(user.position, 'name', '').lower() + return any(keyword in position_name for keyword in [ + 'закупк', 'снабжен', 'procurement', 'supply' + ]) + + +@rules.predicate +def is_purchase_request_author(user, purchase_request): + """Пользователь является автором заявки на закупку""" + if purchase_request is None: + return False + + return ( + purchase_request.created_by == user or + getattr(purchase_request, 'author', None) == user + ) + + +@rules.predicate +def is_purchase_request_approver(user, purchase_request): + """Пользователь назначен согласующим для заявки на закупку""" + if purchase_request is None: + return False + + # Проверка через approvers + if hasattr(purchase_request, 'approvers'): + return user in purchase_request.approvers.all() + + # Проверка через approval_chain + if hasattr(purchase_request, 'approval_chain'): + return purchase_request.approval_chain.filter(approver=user).exists() + + return False + + +@rules.predicate +def is_department_purchase_request(user, purchase_request): + """Заявка на закупку относится к отделу пользователя""" + if purchase_request is None or not hasattr(user, 'department'): + return False + + if hasattr(purchase_request, 'department'): + return purchase_request.department == user.department + + # Проверка через автора + if hasattr(purchase_request, 'created_by') and hasattr(purchase_request.created_by, 'department'): + return purchase_request.created_by.department == user.department + + return False + + +@rules.predicate +def can_approve_purchases(user): + """ + Пользователь может согласовывать закупки. + Адаптируйте под вашу логику (по должности, бюджетным лимитам и т.д.) + """ + if not hasattr(user, 'position'): + return False + + position_name = getattr(user.position, 'name', '').lower() + return any(keyword in position_name for keyword in [ + 'руководитель', 'начальник', 'директор', 'финансов' + ]) + + +@rules.predicate +def is_supplier_contact(user, supplier): + """Пользователь является контактным лицом поставщика""" + if supplier is None: + return False + + if hasattr(supplier, 'contact_person'): + return supplier.contact_person == user + + if hasattr(supplier, 'managers'): + return user in supplier.managers.all() + + return False + + +@rules.predicate +def is_contract_manager(user, contract): + """Пользователь является менеджером договора""" + if contract is None: + return False + + if hasattr(contract, 'manager'): + return contract.manager == user + + if hasattr(contract, 'responsible_person'): + return contract.responsible_person == user + + return False + + +@rules.predicate +def can_manage_suppliers(user): + """Пользователь может управлять поставщиками""" + return is_procurement_manager(user) or is_superuser(user) + + +# ----------------------------------------------------------------------------- +# ПРАВИЛА (rules) +# ----------------------------------------------------------------------------- + +# Просмотр заявки на закупку +rules.add_rule( + 'procurement.view_purchase_request', + is_superuser | is_purchase_request_author | is_procurement_manager | + is_purchase_request_approver | can_approve_purchases +) + +# Создание заявки на закупку +rules.add_rule( + 'procurement.create_purchase_request', + rules.is_authenticated # Любой сотрудник может создать заявку +) + +# Изменение заявки на закупку +rules.add_rule( + 'procurement.change_purchase_request', + is_superuser | is_purchase_request_author | is_procurement_manager +) + +# Удаление заявки на закупку +rules.add_rule( + 'procurement.delete_purchase_request', + is_superuser | is_purchase_request_author +) + +# Согласование заявки на закупку +rules.add_rule( + 'procurement.approve_purchase_request', + is_superuser | is_purchase_request_approver | can_approve_purchases | is_procurement_manager +) + +# Изменение статуса заявки +rules.add_rule( + 'procurement.change_request_status', + is_superuser | is_procurement_manager | can_approve_purchases +) + +# Просмотр поставщика +rules.add_rule( + 'procurement.view_supplier', + is_superuser | is_procurement_manager | is_supplier_contact +) + +# Создание/изменение поставщика +rules.add_rule( + 'procurement.manage_supplier', + is_superuser | can_manage_suppliers +) + +# Удаление поставщика +rules.add_rule( + 'procurement.delete_supplier', + is_superuser +) + +# Просмотр договора +rules.add_rule( + 'procurement.view_contract', + is_superuser | is_contract_manager | is_procurement_manager +) + +# Создание/изменение договора +rules.add_rule( + 'procurement.manage_contract', + is_superuser | is_contract_manager | can_manage_suppliers +) + +# Удаление договора +rules.add_rule( + 'procurement.delete_contract', + is_superuser +) + +# Просмотр всех заявок (для менеджеров) +rules.add_rule( + 'procurement.view_all_requests', + is_superuser | is_procurement_manager | can_approve_purchases +) + +# Просмотр отчётов по закупкам +rules.add_rule( + 'procurement.view_reports', + is_superuser | is_procurement_manager | can_approve_purchases +) + +# Экспорт данных по закупкам +rules.add_rule( + 'procurement.export_data', + is_superuser | is_procurement_manager +) + + +# ----------------------------------------------------------------------------- +# ИСПОЛЬЗОВАНИЕ В КОДЕ +# ----------------------------------------------------------------------------- + +""" +# В views: +from django.core.exceptions import PermissionDenied +import rules + +def purchase_request_detail(request, pk): + purchase_request = get_object_or_404(PurchaseRequest, pk=pk) + + if not rules.test_rule('procurement.view_purchase_request', request.user, purchase_request): + raise PermissionDenied("У вас нет доступа к этой заявке") + + return render(request, 'procurement/request_detail.html', { + 'request': purchase_request + }) + + +def approve_purchase_request(request, pk): + purchase_request = get_object_or_404(PurchaseRequest, pk=pk) + + if not rules.test_rule('procurement.approve_purchase_request', request.user, purchase_request): + return JsonResponse({'error': 'Нет прав на согласование'}, status=403) + + purchase_request.status = 'approved' + purchase_request.approved_by = request.user + purchase_request.approved_at = timezone.now() + purchase_request.save() + + return JsonResponse({'success': True}) + + +def supplier_list(request): + # Фильтрация: только доступные поставщики + if rules.test_rule('procurement.view_all_requests', request.user, None): + suppliers = Supplier.objects.all() + else: + suppliers = Supplier.objects.filter( + Q(contact_person=request.user) | Q(managers=request.user) + ) + + return render(request, 'procurement/suppliers.html', {'suppliers': suppliers}) + + +# В templates: +{% load rules %} + +{% has_rule 'procurement.approve_purchase_request' user purchase_request as can_approve %} +{% if can_approve and purchase_request.status == 'pending' %} +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+{% endif %} + +{% has_rule 'procurement.manage_supplier' user None as can_manage %} +{% if can_manage %} + + Добавить поставщика + +{% endif %} + + +# В DRF permissions: +from rest_framework import permissions +import rules + +class PurchaseRequestPermission(permissions.BasePermission): + def has_permission(self, request, view): + if request.method == 'POST': + return rules.test_rule('procurement.create_purchase_request', request.user, None) + return True + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('procurement.view_purchase_request', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('procurement.change_purchase_request', request.user, obj) + elif request.method == 'DELETE': + return rules.test_rule('procurement.delete_purchase_request', request.user, obj) + return False + + +class SupplierPermission(permissions.BasePermission): + def has_permission(self, request, view): + if request.method == 'POST': + return rules.test_rule('procurement.manage_supplier', request.user, None) + return True + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('procurement.view_supplier', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('procurement.manage_supplier', request.user, None) + elif request.method == 'DELETE': + return rules.test_rule('procurement.delete_supplier', request.user, None) + return False + + +# Фильтрация QuerySet: +from django.db.models import Q + +def get_accessible_purchase_requests(user): + if rules.test_rule('procurement.view_all_requests', user, None): + return PurchaseRequest.objects.all() + + return PurchaseRequest.objects.filter( + Q(created_by=user) | # Созданные пользователем + Q(approvers=user) | # Требующие согласования + Q(department=user.department) # Заявки отдела + ).distinct() + + +def get_accessible_suppliers(user): + if rules.test_rule('procurement.view_all_requests', user, None): + return Supplier.objects.all() + + return Supplier.objects.filter( + Q(contact_person=user) | Q(managers=user) + ).distinct() +""" diff --git a/backend/requests_app/apps.py b/backend/requests_app/apps.py index 53046a89..9efa0562 100644 --- a/backend/requests_app/apps.py +++ b/backend/requests_app/apps.py @@ -8,3 +8,4 @@ class RequestsAppConfig(AppConfig): def ready(self): # Подключаем signals для уведомлений import requests_app.notification_signals # noqa + import requests_app.rules # django-rules: регистрация предикатов и правил доступа diff --git a/backend/requests_app/rules.py b/backend/requests_app/rules.py new file mode 100644 index 00000000..b2a421fe --- /dev/null +++ b/backend/requests_app/rules.py @@ -0,0 +1,270 @@ +""" +django-rules: декларативные правила доступа для requests_app + +Правила используются для проверки permissions на уровне объектов. +https://github.com/dfunckt/django-rules +""" + +import rules + + +# ----------------------------------------------------------------------------- +# ПРЕДИКАТЫ (predicates) +# ----------------------------------------------------------------------------- + +@rules.predicate +def is_superuser(user): + """Суперпользователь имеет все права""" + return user.is_superuser + + +@rules.predicate +def is_request_author(user, request_obj): + """Пользователь является автором заявки""" + if request_obj is None: + return False + return request_obj.created_by == user or request_obj.author == user + + +@rules.predicate +def is_request_assignee(user, request_obj): + """Пользователь является исполнителем заявки""" + if request_obj is None: + return False + + # Проверка через assigned_to (один исполнитель) + if hasattr(request_obj, 'assigned_to'): + return request_obj.assigned_to == user + + # Проверка через assignees (несколько исполнителей) + if hasattr(request_obj, 'assignees'): + return user in request_obj.assignees.all() + + return False + + +@rules.predicate +def is_request_approver(user, request_obj): + """Пользователь назначен согласующим для этой заявки""" + if request_obj is None: + return False + + # Проверка через approvers + if hasattr(request_obj, 'approvers'): + return user in request_obj.approvers.all() + + # Проверка через approval_chain + if hasattr(request_obj, 'approval_chain'): + return request_obj.approval_chain.filter(approver=user).exists() + + return False + + +@rules.predicate +def can_manage_requests(user): + """ + Пользователь может управлять заявками (по должности или роли). + Адаптируйте под вашу логику. + """ + if not hasattr(user, 'position'): + return False + + position_name = getattr(user.position, 'name', '').lower() + return any(keyword in position_name for keyword in [ + 'руководитель', 'начальник', 'директор', 'менеджер', 'администратор' + ]) + + +@rules.predicate +def is_department_request(user, request_obj): + """Заявка относится к отделу пользователя""" + if request_obj is None or not hasattr(user, 'department'): + return False + + # Проверка через department + if hasattr(request_obj, 'department'): + return request_obj.department == user.department + + # Проверка через автора заявки + if hasattr(request_obj, 'created_by') and hasattr(request_obj.created_by, 'department'): + return request_obj.created_by.department == user.department + + return False + + +@rules.predicate +def is_request_watcher(user, request_obj): + """Пользователь наблюдает за заявкой (подписан на уведомления)""" + if request_obj is None: + return False + + if hasattr(request_obj, 'watchers'): + return user in request_obj.watchers.all() + + return False + + +@rules.predicate +def can_change_request_status(user, request_obj): + """Пользователь может менять статус заявки""" + if request_obj is None: + return False + + # Автор может закрывать/отменять свою заявку + if is_request_author(user, request_obj): + return True + + # Исполнитель может переводить в работу/выполнено + if is_request_assignee(user, request_obj): + return True + + # Менеджеры могут менять любые статусы + if can_manage_requests(user): + return True + + return False + + +# ----------------------------------------------------------------------------- +# ПРАВИЛА (rules) +# ----------------------------------------------------------------------------- + +# Просмотр заявки +rules.add_rule( + 'requests_app.view_request', + is_superuser | is_request_author | is_request_assignee | + is_request_approver | is_request_watcher | can_manage_requests +) + +# Изменение заявки +rules.add_rule( + 'requests_app.change_request', + is_superuser | is_request_author | can_manage_requests +) + +# Удаление заявки (только автор и менеджеры) +rules.add_rule( + 'requests_app.delete_request', + is_superuser | (is_request_author & rules.always_allow) | can_manage_requests +) + +# Назначение исполнителя +rules.add_rule( + 'requests_app.assign_request', + is_superuser | can_manage_requests | is_request_author +) + +# Изменение статуса заявки +rules.add_rule( + 'requests_app.change_status', + is_superuser | can_change_request_status +) + +# Согласование заявки +rules.add_rule( + 'requests_app.approve_request', + is_superuser | is_request_approver | can_manage_requests +) + +# Комментирование заявки +rules.add_rule( + 'requests_app.comment_request', + is_superuser | is_request_author | is_request_assignee | + is_request_watcher | can_manage_requests +) + +# Добавление наблюдателей +rules.add_rule( + 'requests_app.add_watchers', + is_superuser | is_request_author | can_manage_requests +) + +# Просмотр всех заявок отдела +rules.add_rule( + 'requests_app.view_department_requests', + is_superuser | can_manage_requests +) + +# Просмотр статистики по заявкам +rules.add_rule( + 'requests_app.view_statistics', + is_superuser | can_manage_requests +) + + +# ----------------------------------------------------------------------------- +# ИСПОЛЬЗОВАНИЕ В КОДЕ +# ----------------------------------------------------------------------------- + +""" +# В views: +from django.core.exceptions import PermissionDenied +import rules + +def request_detail(request, pk): + request_obj = get_object_or_404(Request, pk=pk) + + if not rules.test_rule('requests_app.view_request', request.user, request_obj): + raise PermissionDenied + + return render(request, 'requests_app/detail.html', {'request': request_obj}) + + +def request_assign(request, pk): + request_obj = get_object_or_404(Request, pk=pk) + + if not rules.test_rule('requests_app.assign_request', request.user, request_obj): + raise PermissionDenied + + # Логика назначения + assignee_id = request.POST.get('assignee_id') + request_obj.assigned_to_id = assignee_id + request_obj.save() + + return redirect('requests_app:detail', pk=request_obj.pk) + + +# В templates: +{% load rules %} + +{% has_rule 'requests_app.change_status' user request as can_change_status %} +{% if can_change_status %} +
+ {% csrf_token %} + + +
+{% endif %} + + +# В DRF permissions: +from rest_framework import permissions +import rules + +class RequestPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('requests_app.view_request', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('requests_app.change_request', request.user, obj) + elif request.method == 'DELETE': + return rules.test_rule('requests_app.delete_request', request.user, obj) + return False + + +# Фильтрация QuerySet (только доступные заявки): +from django.db.models import Q + +def get_accessible_requests(user): + return Request.objects.filter( + Q(created_by=user) | # Созданные пользователем + Q(assigned_to=user) | # Назначенные пользователю + Q(watchers=user) | # Наблюдаемые пользователем + Q(approvers=user) | # Требующие согласования + Q(department=user.department) # Заявки отдела + ).distinct() +"""