From 967819a58f5dfbd2b3ddbc7d660015b547f3a554 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Sat, 28 Feb 2026 00:07:53 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20django-rules=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20object-level=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Установлен пакет rules==3.5 - Добавлен ObjectPermissionBackend в AUTHENTICATION_BACKENDS - Созданы базовые правила для employees: * Предикаты: is_hr_staff, is_department_head, is_own_profile и др. * Правила: view/change/delete employee, view reports и контакты - Созданы базовые правила для documents: * Предикаты: is_document_owner, has_document_access, can_approve и др. * Правила: view/change/delete/approve/publish/share documents - Правила регистрируются в apps.py (ready() метод) - Создана подробная документация с примерами использования * В views, templates, DRF permissions, Django Admin * Сценарии: многоуровневое согласование, временные ограничения * Сравнение с django-guardian, отладка, тестирование Документация: docs/guides/DJANGO_RULES_INTEGRATION.md --- backend/documents/apps.py | 1 + backend/documents/rules.py | 296 +++++++++++++ backend/employees/apps.py | 1 + backend/employees/rules.py | 186 +++++++++ backend/eusrr_backend/settings.py | 2 + backend/requirements.txt | 1 + docs/guides/DJANGO_RULES_INTEGRATION.md | 527 ++++++++++++++++++++++++ 7 files changed, 1014 insertions(+) create mode 100644 backend/documents/rules.py create mode 100644 backend/employees/rules.py create mode 100644 docs/guides/DJANGO_RULES_INTEGRATION.md diff --git a/backend/documents/apps.py b/backend/documents/apps.py index d76e42f7..70360190 100644 --- a/backend/documents/apps.py +++ b/backend/documents/apps.py @@ -8,3 +8,4 @@ class DocumentsConfig(AppConfig): def ready(self): # импортируем сигналы, чтобы они подхватились import documents.notification_signals # noqa + import documents.rules # django-rules: регистрация предикатов и правил доступа diff --git a/backend/documents/rules.py b/backend/documents/rules.py new file mode 100644 index 00000000..eff25ee5 --- /dev/null +++ b/backend/documents/rules.py @@ -0,0 +1,296 @@ +""" +django-rules: декларативные правила доступа для documents + +Правила используются для проверки permissions на уровне объектов. +https://github.com/dfunckt/django-rules +""" + +import rules + + +# ----------------------------------------------------------------------------- +# ПРЕДИКАТЫ (predicates) +# ----------------------------------------------------------------------------- + +@rules.predicate +def is_superuser(user): + """Суперпользователь имеет все права""" + return user.is_superuser + + +@rules.predicate +def is_document_owner(user, document): + """Пользователь является создателем документа""" + if document is None: + return False + return document.created_by == user or document.owner == user + + +@rules.predicate +def is_document_author(user, document): + """Пользователь является автором документа""" + if document is None or not hasattr(document, 'author'): + return False + return document.author == user + + +@rules.predicate +def has_document_access(user, document): + """ + Пользователь имеет доступ к документу через: + - Публичный доступ (is_public) + - Доступ для отдела (department_access) + - Прямой доступ в списке разрешённых пользователей + """ + if document is None: + return False + + # Публичный документ + if hasattr(document, 'is_public') and document.is_public: + return True + + # Доступ для отдела + if hasattr(document, 'department_access') and hasattr(user, 'department'): + if document.department_access and document.department_access == user.department: + return True + + # Прямой доступ через ManyToMany (если есть поле shared_with или allowed_users) + if hasattr(document, 'shared_with'): + if user in document.shared_with.all(): + return True + + if hasattr(document, 'allowed_users'): + if user in document.allowed_users.all(): + return True + + return False + + +@rules.predicate +def can_approve_documents(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_document_approver(user, document): + """Пользователь назначен согласующим для этого документа""" + if document is None: + return False + + # Проверка через связь approvers, если есть + if hasattr(document, 'approvers'): + return user in document.approvers.all() + + # Проверка через связь approval_chain (если используется цепочка согласования) + if hasattr(document, 'approval_chain'): + return document.approval_chain.filter(approver=user).exists() + + return False + + +@rules.predicate +def is_same_department(user, document): + """Документ относится к отделу пользователя""" + if document is None or not hasattr(user, 'department'): + return False + + if hasattr(document, 'department'): + return document.department == user.department + + return False + + +@rules.predicate +def is_document_category_manager(user, document): + """ + Пользователь является менеджером категории документа. + Адаптируйте под вашу структуру категорий. + """ + if document is None or not hasattr(document, 'category'): + return False + + category = document.category + if hasattr(category, 'managers'): + return user in category.managers.all() + + return False + + +# ----------------------------------------------------------------------------- +# ПРАВИЛА (rules) +# ----------------------------------------------------------------------------- + +# Просмотр документа +rules.add_rule( + 'documents.view_document', + is_superuser | is_document_owner | is_document_author | has_document_access +) + +# Изменение документа (только владелец и автор) +rules.add_rule( + 'documents.change_document', + is_superuser | is_document_owner | is_document_author +) + +# Удаление документа (только владелец и superuser) +rules.add_rule( + 'documents.delete_document', + is_superuser | is_document_owner +) + +# Согласование документа (согласующие + руководители) +rules.add_rule( + 'documents.approve_document', + is_superuser | is_document_approver | can_approve_documents +) + +# Публикация документа (владелец + менеджеры категории) +rules.add_rule( + 'documents.publish_document', + is_superuser | is_document_owner | is_document_category_manager +) + +# Скачивание документа (все, кто имеет доступ на просмотр) +rules.add_rule( + 'documents.download_document', + is_superuser | is_document_owner | is_document_author | has_document_access +) + +# Выдача доступа другим пользователям (только владелец) +rules.add_rule( + 'documents.share_document', + is_superuser | is_document_owner +) + +# Просмотр истории изменений документа +rules.add_rule( + 'documents.view_document_history', + is_superuser | is_document_owner | is_document_author | is_document_category_manager +) + +# Просмотр всех документов отдела +rules.add_rule( + 'documents.view_department_documents', + is_superuser | can_approve_documents +) + + +# ----------------------------------------------------------------------------- +# PERMISSIONS ДЛЯ МОДЕЛЕЙ +# ----------------------------------------------------------------------------- + +# Если вы хотите переопределить стандартные Django permissions: +# rules.add_perm('documents.view_document', is_superuser | has_document_access) +# rules.add_perm('documents.change_document', is_superuser | is_document_owner) +# rules.add_perm('documents.delete_document', is_superuser | is_document_owner) +# rules.add_perm('documents.add_document', rules.is_authenticated) # Все авторизованные могут создавать + + +# ----------------------------------------------------------------------------- +# ИСПОЛЬЗОВАНИЕ В КОДЕ +# ----------------------------------------------------------------------------- + +""" +# В views: +from django.core.exceptions import PermissionDenied +import rules + +def document_detail(request, pk): + document = get_object_or_404(Document, pk=pk) + + # Проверка доступа + if not rules.test_rule('documents.view_document', request.user, document): + raise PermissionDenied + + return render(request, 'documents/detail.html', {'document': document}) + + +def document_approve(request, pk): + document = get_object_or_404(Document, pk=pk) + + # Проверка прав на согласование + if not rules.test_rule('documents.approve_document', request.user, document): + raise PermissionDenied + + # Логика согласования + document.status = 'approved' + document.approved_by = request.user + document.save() + + return redirect('documents:detail', pk=document.pk) + + +# В templates: +{% load rules %} + +{% has_rule 'documents.change_document' user document as can_edit %} +{% if can_edit %} + + Редактировать + +{% endif %} + +{% has_rule 'documents.approve_document' user document as can_approve %} +{% if can_approve and document.status == 'pending' %} +
+ {% csrf_token %} + +
+{% endif %} + + +# В DRF permissions: +from rest_framework import permissions +import rules + +class DocumentPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('documents.view_document', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('documents.change_document', request.user, obj) + elif request.method == 'DELETE': + return rules.test_rule('documents.delete_document', request.user, obj) + return False + + +# В queryset filtering (показывать только доступные документы): +from django.db.models import Q + +def get_accessible_documents(user): + # Документы, к которым есть доступ + return Document.objects.filter( + Q(created_by=user) | # Созданные пользователем + Q(owner=user) | # Принадлежащие пользователю + Q(is_public=True) | # Публичные + Q(department_access=user.department) | # Доступные для отдела + Q(shared_with=user) # Расшаренные для пользователя + ).distinct() + + +# Middleware для проверки доступа к файлам: +# (если файлы отдаются через X-Accel-Redirect или подобное) + +class DocumentAccessMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.path.startswith('/media/documents/'): + # Извлекаем документ по пути + document = self.get_document_from_path(request.path) + if document and not rules.test_rule('documents.view_document', request.user, document): + return HttpResponseForbidden("У вас нет доступа к этому документу") + + return self.get_response(request) +""" diff --git a/backend/employees/apps.py b/backend/employees/apps.py index f5f88ccb..64346ff7 100644 --- a/backend/employees/apps.py +++ b/backend/employees/apps.py @@ -8,3 +8,4 @@ class EmployeesConfig(AppConfig): def ready(self): import employees.signals import employees.signals_birthday # Сигналы для синхронизации дней рождений с django-scheduler + import employees.rules # django-rules: регистрация предикатов и правил доступа diff --git a/backend/employees/rules.py b/backend/employees/rules.py new file mode 100644 index 00000000..44a3be83 --- /dev/null +++ b/backend/employees/rules.py @@ -0,0 +1,186 @@ +""" +django-rules: декларативные правила доступа для employees + +Правила используются для проверки permissions на уровне объектов. +https://github.com/dfunckt/django-rules +""" + +import rules + + +# ----------------------------------------------------------------------------- +# ПРЕДИКАТЫ (predicates) +# ----------------------------------------------------------------------------- + +@rules.predicate +def is_superuser(user): + """Суперпользователь имеет все права""" + return user.is_superuser + + +@rules.predicate +def is_staff(user): + """Сотрудник (is_staff) имеет базовые права""" + return user.is_staff + + +@rules.predicate +def is_hr_staff(user): + """ + HR сотрудник - определяется по должности или группе. + Адаптируйте под вашу логику: по position, department, группе и т.д. + """ + if not hasattr(user, 'position'): + return False + position_name = getattr(user.position, 'name', '').lower() + return 'hr' in position_name or 'кадр' in position_name + + +@rules.predicate +def is_department_head(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_own_profile(user, employee): + """Пользователь просматривает свой собственный профиль""" + if employee is None: + return False + return user.pk == employee.pk + + +@rules.predicate +def is_same_department(user, employee): + """Пользователь и сотрудник в одном отделе""" + if employee is None or not hasattr(user, 'department') or not hasattr(employee, 'department'): + return False + return user.department == employee.department + + +# ----------------------------------------------------------------------------- +# ПРАВИЛА (rules) +# ----------------------------------------------------------------------------- + +# Просмотр профиля сотрудника +rules.add_rule( + 'employees.view_employee', + is_superuser | is_staff | is_hr_staff | is_own_profile +) + +# Изменение профиля сотрудника +rules.add_rule( + 'employees.change_employee', + is_superuser | is_hr_staff | (is_own_profile & ~rules.always_deny) +) + +# Удаление сотрудника (только HR и superuser) +rules.add_rule( + 'employees.delete_employee', + is_superuser | is_hr_staff +) + +# Просмотр списка всех сотрудников +rules.add_rule( + 'employees.view_all_employees', + is_superuser | is_staff | is_hr_staff +) + +# Просмотр отчётов по персоналу (только HR и руководители) +rules.add_rule( + 'employees.view_reports', + is_superuser | is_hr_staff | is_department_head +) + +# Изменение должности сотрудника (только HR) +rules.add_rule( + 'employees.change_position', + is_superuser | is_hr_staff +) + +# Изменение отдела сотрудника (только HR) +rules.add_rule( + 'employees.change_department', + is_superuser | is_hr_staff +) + +# Просмотр контактной информации сотрудника +rules.add_rule( + 'employees.view_contact_info', + is_superuser | is_staff | is_own_profile | is_same_department +) + +# ----------------------------------------------------------------------------- +# PERMISSIONS ДЛЯ МОДЕЛЕЙ +# ----------------------------------------------------------------------------- + +# Если вы хотите переопределить стандартные Django permissions для модели: +# rules.add_perm('employees.view_employee', is_superuser | is_staff) +# rules.add_perm('employees.change_employee', is_superuser | is_hr_staff) +# rules.add_perm('employees.delete_employee', is_superuser | is_hr_staff) +# rules.add_perm('employees.add_employee', is_superuser | is_hr_staff) + + +# ----------------------------------------------------------------------------- +# ИСПОЛЬЗОВАНИЕ В КОДЕ +# ----------------------------------------------------------------------------- + +""" +# В views: +from django.core.exceptions import PermissionDenied + +def employee_detail(request, pk): + employee = get_object_or_404(Employee, pk=pk) + + # Проверка через has_perm (если правило зарегистрировано через add_perm) + if not request.user.has_perm('employees.view_employee', employee): + raise PermissionDenied + + # Или напрямую через rules.test_rule + if not rules.test_rule('employees.view_employee', request.user, employee): + raise PermissionDenied + + return render(request, 'employees/detail.html', {'employee': employee}) + + +# В templates: +{% load rules %} + +{% has_perm 'employees.change_employee' user employee as can_change %} +{% if can_change %} + Редактировать +{% endif %} + + +# В DRF permissions: +from rest_framework import permissions +import rules + +class EmployeePermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('employees.view_employee', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('employees.change_employee', request.user, obj) + elif request.method == 'DELETE': + return rules.test_rule('employees.delete_employee', request.user, obj) + return False + + +# В admin.py для object-level permissions: +from django.contrib import admin + +class EmployeeAdmin(admin.ModelAdmin): + def has_change_permission(self, request, obj=None): + if obj is None: + return super().has_change_permission(request, obj) + return rules.test_rule('employees.change_employee', request.user, obj) +""" diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index e083f2f0..a5885196 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -36,6 +36,7 @@ INSTALLED_APPS = [ "widget_tweaks", "simple_history", "rest_framework", + "rules", # django-rules для декларативных permissions # Celery приложения "django_celery_beat", # Периодические задачи "django_celery_results", # Хранение результатов @@ -347,6 +348,7 @@ AUTHENTICATION_BACKENDS = [ "eusrr_backend.auth_backends.SuperuserOnlyBackend", # экстренный доступ для админа # расчёт прав на основе должностей "eusrr_backend.auth_backends.PositionRoleBackend", + "rules.permissions.ObjectPermissionBackend", # django-rules для object-level permissions "django.contrib.auth.backends.ModelBackend", # стандартный Django бэкенд ] diff --git a/backend/requirements.txt b/backend/requirements.txt index 61d34542..5a6a286b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -102,6 +102,7 @@ qrcode==8.1 redis==6.4.0 requests==2.32.4 requests-oauthlib==2.0.0 +rules==3.5 service-identity==24.2.0 six==1.17.0 social-auth-app-django==5.5.1 diff --git a/docs/guides/DJANGO_RULES_INTEGRATION.md b/docs/guides/DJANGO_RULES_INTEGRATION.md new file mode 100644 index 00000000..d1d7a7be --- /dev/null +++ b/docs/guides/DJANGO_RULES_INTEGRATION.md @@ -0,0 +1,527 @@ +# Интеграция django-rules: Object-Level Permissions + +**Дата:** 28 февраля 2026 +**Статус:** ✅ Базовая интеграция завершена +**Ветка:** `feature/django-rules` + +## Обзор + +django-rules — это библиотека для декларативного управления правами доступа на уровне объектов в Django. Она позволяет определять сложную логику permissions через композицию простых предикатов. + +### Преимущества + +- ✅ **Object-level permissions**: проверка прав на конкретные объекты +- ✅ **Декларативный синтаксис**: правила определяются явно и читаемо +- ✅ **Композиция**: комбинирование предикатов через `&`, `|`, `~` +- ✅ **Интеграция**: работает с Django, DRF, Admin, Templates +- ✅ **Производительность**: легковесная библиотека без overhead +- ✅ **Тестируемость**: правила легко покрываются unit-тестами + +### Ссылки + +- **GitHub**: https://github.com/dfunckt/django-rules +- **Документация**: https://github.com/dfunckt/django-rules#rules +- **PyPI**: https://pypi.org/project/rules/ + +--- + +## Установка и настройка + +### 1. Установка пакета + +```bash +../.venv/Scripts/pip install rules==3.5 +``` + +Добавлено в `requirements.txt`: +``` +rules==3.5 +``` + +### 2. Настройка Django + +**settings.py**: + +```python +INSTALLED_APPS = [ + # ... + "rules", # django-rules для декларативных permissions + # ... +] + +AUTHENTICATION_BACKENDS = [ + "eusrr_backend.auth_backends.LDAP3Backend", + "eusrr_backend.auth_backends.EmailOrPhoneBackend", + "eusrr_backend.auth_backends.SuperuserOnlyBackend", + "eusrr_backend.auth_backends.PositionRoleBackend", + "rules.permissions.ObjectPermissionBackend", # ← django-rules backend + "django.contrib.auth.backends.ModelBackend", +] +``` + +### 3. Создание файлов правил + +Созданы базовые правила для двух приложений: + +- **employees/rules.py**: правила доступа к профилям сотрудников +- **documents/rules.py**: правила доступа к документам + +--- + +## Архитектура правил + +### Базовая структура + +```python +import rules + +# 1. ПРЕДИКАТЫ - базовые проверки (возвращают True/False) +@rules.predicate +def is_document_owner(user, document): + return document.owner == user + +# 2. ПРАВИЛА - композиция предикатов +rules.add_rule( + 'documents.view_document', + is_superuser | is_document_owner | has_document_access +) + +# 3. PERMISSIONS (опционально) - регистрация в Django permissions +rules.add_perm('documents.view_document', is_superuser | is_document_owner) +``` + +### Композиция предикатов + +```python +# Логическое И (AND) +rule = predicate1 & predicate2 + +# Логическое ИЛИ (OR) +rule = predicate1 | predicate2 + +# Логическое НЕ (NOT) +rule = ~predicate1 + +# Сложные комбинации +rule = (is_owner | is_admin) & ~is_banned & has_subscription +``` + +--- + +## Реализованные правила + +### Employees (employees/rules.py) + +#### Предикаты: +- `is_superuser` — пользователь является суперюзером +- `is_staff` — пользователь имеет статус персонала +- `is_hr_staff` — HR сотрудник (по должности) +- `is_department_head` — руководитель отдела +- `is_own_profile` — собственный профиль +- `is_same_department` — сотрудники в одном отделе + +#### Правила: +- `employees.view_employee` — просмотр профиля +- `employees.change_employee` — изменение профиля +- `employees.delete_employee` — удаление сотрудника +- `employees.view_all_employees` — просмотр списка +- `employees.view_reports` — просмотр отчётов +- `employees.change_position` — изменение должности +- `employees.change_department` — изменение отдела +- `employees.view_contact_info` — просмотр контактов + +### Documents (documents/rules.py) + +#### Предикаты: +- `is_document_owner` — владелец документа +- `is_document_author` — автор документа +- `has_document_access` — доступ через is_public/department/shared +- `can_approve_documents` — может согласовывать (по должности) +- `is_document_approver` — назначен согласующим +- `is_same_department` — документ отдела пользователя +- `is_document_category_manager` — менеджер категории + +#### Правила: +- `documents.view_document` — просмотр документа +- `documents.change_document` — изменение документа +- `documents.delete_document` — удаление документа +- `documents.approve_document` — согласование документа +- `documents.publish_document` — публикация документа +- `documents.download_document` — скачивание документа +- `documents.share_document` — выдача доступа +- `documents.view_document_history` — просмотр истории +- `documents.view_department_documents` — документы отдела + +--- + +## Использование + +### 1. В Views + +```python +from django.core.exceptions import PermissionDenied +import rules + +def document_detail(request, pk): + document = get_object_or_404(Document, pk=pk) + + # Проверка через rules.test_rule + if not rules.test_rule('documents.view_document', request.user, document): + raise PermissionDenied + + return render(request, 'documents/detail.html', {'document': document}) + + +def document_approve(request, pk): + document = get_object_or_404(Document, pk=pk) + + if not rules.test_rule('documents.approve_document', request.user, document): + raise PermissionDenied + + document.status = 'approved' + document.approved_by = request.user + document.save() + + return redirect('documents:detail', pk=document.pk) +``` + +### 2. В Templates + +```django +{% load rules %} + +{% has_rule 'documents.change_document' user document as can_edit %} +{% if can_edit %} + + Редактировать + +{% endif %} + +{% has_rule 'documents.approve_document' user document as can_approve %} +{% if can_approve and document.status == 'pending' %} +
+ {% csrf_token %} + +
+{% endif %} +``` + +### 3. В DRF Permissions + +```python +from rest_framework import permissions +import rules + +class DocumentPermission(permissions.BasePermission): + """Object-level permission для Document через django-rules""" + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return rules.test_rule('documents.view_document', request.user, obj) + elif request.method in ['PUT', 'PATCH']: + return rules.test_rule('documents.change_document', request.user, obj) + elif request.method == 'DELETE': + return rules.test_rule('documents.delete_document', request.user, obj) + return False + + +# В ViewSet: +class DocumentViewSet(viewsets.ModelViewSet): + queryset = Document.objects.all() + serializer_class = DocumentSerializer + permission_classes = [permissions.IsAuthenticated, DocumentPermission] +``` + +### 4. В Django Admin + +```python +from django.contrib import admin +import rules + +class DocumentAdmin(admin.ModelAdmin): + def has_change_permission(self, request, obj=None): + if obj is None: + # Проверка на уровне модели (list view) + return super().has_change_permission(request, obj) + + # Проверка на уровне объекта (change view) + return rules.test_rule('documents.change_document', request.user, obj) + + def has_delete_permission(self, request, obj=None): + if obj is None: + return super().has_delete_permission(request, obj) + return rules.test_rule('documents.delete_document', request.user, obj) +``` + +### 5. Фильтрация QuerySet + +```python +from django.db.models import Q + +def get_accessible_documents(user): + """Возвращает документы, к которым пользователь имеет доступ""" + return Document.objects.filter( + Q(created_by=user) | # Созданные пользователем + Q(owner=user) | # Принадлежащие пользователю + Q(is_public=True) | # Публичные + Q(department_access=user.department) | # Для отдела + Q(shared_with=user) # Расшаренные + ).distinct() +``` + +### 6. Model-level Permissions (опционально) + +Если нужно интегрировать с Django permissions (`user.has_perm()`): + +```python +# В rules.py: +rules.add_perm('documents.view_document', is_superuser | has_document_access) +rules.add_perm('documents.change_document', is_superuser | is_document_owner) + +# Теперь можно использовать стандартный API: +if request.user.has_perm('documents.view_document', document): + # ... +``` + +--- + +## Тестирование + +### Unit-тесты для предикатов + +```python +import pytest +import rules +from employees.rules import is_own_profile, is_hr_staff + +@pytest.mark.django_db +def test_is_own_profile(): + user = Employee.objects.create(username='test') + other = Employee.objects.create(username='other') + + assert is_own_profile(user, user) is True + assert is_own_profile(user, other) is False + + +@pytest.mark.django_db +def test_is_hr_staff(): + hr_position = Position.objects.create(name='HR Manager') + dev_position = Position.objects.create(name='Developer') + + hr_user = Employee.objects.create(username='hr', position=hr_position) + dev_user = Employee.objects.create(username='dev', position=dev_position) + + assert is_hr_staff(hr_user) is True + assert is_hr_staff(dev_user) is False +``` + +### Интеграционные тесты для правил + +```python +import pytest +import rules + +@pytest.mark.django_db +def test_view_employee_permission(): + hr = Employee.objects.create(username='hr', position=hr_position) + employee = Employee.objects.create(username='employee') + + # HR может просматривать любого сотрудника + assert rules.test_rule('employees.view_employee', hr, employee) + + # Сотрудник может просматривать свой профиль + assert rules.test_rule('employees.view_employee', employee, employee) +``` + +--- + +## Следующие шаги + +### 1. Адаптация под реальные модели + +Текущие правила содержат примеры и комментарии. Необходимо: + +- ✅ Проверить наличие полей в моделях (`owner`, `is_public`, `shared_with` и т.д.) +- ✅ Адаптировать предикаты под реальную структуру +- ✅ Убрать неиспользуемые предикаты +- ✅ Добавить недостающие правила + +### 2. Миграция существующих permissions + +Заменить ручные проверки на правила: + +```python +# Старый код: +if not (request.user.is_superuser or document.owner == request.user): + raise PermissionDenied + +# Новый код: +if not rules.test_rule('documents.change_document', request.user, document): + raise PermissionDenied +``` + +### 3. Покрытие тестами + +Создать тесты для всех критичных правил: + +- `employees/tests/test_rules.py` +- `documents/tests/test_rules.py` + +### 4. Расширение на другие приложения + +Создать `rules.py` для: + +- `calendar_app` — доступ к календарям и событиям +- `requests_app` — создание и обработка заявок +- `communications` — доступ к чатам и сообщениям +- `finance` — финансовые документы и отчёты + +### 5. Performance optimization + +Для больших выборок использовать фильтрацию на уровне БД: + +```python +# Вместо: +documents = [d for d in Document.objects.all() if rules.test_rule('view', user, d)] + +# Использовать: +documents = get_accessible_documents(user) +``` + +### 6. Документация для команды + +- Создать примеры использования для каждого приложения +- Обновить CODE_STYLE.md с правилами написания predicates +- Провести код-ревью существующих permissions + +--- + +## Сравнение с django-guardian + +| Аспект | django-rules | django-guardian | +|--------|--------------|-----------------| +| **Хранение** | В коде (декларативно) | В БД (таблицы permissions) | +| **Производительность** | Быстрее (нет запросов в БД) | Медленнее (JOIN с таблицами) | +| **Гибкость** | Высокая (любая логика) | Ограничена (user-object pairs) | +| **Миграции** | Не требуются | Нужна БД миграция | +| **Сложность** | Проще | Сложнее | +| **Масштабирование** | Отлично для ~1K объектов | Лучше для миллионов объектов | + +**Вывод**: для EUSRR с умеренным количеством объектов и сложной логикой прав django-rules — оптимальный выбор. + +--- + +## Примеры реальных сценариев + +### Сценарий 1: Многоуровневое согласование + +```python +@rules.predicate +def can_approve_at_current_stage(user, document): + """Проверка, что пользователь — текущий согласующий""" + current_stage = document.approval_chain.filter(status='pending').first() + if not current_stage: + return False + return current_stage.approver == user + +rules.add_rule( + 'documents.approve_at_stage', + is_superuser | can_approve_at_current_stage +) +``` + +### Сценарий 2: Временные ограничения + +```python +from django.utils import timezone +from datetime import timedelta + +@rules.predicate +def can_edit_recent_document(user, document): + """Редактирование только в течение 24 часов после создания""" + if not is_document_owner(user, document): + return False + return timezone.now() - document.created_at < timedelta(hours=24) + +rules.add_rule( + 'documents.quick_edit', + can_edit_recent_document +) +``` + +### Сценарий 3: Иерархия отделов + +```python +@rules.predicate +def is_parent_department_head(user, employee): + """Руководитель вышестоящего отдела""" + if not hasattr(employee, 'department') or not hasattr(employee.department, 'parent'): + return False + return ( + hasattr(user, 'department') and + user.department == employee.department.parent and + is_department_head(user) + ) + +rules.add_rule( + 'employees.view_subordinate', + is_superuser | is_department_head | is_parent_department_head +) +``` + +--- + +## Отладка и мониторинг + +### Логирование проверок прав + +```python +import logging + +logger = logging.getLogger(__name__) + +@rules.predicate +def is_document_owner(user, document): + result = document.owner == user + logger.debug( + f"Permission check: is_document_owner({user.username}, doc_{document.pk}) = {result}" + ) + return result +``` + +### Отладочная view для проверки прав + +```python +def debug_permissions(request, document_id): + """DEBUG view для проверки прав доступа""" + if not settings.DEBUG: + raise Http404 + + document = get_object_or_404(Document, pk=document_id) + + checks = { + 'view': rules.test_rule('documents.view_document', request.user, document), + 'change': rules.test_rule('documents.change_document', request.user, document), + 'delete': rules.test_rule('documents.delete_document', request.user, document), + 'approve': rules.test_rule('documents.approve_document', request.user, document), + } + + return JsonResponse(checks) +``` + +--- + +## Заключение + +Базовая интеграция django-rules завершена: + +- ✅ Пакет установлен и настроен +- ✅ Созданы примеры правил для employees и documents +- ✅ Документация и примеры использования готовы + +Следующий шаг — адаптация правил под реальную структуру моделей и миграция существующих проверок прав на использование django-rules. + +--- + +**Автор**: GitHub Copilot +**Дата**: 28 февраля 2026 From 7e4c69321149c0ebb1786fd84e9363b6e5d7e415 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Sat, 28 Feb 2026 00:13:46 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BB=D1=8F=205=20=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20(requests,=20communicati?= =?UTF-8?q?ons,=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() +"""