mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
[Refactor] Users and Groups (#9476)
* Cleanup UserDetail page * Cleanup display * Re-use UserTable * Add 'users' permission role * Check user roles in "admin center" * Revert "Add 'users' permission role" This reverts commit 35b047b2f9859c836993026fdd22eeeca950f450. * Improve display logic * Expose group rule-sets to API * Prefetch rule_sets * Add 'label' to RuleSetSerializer * Add basic RuleSet table * Add API endpoints for RuleSet model * Edit group roles via table * Refactor user permissions checks - Remove duplicate function calls - Refactor permission checks into new file * Further refactoring * Even more refactoring * Fix user settings permission * Add TransferList component * Tweak GroupDrawer * Tweak UserDrawer * adjust user groups via API / UI * Allow "users" detail on Group API * Bump API version * Enumeration of RuleSet name * Update * Add permission check * Update src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx Co-authored-by: Matthias Mair <code@mjmair.com> * uncomment warning * Extend enum usage * More checks * Bug fix * Fix permission checks * Additional testing for user roles endpoint * Updated permission classes - RolePermission with read-only fallback - RolePermission with additional staff requirement * Do not allow creation of new RuleSet objects * Cleanup permission checks and unit tests * Cleanup UI permission checks * Updated class dostrings * Cleanup * Cleanup permission checks for UserTable * Add playwright tests for "permission" checks - Basic for now - Can be extended in the future * Tweak unit tests * Adjust layout of warning / error messages * Tweak group table logic * Table cleanup * Display roles associated with a particular group * Cleanup * Tweak user detail page --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
parent
dc1acfdacb
commit
15be7ab988
@ -19,7 +19,6 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
import InvenTree.version
|
import InvenTree.version
|
||||||
import users.models
|
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from InvenTree import helpers
|
from InvenTree import helpers
|
||||||
from InvenTree.auth_overrides import registration_enabled
|
from InvenTree.auth_overrides import registration_enabled
|
||||||
@ -27,6 +26,7 @@ from InvenTree.mixins import ListCreateAPI
|
|||||||
from InvenTree.sso import sso_registration_enabled
|
from InvenTree.sso import sso_registration_enabled
|
||||||
from plugin.serializers import MetadataSerializer
|
from plugin.serializers import MetadataSerializer
|
||||||
from users.models import ApiToken
|
from users.models import ApiToken
|
||||||
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
from .helpers import plugins_info
|
from .helpers import plugins_info
|
||||||
from .helpers_email import is_email_configured
|
from .helpers_email import is_email_configured
|
||||||
@ -681,14 +681,9 @@ class APISearchView(GenericAPIView):
|
|||||||
|
|
||||||
# Check permissions and update results dict with particular query
|
# Check permissions and update results dict with particular query
|
||||||
model = view.serializer_class.Meta.model
|
model = view.serializer_class.Meta.model
|
||||||
app_label = model._meta.app_label
|
|
||||||
model_name = model._meta.model_name
|
|
||||||
table = f'{app_label}_{model_name}'
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if users.models.RuleSet.check_table_permission(
|
if check_user_permission(request.user, model, 'view'):
|
||||||
request.user, table, 'view'
|
|
||||||
):
|
|
||||||
results[key] = view.list(request, *args, **kwargs).data
|
results[key] = view.list(request, *args, **kwargs).data
|
||||||
else:
|
else:
|
||||||
results[key] = {
|
results[key] = {
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 334
|
INVENTREE_API_VERSION = 335
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v335 - 2025-04-09 : https://github.com/inventree/InvenTree/pull/9476
|
||||||
|
- Adds "roles" detail to the Group API endpoint
|
||||||
|
- Adds "users" detail to the Group API endpoint
|
||||||
|
- Adds "groups" detail to the User API endpoint
|
||||||
|
|
||||||
v334 - 2025-04-08 : https://github.com/inventree/InvenTree/pull/9453
|
v334 - 2025-04-08 : https://github.com/inventree/InvenTree/pull/9453
|
||||||
- Fixes various operationId and enum collisions and help texts
|
- Fixes various operationId and enum collisions and help texts
|
||||||
|
|
||||||
|
@ -13,9 +13,9 @@ from rest_framework.utils import model_meta
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import InvenTree.permissions
|
import InvenTree.permissions
|
||||||
import users.models
|
|
||||||
from InvenTree.helpers import str2bool
|
from InvenTree.helpers import str2bool
|
||||||
from InvenTree.serializers import DependentField
|
from InvenTree.serializers import DependentField
|
||||||
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
@ -107,22 +107,17 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
self.model = InvenTree.permissions.get_model_for_view(view)
|
self.model = InvenTree.permissions.get_model_for_view(view)
|
||||||
|
|
||||||
# Construct the 'table name' from the model
|
# Construct the 'table name' from the model
|
||||||
app_label = self.model._meta.app_label
|
|
||||||
tbl_label = self.model._meta.model_name
|
tbl_label = self.model._meta.model_name
|
||||||
|
|
||||||
metadata['model'] = tbl_label
|
metadata['model'] = tbl_label
|
||||||
|
|
||||||
table = f'{app_label}_{tbl_label}'
|
|
||||||
|
|
||||||
actions = metadata.get('actions', None)
|
actions = metadata.get('actions', None)
|
||||||
|
|
||||||
if actions is None:
|
if actions is None:
|
||||||
actions = {}
|
actions = {}
|
||||||
|
|
||||||
check = users.models.RuleSet.check_table_permission
|
|
||||||
|
|
||||||
# Map the request method to a permission type
|
# Map the request method to a permission type
|
||||||
rolemap = {
|
rolemap = {
|
||||||
|
'OPTIONS': 'view',
|
||||||
'GET': 'view',
|
'GET': 'view',
|
||||||
'POST': 'add',
|
'POST': 'add',
|
||||||
'PUT': 'change',
|
'PUT': 'change',
|
||||||
@ -136,13 +131,15 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
|
|
||||||
# Remove any HTTP methods that the user does not have permission for
|
# Remove any HTTP methods that the user does not have permission for
|
||||||
for method, permission in rolemap.items():
|
for method, permission in rolemap.items():
|
||||||
result = check(user, table, permission)
|
result = check_user_permission(user, self.model, permission)
|
||||||
|
|
||||||
if method in actions and not result:
|
if method in actions and not result:
|
||||||
del actions[method]
|
del actions[method]
|
||||||
|
|
||||||
# Add a 'DELETE' action if we are allowed to delete
|
# Add a 'DELETE' action if we are allowed to delete
|
||||||
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
|
if 'DELETE' in view.allowed_methods and check_user_permission(
|
||||||
|
user, self.model, 'delete'
|
||||||
|
):
|
||||||
actions['DELETE'] = {}
|
actions['DELETE'] = {}
|
||||||
|
|
||||||
metadata['actions'] = actions
|
metadata['actions'] = actions
|
||||||
|
@ -4,7 +4,7 @@ from functools import wraps
|
|||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
import users.models
|
import users.permissions
|
||||||
|
|
||||||
|
|
||||||
def get_model_for_view(view):
|
def get_model_for_view(view):
|
||||||
@ -73,7 +73,7 @@ class RolePermission(permissions.BasePermission):
|
|||||||
if '.' in role:
|
if '.' in role:
|
||||||
role, permission = role.split('.')
|
role, permission = role.split('.')
|
||||||
|
|
||||||
return users.models.check_user_role(user, role, permission)
|
return users.permissions.check_user_role(user, role, permission)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Extract the model name associated with this request
|
# Extract the model name associated with this request
|
||||||
@ -82,16 +82,44 @@ class RolePermission(permissions.BasePermission):
|
|||||||
if model is None:
|
if model is None:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
app_label = model._meta.app_label
|
|
||||||
model_name = model._meta.model_name
|
|
||||||
|
|
||||||
table = f'{app_label}_{model_name}'
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# We will assume that if the serializer class does *not* have a Meta,
|
# We will assume that if the serializer class does *not* have a Meta,
|
||||||
# then we don't need a permission
|
# then we don't need a permission
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return users.models.RuleSet.check_table_permission(user, table, permission)
|
return users.permissions.check_user_permission(user, model, permission)
|
||||||
|
|
||||||
|
|
||||||
|
class RolePermissionOrReadOnly(RolePermission):
|
||||||
|
"""RolePermission which also allows read access for any authenticated user."""
|
||||||
|
|
||||||
|
REQUIRE_STAFF = False
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Determine if the current user has the specified permissions.
|
||||||
|
|
||||||
|
- If the user does have the required role, then allow the request
|
||||||
|
- If the user does not have the required role, but is authenticated, then allow read-only access
|
||||||
|
"""
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
|
||||||
|
if not user or not user.is_active or not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not self.REQUIRE_STAFF or user.is_staff:
|
||||||
|
if super().has_permission(request, view):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return request.method in permissions.SAFE_METHODS
|
||||||
|
|
||||||
|
|
||||||
|
class StaffRolePermissionOrReadOnly(RolePermissionOrReadOnly):
|
||||||
|
"""RolePermission which requires staff AND role access, or read-only."""
|
||||||
|
|
||||||
|
REQUIRE_STAFF = True
|
||||||
|
|
||||||
|
|
||||||
class IsSuperuser(permissions.IsAdminUser):
|
class IsSuperuser(permissions.IsAdminUser):
|
||||||
|
@ -12,7 +12,8 @@ from InvenTree.api import read_license_file
|
|||||||
from InvenTree.api_version import INVENTREE_API_VERSION
|
from InvenTree.api_version import INVENTREE_API_VERSION
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
|
||||||
from InvenTree.version import inventreeApiText, parse_version_text
|
from InvenTree.version import inventreeApiText, parse_version_text
|
||||||
from users.models import RuleSet, update_group_roles
|
from users.ruleset import RULESET_NAMES
|
||||||
|
from users.tasks import update_group_roles
|
||||||
|
|
||||||
|
|
||||||
class HTMLAPITests(InvenTreeTestCase):
|
class HTMLAPITests(InvenTreeTestCase):
|
||||||
@ -142,7 +143,7 @@ class ApiAccessTests(InvenTreeAPITestCase):
|
|||||||
role_names = roles.keys()
|
role_names = roles.keys()
|
||||||
|
|
||||||
# By default, no permissions are provided
|
# By default, no permissions are provided
|
||||||
for rule in RuleSet.RULESET_NAMES:
|
for rule in RULESET_NAMES:
|
||||||
self.assertIn(rule, role_names)
|
self.assertIn(rule, role_names)
|
||||||
|
|
||||||
if roles[rule] is None:
|
if roles[rule] is None:
|
||||||
@ -167,7 +168,7 @@ class ApiAccessTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
roles = response.data['roles']
|
roles = response.data['roles']
|
||||||
|
|
||||||
for rule in RuleSet.RULESET_NAMES:
|
for rule in RULESET_NAMES:
|
||||||
self.assertIn(rule, roles.keys())
|
self.assertIn(rule, roles.keys())
|
||||||
|
|
||||||
for perm in ['view', 'add', 'change', 'delete']:
|
for perm in ['view', 'add', 'change', 'delete']:
|
||||||
|
@ -191,7 +191,7 @@ class InvenTreeTaskTests(TestCase):
|
|||||||
|
|
||||||
# Create a staff user (to ensure notifications are sent)
|
# Create a staff user (to ensure notifications are sent)
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
username='staff', password='staffpass', is_staff=True
|
username='staff', password='staffpass', is_staff=False
|
||||||
)
|
)
|
||||||
|
|
||||||
n_tasks = Task.objects.count()
|
n_tasks = Task.objects.count()
|
||||||
@ -216,8 +216,8 @@ class InvenTreeTaskTests(TestCase):
|
|||||||
self.assertEqual(NotificationEntry.objects.count(), n_entries + 0)
|
self.assertEqual(NotificationEntry.objects.count(), n_entries + 0)
|
||||||
self.assertEqual(NotificationMessage.objects.count(), n_messages + 0)
|
self.assertEqual(NotificationMessage.objects.count(), n_messages + 0)
|
||||||
|
|
||||||
# Give them all the permissions
|
# Give them all the required staff level permissions
|
||||||
user.is_superuser = True
|
user.is_staff = True
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
# Create a 'failed' task in the database
|
# Create a 'failed' task in the database
|
||||||
|
@ -262,6 +262,9 @@ class UserSettingsList(SettingsList):
|
|||||||
queryset = common.models.InvenTreeUserSetting.objects.all()
|
queryset = common.models.InvenTreeUserSetting.objects.all()
|
||||||
serializer_class = common.serializers.UserSettingsSerializer
|
serializer_class = common.serializers.UserSettingsSerializer
|
||||||
|
|
||||||
|
# Note: Any user can view and edit their own settings
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
"""Ensure all user settings are created."""
|
"""Ensure all user settings are created."""
|
||||||
common.models.InvenTreeUserSetting.build_default_values(user=request.user)
|
common.models.InvenTreeUserSetting.build_default_values(user=request.user)
|
||||||
@ -771,7 +774,7 @@ class AttachmentList(BulkDeleteMixin, ListCreateAPI):
|
|||||||
- Ensure that the user has correct 'delete' permissions for each model
|
- Ensure that the user has correct 'delete' permissions for each model
|
||||||
"""
|
"""
|
||||||
from common.validators import attachment_model_class_from_label
|
from common.validators import attachment_model_class_from_label
|
||||||
from users.models import check_user_permission
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
model_types = queryset.values_list('model_type', flat=True).distinct()
|
model_types = queryset.values_list('model_type', flat=True).distinct()
|
||||||
|
|
||||||
|
@ -16,7 +16,8 @@ import InvenTree.helpers
|
|||||||
from InvenTree.ready import isImportingData, isRebuildingData
|
from InvenTree.ready import isImportingData, isRebuildingData
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.models import NotificationUserSetting, PluginConfig
|
from plugin.models import NotificationUserSetting, PluginConfig
|
||||||
from users.models import Owner, check_user_permission
|
from users.models import Owner
|
||||||
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
@ -619,7 +619,7 @@ class AttachmentSerializer(InvenTreeModelSerializer):
|
|||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
"""Override the save method to handle the model_type field."""
|
"""Override the save method to handle the model_type field."""
|
||||||
from InvenTree.models import InvenTreeAttachmentMixin
|
from InvenTree.models import InvenTreeAttachmentMixin
|
||||||
from users.models import check_user_permission
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
model_type = self.validated_data.get('model_type', None)
|
model_type = self.validated_data.get('model_type', None)
|
||||||
|
|
||||||
|
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from company.models import Address, Company, Contact, ManufacturerPart, SupplierPart
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
from users.permissions import check_user_permission
|
||||||
from .models import Address, Company, Contact, ManufacturerPart, SupplierPart
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyTest(InvenTreeAPITestCase):
|
class CompanyTest(InvenTreeAPITestCase):
|
||||||
@ -384,7 +384,7 @@ class AddressTest(InvenTreeAPITestCase):
|
|||||||
self.assertIn(key, response.data)
|
self.assertIn(key, response.data)
|
||||||
|
|
||||||
def test_edit(self):
|
def test_edit(self):
|
||||||
"""Test editing an object."""
|
"""Test editing an Address object."""
|
||||||
addr = Address.objects.first()
|
addr = Address.objects.first()
|
||||||
|
|
||||||
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
|
url = reverse('api-address-detail', kwargs={'pk': addr.pk})
|
||||||
@ -392,6 +392,7 @@ class AddressTest(InvenTreeAPITestCase):
|
|||||||
self.patch(url, {'title': 'Hello'}, expected_code=403)
|
self.patch(url, {'title': 'Hello'}, expected_code=403)
|
||||||
|
|
||||||
self.assignRole('purchase_order.change')
|
self.assignRole('purchase_order.change')
|
||||||
|
self.assertTrue(check_user_permission(self.user, Address, 'change'))
|
||||||
|
|
||||||
self.patch(url, {'title': 'World'}, expected_code=200)
|
self.patch(url, {'title': 'World'}, expected_code=200)
|
||||||
|
|
||||||
@ -407,7 +408,10 @@ class AddressTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.delete(url, expected_code=403)
|
self.delete(url, expected_code=403)
|
||||||
|
|
||||||
|
# Assign role, check permission
|
||||||
|
self.assertFalse(check_user_permission(self.user, Address, 'delete'))
|
||||||
self.assignRole('purchase_order.delete')
|
self.assignRole('purchase_order.delete')
|
||||||
|
self.assertTrue(check_user_permission(self.user, Address, 'delete'))
|
||||||
|
|
||||||
self.delete(url, expected_code=204)
|
self.delete(url, expected_code=204)
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ from InvenTree.mixins import (
|
|||||||
RetrieveUpdateAPI,
|
RetrieveUpdateAPI,
|
||||||
RetrieveUpdateDestroyAPI,
|
RetrieveUpdateDestroyAPI,
|
||||||
)
|
)
|
||||||
from users.models import check_user_permission
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
|
|
||||||
class DataImporterPermission(permissions.BasePermission):
|
class DataImporterPermission(permissions.BasePermission):
|
||||||
|
@ -24,7 +24,7 @@ from InvenTree.helpers import hash_barcode
|
|||||||
from InvenTree.mixins import ListAPI, RetrieveDestroyAPI
|
from InvenTree.mixins import ListAPI, RetrieveDestroyAPI
|
||||||
from InvenTree.permissions import IsStaffOrReadOnly
|
from InvenTree.permissions import IsStaffOrReadOnly
|
||||||
from plugin import PluginMixinEnum, registry
|
from plugin import PluginMixinEnum, registry
|
||||||
from users.models import RuleSet
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
from . import serializers as barcode_serializers
|
from . import serializers as barcode_serializers
|
||||||
|
|
||||||
@ -302,14 +302,9 @@ class BarcodeAssign(BarcodeView):
|
|||||||
|
|
||||||
if instance := kwargs.get(label):
|
if instance := kwargs.get(label):
|
||||||
# Check that the user has the required permission
|
# Check that the user has the required permission
|
||||||
app_label = model._meta.app_label
|
if not check_user_permission(request.user, model, 'change'):
|
||||||
model_name = model._meta.model_name
|
|
||||||
|
|
||||||
table = f'{app_label}_{model_name}'
|
|
||||||
|
|
||||||
if not RuleSet.check_table_permission(request.user, table, 'change'):
|
|
||||||
raise PermissionDenied({
|
raise PermissionDenied({
|
||||||
'error': f'You do not have the required permissions for {table}'
|
'error': f'You do not have the required permissions for {model}'
|
||||||
})
|
})
|
||||||
|
|
||||||
instance.assign_barcode(barcode_data=barcode, barcode_hash=barcode_hash)
|
instance.assign_barcode(barcode_data=barcode, barcode_hash=barcode_hash)
|
||||||
@ -365,14 +360,9 @@ class BarcodeUnassign(BarcodeView):
|
|||||||
|
|
||||||
if instance := data.get(label, None):
|
if instance := data.get(label, None):
|
||||||
# Check that the user has the required permission
|
# Check that the user has the required permission
|
||||||
app_label = model._meta.app_label
|
if not check_user_permission(request.user, model, 'change'):
|
||||||
model_name = model._meta.model_name
|
|
||||||
|
|
||||||
table = f'{app_label}_{model_name}'
|
|
||||||
|
|
||||||
if not RuleSet.check_table_permission(request.user, table, 'change'):
|
|
||||||
raise PermissionDenied({
|
raise PermissionDenied({
|
||||||
'error': f'You do not have the required permissions for {table}'
|
'error': f'You do not have the required permissions for {model}'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Unassign the barcode data from the model instance
|
# Unassign the barcode data from the model instance
|
||||||
|
@ -9,6 +9,7 @@ from django.contrib.auth.models import Group
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from users.models import ApiToken, Owner, RuleSet
|
from users.models import ApiToken, Owner, RuleSet
|
||||||
|
from users.ruleset import RULESET_CHOICES
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@ -65,7 +66,7 @@ class RuleSetInline(admin.TabularInline):
|
|||||||
verbose_plural_name = 'Rulesets'
|
verbose_plural_name = 'Rulesets'
|
||||||
fields = ['name', *list(RuleSet.RULE_OPTIONS)]
|
fields = ['name', *list(RuleSet.RULE_OPTIONS)]
|
||||||
readonly_fields = ['name']
|
readonly_fields = ['name']
|
||||||
max_num = len(RuleSet.RULESET_CHOICES)
|
max_num = len(RULESET_CHOICES)
|
||||||
min_num = 1
|
min_num = 1
|
||||||
extra = 0
|
extra = 0
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
@ -26,7 +26,7 @@ from InvenTree.mixins import (
|
|||||||
RetrieveUpdateDestroyAPI,
|
RetrieveUpdateDestroyAPI,
|
||||||
)
|
)
|
||||||
from InvenTree.settings import FRONTEND_URL_BASE
|
from InvenTree.settings import FRONTEND_URL_BASE
|
||||||
from users.models import ApiToken, Owner, UserProfile
|
from users.models import ApiToken, Owner, RuleSet, UserProfile
|
||||||
from users.serializers import (
|
from users.serializers import (
|
||||||
ApiTokenSerializer,
|
ApiTokenSerializer,
|
||||||
ExtendedUserSerializer,
|
ExtendedUserSerializer,
|
||||||
@ -35,6 +35,7 @@ from users.serializers import (
|
|||||||
MeUserSerializer,
|
MeUserSerializer,
|
||||||
OwnerSerializer,
|
OwnerSerializer,
|
||||||
RoleSerializer,
|
RoleSerializer,
|
||||||
|
RuleSetSerializer,
|
||||||
UserCreateSerializer,
|
UserCreateSerializer,
|
||||||
UserProfileSerializer,
|
UserProfileSerializer,
|
||||||
)
|
)
|
||||||
@ -45,7 +46,7 @@ logger = structlog.get_logger('inventree')
|
|||||||
class OwnerList(ListAPI):
|
class OwnerList(ListAPI):
|
||||||
"""List API endpoint for Owner model.
|
"""List API endpoint for Owner model.
|
||||||
|
|
||||||
Cannot create.
|
Cannot create a new Owner object via the API, but can view existing instances.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
queryset = Owner.objects.all()
|
queryset = Owner.objects.all()
|
||||||
@ -127,17 +128,28 @@ class RoleDetails(RetrieveAPI):
|
|||||||
|
|
||||||
|
|
||||||
class UserDetail(RetrieveUpdateDestroyAPI):
|
class UserDetail(RetrieveUpdateDestroyAPI):
|
||||||
"""Detail endpoint for a single user."""
|
"""Detail endpoint for a single user.
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
- Staff users (who also have the 'admin' role) can perform write operations
|
||||||
|
- Otherwise authenticated users have read-only access
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = User.objects.all()
|
queryset = User.objects.all()
|
||||||
serializer_class = ExtendedUserSerializer
|
serializer_class = ExtendedUserSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
|
||||||
|
|
||||||
|
|
||||||
class MeUserDetail(RetrieveUpdateAPI, UserDetail):
|
class MeUserDetail(RetrieveUpdateAPI, UserDetail):
|
||||||
"""Detail endpoint for current user."""
|
"""Detail endpoint for current user.
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
- User can edit their own details via this endpoint
|
||||||
|
- Only a subset of fields are available here
|
||||||
|
"""
|
||||||
|
|
||||||
serializer_class = MeUserSerializer
|
serializer_class = MeUserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'}
|
rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'}
|
||||||
|
|
||||||
@ -154,14 +166,19 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail):
|
|||||||
|
|
||||||
|
|
||||||
class UserList(ListCreateAPI):
|
class UserList(ListCreateAPI):
|
||||||
"""List endpoint for detail on all users."""
|
"""List endpoint for detail on all users.
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
- Staff users (who also have the 'admin' role) can perform write operations
|
||||||
|
- Otherwise authenticated users have read-only access
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = User.objects.all()
|
queryset = User.objects.all()
|
||||||
serializer_class = UserCreateSerializer
|
serializer_class = UserCreateSerializer
|
||||||
permission_classes = [
|
|
||||||
permissions.IsAuthenticated,
|
# User must have the right role, AND be a staff user, else read-only
|
||||||
InvenTree.permissions.IsSuperuserOrReadOnly,
|
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
|
||||||
]
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
search_fields = ['first_name', 'last_name', 'username']
|
search_fields = ['first_name', 'last_name', 'username']
|
||||||
@ -180,40 +197,78 @@ class UserList(ListCreateAPI):
|
|||||||
|
|
||||||
|
|
||||||
class GroupMixin:
|
class GroupMixin:
|
||||||
"""Mixin for Group API endpoints to add permissions filter."""
|
"""Mixin for Group API endpoints to add permissions filter.
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
- Staff users (who also have the 'admin' role) can perform write operations
|
||||||
|
- Otherwise authenticated users have read-only access
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = Group.objects.all()
|
||||||
|
serializer_class = GroupSerializer
|
||||||
|
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
"""Return serializer instance for this endpoint."""
|
"""Return serializer instance for this endpoint."""
|
||||||
# Do we wish to include extra detail?
|
# Do we wish to include extra detail?
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
|
|
||||||
|
kwargs['role_detail'] = InvenTree.helpers.str2bool(
|
||||||
|
params.get('role_detail', True)
|
||||||
|
)
|
||||||
|
|
||||||
kwargs['permission_detail'] = InvenTree.helpers.str2bool(
|
kwargs['permission_detail'] = InvenTree.helpers.str2bool(
|
||||||
params.get('permission_detail', None)
|
params.get('permission_detail', None)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
kwargs['user_detail'] = InvenTree.helpers.str2bool(
|
||||||
|
params.get('user_detail', None)
|
||||||
|
)
|
||||||
|
|
||||||
kwargs['context'] = self.get_serializer_context()
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
|
||||||
return super().get_serializer(*args, **kwargs)
|
return super().get_serializer(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Return queryset for this endpoint.
|
||||||
|
|
||||||
|
Note that the queryset is filtered by the permissions of the current user.
|
||||||
|
"""
|
||||||
|
return super().get_queryset().prefetch_related('rule_sets', 'user_set')
|
||||||
|
|
||||||
|
|
||||||
class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI):
|
class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""Detail endpoint for a particular auth group."""
|
"""Detail endpoint for a particular auth group."""
|
||||||
|
|
||||||
queryset = Group.objects.all()
|
|
||||||
serializer_class = GroupSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
|
|
||||||
class GroupList(GroupMixin, ListCreateAPI):
|
class GroupList(GroupMixin, ListCreateAPI):
|
||||||
"""List endpoint for all auth groups."""
|
"""List endpoint for all auth groups."""
|
||||||
|
|
||||||
queryset = Group.objects.all()
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
serializer_class = GroupSerializer
|
search_fields = ['name']
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
ordering_fields = ['name']
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSetMixin:
|
||||||
|
"""Mixin for RuleSet API endpoints."""
|
||||||
|
|
||||||
|
queryset = RuleSet.objects.all()
|
||||||
|
serializer_class = RuleSetSerializer
|
||||||
|
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSetList(RuleSetMixin, ListAPI):
|
||||||
|
"""List endpoint for all RuleSet instances."""
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
search_fields = ['name']
|
search_fields = ['name']
|
||||||
|
|
||||||
ordering_fields = ['name']
|
ordering_fields = ['name']
|
||||||
|
filterset_fields = ['group', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSetDetail(RuleSetMixin, RetrieveUpdateDestroyAPI):
|
||||||
|
"""Detail endpoint for a particular RuleSet instance."""
|
||||||
|
|
||||||
|
|
||||||
class GetAuthToken(GenericAPIView):
|
class GetAuthToken(GenericAPIView):
|
||||||
@ -362,7 +417,12 @@ class LoginRedirect(RedirectView):
|
|||||||
|
|
||||||
|
|
||||||
class UserProfileDetail(RetrieveUpdateAPI):
|
class UserProfileDetail(RetrieveUpdateAPI):
|
||||||
"""Detail endpoint for the user profile."""
|
"""Detail endpoint for the user profile.
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
- Any authenticated user has write access against this endpoint
|
||||||
|
- The endpoint always returns the profile associated with the current user
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = UserProfile.objects.all()
|
queryset = UserProfile.objects.all()
|
||||||
serializer_class = UserProfileSerializer
|
serializer_class = UserProfileSerializer
|
||||||
@ -399,6 +459,13 @@ user_urls = [
|
|||||||
path('', GroupList.as_view(), name='api-group-list'),
|
path('', GroupList.as_view(), name='api-group-list'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
'ruleset/',
|
||||||
|
include([
|
||||||
|
path('<int:pk>/', RuleSetDetail.as_view(), name='api-ruleset-detail'),
|
||||||
|
path('', RuleSetList.as_view(), name='api-ruleset-list'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
path('<int:pk>/', UserDetail.as_view(), name='api-user-detail'),
|
path('<int:pk>/', UserDetail.as_view(), name='api-user-detail'),
|
||||||
path('', UserList.as_view(), name='api-user-list'),
|
path('', UserList.as_view(), name='api-user-list'),
|
||||||
]
|
]
|
||||||
|
@ -30,7 +30,9 @@ class UsersConfig(AppConfig):
|
|||||||
|
|
||||||
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||||
try:
|
try:
|
||||||
self.assign_permissions()
|
from users.tasks import rebuild_all_permissions
|
||||||
|
|
||||||
|
rebuild_all_permissions()
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -39,24 +41,6 @@ class UsersConfig(AppConfig):
|
|||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def assign_permissions(self):
|
|
||||||
"""Update role permissions for existing groups."""
|
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
|
|
||||||
from users.models import RuleSet, update_group_roles
|
|
||||||
|
|
||||||
# First, delete any rule_set objects which have become outdated!
|
|
||||||
for rule in RuleSet.objects.all():
|
|
||||||
if (
|
|
||||||
rule.name not in RuleSet.RULESET_NAMES
|
|
||||||
): # pragma: no cover # can not change ORM without the app being loaded
|
|
||||||
logger.info('Deleting outdated ruleset: %s', rule.name)
|
|
||||||
rule.delete()
|
|
||||||
|
|
||||||
# Update group permission assignments for all groups
|
|
||||||
for group in Group.objects.all():
|
|
||||||
update_group_roles(group)
|
|
||||||
|
|
||||||
def update_owners(self):
|
def update_owners(self):
|
||||||
"""Create an 'owner' object for each user and group instance."""
|
"""Create an 'owner' object for each user and group instance."""
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 3.2.18 on 2023-03-14 10:07
|
# Generated by Django 3.2.18 on 2023-03-14 10:07
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import users.models
|
from users.ruleset import RULESET_CHOICES
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -14,6 +14,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='ruleset',
|
model_name='ruleset',
|
||||||
name='name',
|
name='name',
|
||||||
field=models.CharField(choices=users.models.RuleSet.RULESET_CHOICES, help_text='Permission set', max_length=50),
|
field=models.CharField(choices=RULESET_CHOICES, help_text='Permission set', max_length=50),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -5,7 +5,7 @@ import datetime
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group, Permission, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
@ -21,11 +21,12 @@ import structlog
|
|||||||
from allauth.account.models import EmailAddress
|
from allauth.account.models import EmailAddress
|
||||||
from rest_framework.authtoken.models import Token as AuthToken
|
from rest_framework.authtoken.models import Token as AuthToken
|
||||||
|
|
||||||
import InvenTree.cache
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.models
|
import InvenTree.models
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from InvenTree.ready import canAppAccessDatabase, isImportingData
|
from InvenTree.ready import isImportingData
|
||||||
|
|
||||||
|
from .ruleset import RULESET_CHOICES, get_ruleset_models
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
@ -215,179 +216,6 @@ class RuleSet(models.Model):
|
|||||||
which are then handled using the normal django permissions approach.
|
which are then handled using the normal django permissions approach.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RULESET_CHOICES = [
|
|
||||||
('admin', _('Admin')),
|
|
||||||
('part_category', _('Part Categories')),
|
|
||||||
('part', _('Parts')),
|
|
||||||
('stocktake', _('Stocktake')),
|
|
||||||
('stock_location', _('Stock Locations')),
|
|
||||||
('stock', _('Stock Items')),
|
|
||||||
('build', _('Build Orders')),
|
|
||||||
('purchase_order', _('Purchase Orders')),
|
|
||||||
('sales_order', _('Sales Orders')),
|
|
||||||
('return_order', _('Return Orders')),
|
|
||||||
]
|
|
||||||
|
|
||||||
RULESET_NAMES = [choice[0] for choice in RULESET_CHOICES]
|
|
||||||
|
|
||||||
RULESET_PERMISSIONS = ['view', 'add', 'change', 'delete']
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_ruleset_models():
|
|
||||||
"""Return a dictionary of models associated with each ruleset."""
|
|
||||||
ruleset_models = {
|
|
||||||
'admin': [
|
|
||||||
'auth_group',
|
|
||||||
'auth_user',
|
|
||||||
'auth_permission',
|
|
||||||
'users_apitoken',
|
|
||||||
'users_ruleset',
|
|
||||||
'report_labeltemplate',
|
|
||||||
'report_reportasset',
|
|
||||||
'report_reportsnippet',
|
|
||||||
'report_reporttemplate',
|
|
||||||
'account_emailaddress',
|
|
||||||
'account_emailconfirmation',
|
|
||||||
'socialaccount_socialaccount',
|
|
||||||
'socialaccount_socialapp',
|
|
||||||
'socialaccount_socialtoken',
|
|
||||||
'otp_totp_totpdevice',
|
|
||||||
'otp_static_statictoken',
|
|
||||||
'otp_static_staticdevice',
|
|
||||||
'mfa_authenticator',
|
|
||||||
'plugin_pluginconfig',
|
|
||||||
'plugin_pluginsetting',
|
|
||||||
'plugin_notificationusersetting',
|
|
||||||
'common_barcodescanresult',
|
|
||||||
'common_newsfeedentry',
|
|
||||||
'taggit_tag',
|
|
||||||
'taggit_taggeditem',
|
|
||||||
'flags_flagstate',
|
|
||||||
'machine_machineconfig',
|
|
||||||
'machine_machinesetting',
|
|
||||||
],
|
|
||||||
'part_category': [
|
|
||||||
'part_partcategory',
|
|
||||||
'part_partcategoryparametertemplate',
|
|
||||||
'part_partcategorystar',
|
|
||||||
],
|
|
||||||
'part': [
|
|
||||||
'part_part',
|
|
||||||
'part_partpricing',
|
|
||||||
'part_bomitem',
|
|
||||||
'part_bomitemsubstitute',
|
|
||||||
'part_partsellpricebreak',
|
|
||||||
'part_partinternalpricebreak',
|
|
||||||
'part_parttesttemplate',
|
|
||||||
'part_partparametertemplate',
|
|
||||||
'part_partparameter',
|
|
||||||
'part_partrelated',
|
|
||||||
'part_partstar',
|
|
||||||
'part_partcategorystar',
|
|
||||||
'company_supplierpart',
|
|
||||||
'company_manufacturerpart',
|
|
||||||
'company_manufacturerpartparameter',
|
|
||||||
],
|
|
||||||
'stocktake': ['part_partstocktake', 'part_partstocktakereport'],
|
|
||||||
'stock_location': ['stock_stocklocation', 'stock_stocklocationtype'],
|
|
||||||
'stock': [
|
|
||||||
'stock_stockitem',
|
|
||||||
'stock_stockitemtracking',
|
|
||||||
'stock_stockitemtestresult',
|
|
||||||
],
|
|
||||||
'build': [
|
|
||||||
'part_part',
|
|
||||||
'part_partcategory',
|
|
||||||
'part_bomitem',
|
|
||||||
'part_bomitemsubstitute',
|
|
||||||
'build_build',
|
|
||||||
'build_builditem',
|
|
||||||
'build_buildline',
|
|
||||||
'stock_stockitem',
|
|
||||||
'stock_stocklocation',
|
|
||||||
],
|
|
||||||
'purchase_order': [
|
|
||||||
'company_company',
|
|
||||||
'company_contact',
|
|
||||||
'company_address',
|
|
||||||
'company_manufacturerpart',
|
|
||||||
'company_manufacturerpartparameter',
|
|
||||||
'company_supplierpart',
|
|
||||||
'company_supplierpricebreak',
|
|
||||||
'order_purchaseorder',
|
|
||||||
'order_purchaseorderlineitem',
|
|
||||||
'order_purchaseorderextraline',
|
|
||||||
],
|
|
||||||
'sales_order': [
|
|
||||||
'company_company',
|
|
||||||
'company_contact',
|
|
||||||
'company_address',
|
|
||||||
'order_salesorder',
|
|
||||||
'order_salesorderallocation',
|
|
||||||
'order_salesorderlineitem',
|
|
||||||
'order_salesorderextraline',
|
|
||||||
'order_salesordershipment',
|
|
||||||
],
|
|
||||||
'return_order': [
|
|
||||||
'company_company',
|
|
||||||
'company_contact',
|
|
||||||
'company_address',
|
|
||||||
'order_returnorder',
|
|
||||||
'order_returnorderlineitem',
|
|
||||||
'order_returnorderextraline',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.SITE_MULTI:
|
|
||||||
ruleset_models['admin'].append('sites_site')
|
|
||||||
|
|
||||||
return ruleset_models
|
|
||||||
|
|
||||||
# Database models we ignore permission sets for
|
|
||||||
@staticmethod
|
|
||||||
def get_ruleset_ignore():
|
|
||||||
"""Return a list of database tables which do not require permissions."""
|
|
||||||
return [
|
|
||||||
# Core django models (not user configurable)
|
|
||||||
'admin_logentry',
|
|
||||||
'contenttypes_contenttype',
|
|
||||||
# Models which currently do not require permissions
|
|
||||||
'common_attachment',
|
|
||||||
'common_customunit',
|
|
||||||
'common_dataoutput',
|
|
||||||
'common_inventreesetting',
|
|
||||||
'common_inventreeusersetting',
|
|
||||||
'common_notificationentry',
|
|
||||||
'common_notificationmessage',
|
|
||||||
'common_notesimage',
|
|
||||||
'common_projectcode',
|
|
||||||
'common_webhookendpoint',
|
|
||||||
'common_webhookmessage',
|
|
||||||
'common_inventreecustomuserstatemodel',
|
|
||||||
'common_selectionlistentry',
|
|
||||||
'common_selectionlist',
|
|
||||||
'users_owner',
|
|
||||||
'users_userprofile', # User profile is handled in the serializer - only own user can change
|
|
||||||
# Third-party tables
|
|
||||||
'error_report_error',
|
|
||||||
'exchange_rate',
|
|
||||||
'exchange_exchangebackend',
|
|
||||||
'usersessions_usersession',
|
|
||||||
'sessions_session',
|
|
||||||
# Django-q
|
|
||||||
'django_q_ormq',
|
|
||||||
'django_q_failure',
|
|
||||||
'django_q_task',
|
|
||||||
'django_q_schedule',
|
|
||||||
'django_q_success',
|
|
||||||
# Importing
|
|
||||||
'importer_dataimportsession',
|
|
||||||
'importer_dataimportcolumnmap',
|
|
||||||
'importer_dataimportrow',
|
|
||||||
]
|
|
||||||
|
|
||||||
RULESET_CHANGE_INHERIT = [('part', 'partparameter'), ('part', 'bomitem')]
|
|
||||||
|
|
||||||
RULE_OPTIONS = ['can_view', 'can_add', 'can_change', 'can_delete']
|
RULE_OPTIONS = ['can_view', 'can_add', 'can_change', 'can_delete']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -395,6 +223,11 @@ class RuleSet(models.Model):
|
|||||||
|
|
||||||
unique_together = (('name', 'group'),)
|
unique_together = (('name', 'group'),)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
"""Return the translated label for this ruleset."""
|
||||||
|
return dict(RULESET_CHOICES).get(self.name, self.name)
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=RULESET_CHOICES,
|
choices=RULESET_CHOICES,
|
||||||
@ -431,48 +264,6 @@ class RuleSet(models.Model):
|
|||||||
help_text=_('Permission to delete items'),
|
help_text=_('Permission to delete items'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def check_table_permission(cls, user: User, table, permission):
|
|
||||||
"""Check if the provided user has the specified permission against the table."""
|
|
||||||
# Superuser knows no bounds
|
|
||||||
if user.is_superuser:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# If the table does *not* require permissions
|
|
||||||
if table in cls.get_ruleset_ignore():
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Work out which roles touch the given table
|
|
||||||
for role in cls.RULESET_NAMES:
|
|
||||||
if table in cls.get_ruleset_models()[role]:
|
|
||||||
if check_user_role(user, role, permission):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check for children models which inherits from parent role
|
|
||||||
for parent, child in cls.RULESET_CHANGE_INHERIT:
|
|
||||||
# Get child model name
|
|
||||||
parent_child_string = f'{parent}_{child}'
|
|
||||||
|
|
||||||
if parent_child_string == table:
|
|
||||||
# Check if parent role has change permission
|
|
||||||
if check_user_role(user, parent, 'change'):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Print message instead of throwing an error
|
|
||||||
name = getattr(user, 'name', user.pk)
|
|
||||||
logger.debug(
|
|
||||||
"User '%s' failed permission check for %s.%s", name, table, permission
|
|
||||||
)
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_model_permission_string(model, permission):
|
|
||||||
"""Construct the correctly formatted permission string, given the app_model name, and the permission type."""
|
|
||||||
model, app = split_model(model)
|
|
||||||
|
|
||||||
return f'{app}.{permission}_{model}'
|
|
||||||
|
|
||||||
def __str__(self, debug=False): # pragma: no cover
|
def __str__(self, debug=False): # pragma: no cover
|
||||||
"""Ruleset string representation."""
|
"""Ruleset string representation."""
|
||||||
if debug:
|
if debug:
|
||||||
@ -500,263 +291,12 @@ class RuleSet(models.Model):
|
|||||||
|
|
||||||
if self.group:
|
if self.group:
|
||||||
# Update the group too!
|
# Update the group too!
|
||||||
|
# Note: This will trigger the 'update_group_roles' signal
|
||||||
self.group.save()
|
self.group.save()
|
||||||
|
|
||||||
def get_models(self):
|
def get_models(self):
|
||||||
"""Return the database tables / models that this ruleset covers."""
|
"""Return the database tables / models that this ruleset covers."""
|
||||||
return self.get_ruleset_models().get(self.name, [])
|
return get_ruleset_models().get(self.name, [])
|
||||||
|
|
||||||
|
|
||||||
def split_model(model):
|
|
||||||
"""Get modelname and app from modelstring."""
|
|
||||||
*app, model = model.split('_')
|
|
||||||
|
|
||||||
# handle models that have
|
|
||||||
app = '_'.join(app) if len(app) > 1 else app[0]
|
|
||||||
|
|
||||||
return model, app
|
|
||||||
|
|
||||||
|
|
||||||
def split_permission(app, perm):
|
|
||||||
"""Split permission string into permission and model."""
|
|
||||||
permission_name, *model = perm.split('_')
|
|
||||||
# handle models that have underscores
|
|
||||||
if len(model) > 1: # pragma: no cover
|
|
||||||
app += '_' + '_'.join(model[:-1])
|
|
||||||
perm = permission_name + '_' + model[-1:][0]
|
|
||||||
model = model[-1:][0]
|
|
||||||
return perm, model
|
|
||||||
|
|
||||||
|
|
||||||
def update_group_roles(group, debug=False):
|
|
||||||
"""Iterates through all of the RuleSets associated with the group, and ensures that the correct permissions are either applied or removed from the group.
|
|
||||||
|
|
||||||
This function is called under the following conditions:
|
|
||||||
|
|
||||||
a) Whenever the InvenTree database is launched
|
|
||||||
b) Whenever the group object is updated
|
|
||||||
|
|
||||||
The RuleSet model has complete control over the permissions applied to any group.
|
|
||||||
"""
|
|
||||||
if not canAppAccessDatabase(allow_test=True):
|
|
||||||
return # pragma: no cover
|
|
||||||
|
|
||||||
# List of permissions already associated with this group
|
|
||||||
group_permissions = set()
|
|
||||||
|
|
||||||
# Iterate through each permission already assigned to this group,
|
|
||||||
# and create a simplified permission key string
|
|
||||||
for p in group.permissions.all().prefetch_related('content_type'):
|
|
||||||
(permission, app, model) = p.natural_key()
|
|
||||||
permission_string = f'{app}.{permission}'
|
|
||||||
group_permissions.add(permission_string)
|
|
||||||
|
|
||||||
# List of permissions which must be added to the group
|
|
||||||
permissions_to_add = set()
|
|
||||||
|
|
||||||
# List of permissions which must be removed from the group
|
|
||||||
permissions_to_delete = set()
|
|
||||||
|
|
||||||
def add_model(name, action, allowed):
|
|
||||||
"""Add a new model to the pile.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: The name of the model e.g. part_part
|
|
||||||
action: The permission action e.g. view
|
|
||||||
allowed: Whether or not the action is allowed
|
|
||||||
"""
|
|
||||||
if action not in ['view', 'add', 'change', 'delete']: # pragma: no cover
|
|
||||||
raise ValueError(f'Action {action} is invalid')
|
|
||||||
|
|
||||||
permission_string = RuleSet.get_model_permission_string(model, action)
|
|
||||||
|
|
||||||
if allowed:
|
|
||||||
# An 'allowed' action is always preferenced over a 'forbidden' action
|
|
||||||
if permission_string in permissions_to_delete:
|
|
||||||
permissions_to_delete.remove(permission_string)
|
|
||||||
|
|
||||||
permissions_to_add.add(permission_string)
|
|
||||||
|
|
||||||
elif permission_string not in permissions_to_add:
|
|
||||||
permissions_to_delete.add(permission_string)
|
|
||||||
|
|
||||||
# Pre-fetch all the RuleSet objects
|
|
||||||
rulesets = {
|
|
||||||
r.name: r for r in RuleSet.objects.filter(group=group).prefetch_related('group')
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get all the rulesets associated with this group
|
|
||||||
for r in RuleSet.RULESET_CHOICES:
|
|
||||||
rulename = r[0]
|
|
||||||
|
|
||||||
if rulename in rulesets:
|
|
||||||
ruleset = rulesets[rulename]
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
ruleset = RuleSet.objects.get(group=group, name=rulename)
|
|
||||||
except RuleSet.DoesNotExist:
|
|
||||||
ruleset = RuleSet.objects.create(group=group, name=rulename)
|
|
||||||
|
|
||||||
# Which database tables does this RuleSet touch?
|
|
||||||
models = ruleset.get_models()
|
|
||||||
|
|
||||||
for model in models:
|
|
||||||
# Keep track of the available permissions for each model
|
|
||||||
|
|
||||||
add_model(model, 'view', ruleset.can_view)
|
|
||||||
add_model(model, 'add', ruleset.can_add)
|
|
||||||
add_model(model, 'change', ruleset.can_change)
|
|
||||||
add_model(model, 'delete', ruleset.can_delete)
|
|
||||||
|
|
||||||
def get_permission_object(permission_string):
|
|
||||||
"""Find the permission object in the database, from the simplified permission string.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
permission_string: a simplified permission_string e.g. 'part.view_partcategory'
|
|
||||||
|
|
||||||
Returns the permission object in the database associated with the permission string
|
|
||||||
"""
|
|
||||||
(app, perm) = permission_string.split('.')
|
|
||||||
|
|
||||||
perm, model = split_permission(app, perm)
|
|
||||||
|
|
||||||
try:
|
|
||||||
content_type = ContentType.objects.get(app_label=app, model=model)
|
|
||||||
permission = Permission.objects.get(
|
|
||||||
content_type=content_type, codename=perm
|
|
||||||
)
|
|
||||||
except ContentType.DoesNotExist: # pragma: no cover
|
|
||||||
# logger.warning(
|
|
||||||
# "Error: Could not find permission matching '%s'", permission_string
|
|
||||||
# )
|
|
||||||
permission = None
|
|
||||||
|
|
||||||
return permission
|
|
||||||
|
|
||||||
# Add any required permissions to the group
|
|
||||||
for perm in permissions_to_add:
|
|
||||||
# Ignore if permission is already in the group
|
|
||||||
if perm in group_permissions:
|
|
||||||
continue
|
|
||||||
|
|
||||||
permission = get_permission_object(perm)
|
|
||||||
|
|
||||||
if permission:
|
|
||||||
group.permissions.add(permission)
|
|
||||||
|
|
||||||
if debug: # pragma: no cover
|
|
||||||
logger.debug('Adding permission %s to group %s', perm, group.name)
|
|
||||||
|
|
||||||
# Remove any extra permissions from the group
|
|
||||||
for perm in permissions_to_delete:
|
|
||||||
# Ignore if the permission is not already assigned
|
|
||||||
if perm not in group_permissions:
|
|
||||||
continue
|
|
||||||
|
|
||||||
permission = get_permission_object(perm)
|
|
||||||
|
|
||||||
if permission:
|
|
||||||
group.permissions.remove(permission)
|
|
||||||
|
|
||||||
if debug: # pragma: no cover
|
|
||||||
logger.debug('Removing permission %s from group %s', perm, group.name)
|
|
||||||
|
|
||||||
# Enable all action permissions for certain children models
|
|
||||||
# if parent model has 'change' permission
|
|
||||||
for parent, child in RuleSet.RULESET_CHANGE_INHERIT:
|
|
||||||
parent_child_string = f'{parent}_{child}'
|
|
||||||
|
|
||||||
# Check each type of permission
|
|
||||||
for action in ['view', 'change', 'add', 'delete']:
|
|
||||||
parent_perm = f'{parent}.{action}_{parent}'
|
|
||||||
|
|
||||||
if parent_perm in group_permissions:
|
|
||||||
child_perm = f'{parent}.{action}_{child}'
|
|
||||||
|
|
||||||
# Check if child permission not already in group
|
|
||||||
if child_perm not in group_permissions:
|
|
||||||
# Create permission object
|
|
||||||
add_model(parent_child_string, action, ruleset.can_delete)
|
|
||||||
# Add to group
|
|
||||||
permission = get_permission_object(child_perm)
|
|
||||||
if permission:
|
|
||||||
group.permissions.add(permission)
|
|
||||||
logger.debug(
|
|
||||||
'Adding permission %s to group %s', child_perm, group.name
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_user_permission(user: User, model: models.Model, permission: str) -> bool:
|
|
||||||
"""Check if the user has a particular permission against a given model type.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
user: The user object to check
|
|
||||||
model: The model class to check (e.g. 'part')
|
|
||||||
permission: The permission to check (e.g. 'view' / 'delete')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the user has the specified permission
|
|
||||||
"""
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if user.is_superuser:
|
|
||||||
return True
|
|
||||||
|
|
||||||
permission_name = f'{model._meta.app_label}.{permission}_{model._meta.model_name}'
|
|
||||||
return user.has_perm(permission_name)
|
|
||||||
|
|
||||||
|
|
||||||
def check_user_role(user: User, role: str, permission: str) -> bool:
|
|
||||||
"""Check if a user has a particular role:permission combination.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
user: The user object to check
|
|
||||||
role: The role to check (e.g. 'part' / 'stock')
|
|
||||||
permission: The permission to check (e.g. 'view' / 'delete')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the user has the specified role:permission combination
|
|
||||||
"""
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if user.is_superuser:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# First, check the session cache
|
|
||||||
cache_key = f'role_{user.pk}_{role}_{permission}'
|
|
||||||
result = InvenTree.cache.get_session_cache(cache_key)
|
|
||||||
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Default for no match
|
|
||||||
result = False
|
|
||||||
|
|
||||||
for group in user.groups.all():
|
|
||||||
for rule in group.rule_sets.all():
|
|
||||||
if rule.name == role:
|
|
||||||
if permission == 'add' and rule.can_add:
|
|
||||||
result = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if permission == 'change' and rule.can_change:
|
|
||||||
result = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if permission == 'view' and rule.can_view:
|
|
||||||
result = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if permission == 'delete' and rule.can_delete:
|
|
||||||
result = True
|
|
||||||
break
|
|
||||||
|
|
||||||
# Save result to session-cache
|
|
||||||
InvenTree.cache.set_session_cache(cache_key, result)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class Owner(models.Model):
|
class Owner(models.Model):
|
||||||
@ -943,6 +483,8 @@ def create_missing_rule_sets(sender, instance, **kwargs):
|
|||||||
|
|
||||||
As the linked RuleSet instances are saved *before* the Group, then we can now use these RuleSet values to update the group permissions.
|
As the linked RuleSet instances are saved *before* the Group, then we can now use these RuleSet values to update the group permissions.
|
||||||
"""
|
"""
|
||||||
|
from users.tasks import update_group_roles
|
||||||
|
|
||||||
update_group_roles(instance)
|
update_group_roles(instance)
|
||||||
|
|
||||||
|
|
||||||
|
183
src/backend/InvenTree/users/permissions.py
Normal file
183
src/backend/InvenTree/users/permissions.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
"""Helper functions for user permission checks."""
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
import InvenTree.cache
|
||||||
|
from users.ruleset import RULESET_CHANGE_INHERIT, get_ruleset_ignore, get_ruleset_models
|
||||||
|
|
||||||
|
|
||||||
|
def split_model(model_label: str) -> tuple[str, str]:
|
||||||
|
"""Split a model string into its component parts.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
model_label: The model class to check (e.g. 'part_partcategory')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of the model and app names (e.g. ('partcategory', 'part'))
|
||||||
|
"""
|
||||||
|
*app, model = model_label.split('_')
|
||||||
|
app = '_'.join(app) if len(app) > 1 else app[0]
|
||||||
|
return model, app
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_permission_string(model: models.Model, permission: str) -> str:
|
||||||
|
"""Generate a permission string for a given model and permission type.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
model: The model class to check
|
||||||
|
permission: The permission to check (e.g. 'view' / 'delete')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The permission string (e.g. 'part.view_part')
|
||||||
|
"""
|
||||||
|
model, app = split_model(model)
|
||||||
|
return f'{app}.{permission}_{model}'
|
||||||
|
|
||||||
|
|
||||||
|
def split_permission(app: str, perm: str) -> tuple[str, str]:
|
||||||
|
"""Split the permission string into its component parts.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
app: The application name (e.g. 'part')
|
||||||
|
perm: The permission string (e.g. 'view_part' / 'delete_partcategory')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of the permission and model names
|
||||||
|
"""
|
||||||
|
permission_name, *model = perm.split('_')
|
||||||
|
|
||||||
|
# Handle models that have underscores
|
||||||
|
if len(model) > 1: # pragma: no cover
|
||||||
|
app += '_' + '_'.join(model[:-1])
|
||||||
|
perm = permission_name + '_' + model[-1:][0]
|
||||||
|
model = model[-1:][0]
|
||||||
|
return perm, model
|
||||||
|
|
||||||
|
|
||||||
|
def model_permission_string(model: models.Model, permission: str) -> str:
|
||||||
|
"""Generate a permission string for a given model and permission type.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
model: The model class to check (e.g. 'part')
|
||||||
|
permission: The permission to check (e.g. 'view' / 'delete')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The permission string (e.g. 'part.view_part')
|
||||||
|
"""
|
||||||
|
return f'{model._meta.app_label}.{permission}_{model._meta.model_name}'
|
||||||
|
|
||||||
|
|
||||||
|
def check_user_role(
|
||||||
|
user: User, role: str, permission: str, allow_inactive: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a user has a particular role:permission combination.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
user: The user object to check
|
||||||
|
role: The role to check (e.g. 'part' / 'stock')
|
||||||
|
permission: The permission to check (e.g. 'view' / 'delete')
|
||||||
|
allow_inactive: If False, disallow inactive users from having permissions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the user has the specified role:permission combination
|
||||||
|
|
||||||
|
Note: As this check may be called frequently, we cache the result in the session cache.
|
||||||
|
"""
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not user.is_active and not allow_inactive:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# First, check the session cache
|
||||||
|
cache_key = f'role_{user.pk}_{role}_{permission}'
|
||||||
|
result = InvenTree.cache.get_session_cache(cache_key)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Default for no match
|
||||||
|
result = False
|
||||||
|
|
||||||
|
for group in user.groups.all():
|
||||||
|
for rule in group.rule_sets.all():
|
||||||
|
if rule.name == role:
|
||||||
|
# Check if the rule has the specified permission
|
||||||
|
# e.g. "view" role maps to "can_view" attribute
|
||||||
|
if getattr(rule, f'can_{permission}', False):
|
||||||
|
result = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Save result to session-cache
|
||||||
|
InvenTree.cache.set_session_cache(cache_key, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def check_user_permission(
|
||||||
|
user: User, model: models.Model, permission: str, allow_inactive: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Check if the user has a particular permission against a given model type.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
user: The user object to check
|
||||||
|
model: The model class to check (e.g. 'part')
|
||||||
|
permission: The permission to check (e.g. 'view' / 'delete')
|
||||||
|
allow_inactive: If False, disallow inactive users from having permissions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the user has the specified permission
|
||||||
|
|
||||||
|
Note: As this check may be called frequently, we cache the result in the session cache.
|
||||||
|
"""
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not user.is_active and not allow_inactive:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
|
||||||
|
table_name = f'{model._meta.app_label}_{model._meta.model_name}'
|
||||||
|
|
||||||
|
# Particular table does not require specific permissions
|
||||||
|
if table_name in get_ruleset_ignore():
|
||||||
|
return True
|
||||||
|
|
||||||
|
for role, table_names in get_ruleset_models().items():
|
||||||
|
if table_name in table_names:
|
||||||
|
if check_user_role(user, role, permission):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for children models which inherits from parent role
|
||||||
|
for parent, child in RULESET_CHANGE_INHERIT:
|
||||||
|
# Get child model name
|
||||||
|
parent_child_string = f'{parent}_{child}'
|
||||||
|
|
||||||
|
if parent_child_string == table_name:
|
||||||
|
# Check if parent role has change permission
|
||||||
|
if check_user_role(user, parent, 'change'):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Generate the permission name based on the model and permission
|
||||||
|
# e.g. 'part.view_part'
|
||||||
|
permission_name = f'{model._meta.app_label}.{permission}_{model._meta.model_name}'
|
||||||
|
|
||||||
|
# First, check the session cache
|
||||||
|
cache_key = f'permission_{user.pk}_{permission_name}'
|
||||||
|
result = InvenTree.cache.get_session_cache(cache_key)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
result = user.has_perm(permission_name)
|
||||||
|
|
||||||
|
# Save result to session-cache
|
||||||
|
InvenTree.cache.set_session_cache(cache_key, result)
|
||||||
|
|
||||||
|
return result
|
205
src/backend/InvenTree/users/ruleset.py
Normal file
205
src/backend/InvenTree/users/ruleset.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
"""Ruleset definitions which control the InvenTree user permissions."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSetEnum(str, enum.Enum):
|
||||||
|
"""Enumeration of ruleset names."""
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the string representation of the ruleset."""
|
||||||
|
return str(self.value)
|
||||||
|
|
||||||
|
ADMIN = 'admin'
|
||||||
|
PART_CATEGORY = 'part_category'
|
||||||
|
PART = 'part'
|
||||||
|
STOCKTAKE = 'stocktake'
|
||||||
|
STOCK_LOCATION = 'stock_location'
|
||||||
|
STOCK = 'stock'
|
||||||
|
BUILD = 'build'
|
||||||
|
PURCHASE_ORDER = 'purchase_order'
|
||||||
|
SALES_ORDER = 'sales_order'
|
||||||
|
RETURN_ORDER = 'return_order'
|
||||||
|
|
||||||
|
|
||||||
|
# This is a list of all the ruleset choices available in the system.
|
||||||
|
# These are used to determine the permissions available to a group of users.
|
||||||
|
RULESET_CHOICES = [
|
||||||
|
(RuleSetEnum.ADMIN, _('Admin')),
|
||||||
|
(RuleSetEnum.PART_CATEGORY, _('Part Categories')),
|
||||||
|
(RuleSetEnum.PART, _('Parts')),
|
||||||
|
(RuleSetEnum.STOCKTAKE, _('Stocktake')),
|
||||||
|
(RuleSetEnum.STOCK_LOCATION, _('Stock Locations')),
|
||||||
|
(RuleSetEnum.STOCK, _('Stock Items')),
|
||||||
|
(RuleSetEnum.BUILD, _('Build Orders')),
|
||||||
|
(RuleSetEnum.PURCHASE_ORDER, _('Purchase Orders')),
|
||||||
|
(RuleSetEnum.SALES_ORDER, _('Sales Orders')),
|
||||||
|
(RuleSetEnum.RETURN_ORDER, _('Return Orders')),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ruleset names available in the system.
|
||||||
|
RULESET_NAMES = [choice[0] for choice in RULESET_CHOICES]
|
||||||
|
|
||||||
|
# Permission types available for each ruleset.
|
||||||
|
RULESET_PERMISSIONS = ['view', 'add', 'change', 'delete']
|
||||||
|
|
||||||
|
RULESET_CHANGE_INHERIT = [('part', 'partparameter'), ('part', 'bomitem')]
|
||||||
|
|
||||||
|
|
||||||
|
def get_ruleset_models() -> dict:
|
||||||
|
"""Return a dictionary of models associated with each ruleset.
|
||||||
|
|
||||||
|
This function maps particular database models to each ruleset.
|
||||||
|
"""
|
||||||
|
ruleset_models = {
|
||||||
|
RuleSetEnum.ADMIN: [
|
||||||
|
'auth_group',
|
||||||
|
'auth_user',
|
||||||
|
'auth_permission',
|
||||||
|
'users_apitoken',
|
||||||
|
'users_ruleset',
|
||||||
|
'report_labeltemplate',
|
||||||
|
'report_reportasset',
|
||||||
|
'report_reportsnippet',
|
||||||
|
'report_reporttemplate',
|
||||||
|
'account_emailaddress',
|
||||||
|
'account_emailconfirmation',
|
||||||
|
'socialaccount_socialaccount',
|
||||||
|
'socialaccount_socialapp',
|
||||||
|
'socialaccount_socialtoken',
|
||||||
|
'otp_totp_totpdevice',
|
||||||
|
'otp_static_statictoken',
|
||||||
|
'otp_static_staticdevice',
|
||||||
|
'mfa_authenticator',
|
||||||
|
'plugin_pluginconfig',
|
||||||
|
'plugin_pluginsetting',
|
||||||
|
'plugin_notificationusersetting',
|
||||||
|
'common_barcodescanresult',
|
||||||
|
'common_newsfeedentry',
|
||||||
|
'taggit_tag',
|
||||||
|
'taggit_taggeditem',
|
||||||
|
'flags_flagstate',
|
||||||
|
'machine_machineconfig',
|
||||||
|
'machine_machinesetting',
|
||||||
|
],
|
||||||
|
RuleSetEnum.PART_CATEGORY: [
|
||||||
|
'part_partcategory',
|
||||||
|
'part_partcategoryparametertemplate',
|
||||||
|
'part_partcategorystar',
|
||||||
|
],
|
||||||
|
RuleSetEnum.PART: [
|
||||||
|
'part_part',
|
||||||
|
'part_partpricing',
|
||||||
|
'part_bomitem',
|
||||||
|
'part_bomitemsubstitute',
|
||||||
|
'part_partsellpricebreak',
|
||||||
|
'part_partinternalpricebreak',
|
||||||
|
'part_parttesttemplate',
|
||||||
|
'part_partparametertemplate',
|
||||||
|
'part_partparameter',
|
||||||
|
'part_partrelated',
|
||||||
|
'part_partstar',
|
||||||
|
'part_partcategorystar',
|
||||||
|
'company_supplierpart',
|
||||||
|
'company_manufacturerpart',
|
||||||
|
'company_manufacturerpartparameter',
|
||||||
|
],
|
||||||
|
RuleSetEnum.STOCKTAKE: ['part_partstocktake', 'part_partstocktakereport'],
|
||||||
|
RuleSetEnum.STOCK_LOCATION: ['stock_stocklocation', 'stock_stocklocationtype'],
|
||||||
|
RuleSetEnum.STOCK: [
|
||||||
|
'stock_stockitem',
|
||||||
|
'stock_stockitemtracking',
|
||||||
|
'stock_stockitemtestresult',
|
||||||
|
],
|
||||||
|
RuleSetEnum.BUILD: [
|
||||||
|
'part_part',
|
||||||
|
'part_partcategory',
|
||||||
|
'part_bomitem',
|
||||||
|
'part_bomitemsubstitute',
|
||||||
|
'build_build',
|
||||||
|
'build_builditem',
|
||||||
|
'build_buildline',
|
||||||
|
'stock_stockitem',
|
||||||
|
'stock_stocklocation',
|
||||||
|
],
|
||||||
|
RuleSetEnum.PURCHASE_ORDER: [
|
||||||
|
'company_company',
|
||||||
|
'company_contact',
|
||||||
|
'company_address',
|
||||||
|
'company_manufacturerpart',
|
||||||
|
'company_manufacturerpartparameter',
|
||||||
|
'company_supplierpart',
|
||||||
|
'company_supplierpricebreak',
|
||||||
|
'order_purchaseorder',
|
||||||
|
'order_purchaseorderlineitem',
|
||||||
|
'order_purchaseorderextraline',
|
||||||
|
],
|
||||||
|
RuleSetEnum.SALES_ORDER: [
|
||||||
|
'company_company',
|
||||||
|
'company_contact',
|
||||||
|
'company_address',
|
||||||
|
'order_salesorder',
|
||||||
|
'order_salesorderallocation',
|
||||||
|
'order_salesorderlineitem',
|
||||||
|
'order_salesorderextraline',
|
||||||
|
'order_salesordershipment',
|
||||||
|
],
|
||||||
|
RuleSetEnum.RETURN_ORDER: [
|
||||||
|
'company_company',
|
||||||
|
'company_contact',
|
||||||
|
'company_address',
|
||||||
|
'order_returnorder',
|
||||||
|
'order_returnorderlineitem',
|
||||||
|
'order_returnorderextraline',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.SITE_MULTI:
|
||||||
|
ruleset_models['admin'].append('sites_site')
|
||||||
|
|
||||||
|
return ruleset_models
|
||||||
|
|
||||||
|
|
||||||
|
def get_ruleset_ignore() -> list[str]:
|
||||||
|
"""Return a list of database tables which do not require permissions."""
|
||||||
|
return [
|
||||||
|
# Core django models (not user configurable)
|
||||||
|
'admin_logentry',
|
||||||
|
'contenttypes_contenttype',
|
||||||
|
# Models which currently do not require permissions
|
||||||
|
'common_attachment',
|
||||||
|
'common_customunit',
|
||||||
|
'common_dataoutput',
|
||||||
|
'common_inventreesetting',
|
||||||
|
'common_inventreeusersetting',
|
||||||
|
'common_notificationentry',
|
||||||
|
'common_notificationmessage',
|
||||||
|
'common_notesimage',
|
||||||
|
'common_projectcode',
|
||||||
|
'common_webhookendpoint',
|
||||||
|
'common_webhookmessage',
|
||||||
|
'common_inventreecustomuserstatemodel',
|
||||||
|
'common_selectionlistentry',
|
||||||
|
'common_selectionlist',
|
||||||
|
'users_owner',
|
||||||
|
'users_userprofile', # User profile is handled in the serializer - only own user can change
|
||||||
|
# Third-party tables
|
||||||
|
'error_report_error',
|
||||||
|
'exchange_rate',
|
||||||
|
'exchange_exchangebackend',
|
||||||
|
'usersessions_usersession',
|
||||||
|
'sessions_session',
|
||||||
|
# Django-q
|
||||||
|
'django_q_ormq',
|
||||||
|
'django_q_failure',
|
||||||
|
'django_q_task',
|
||||||
|
'django_q_schedule',
|
||||||
|
'django_q_success',
|
||||||
|
# Importing
|
||||||
|
'importer_dataimportsession',
|
||||||
|
'importer_dataimportcolumnmap',
|
||||||
|
'importer_dataimportrow',
|
||||||
|
]
|
@ -11,7 +11,9 @@ from rest_framework.exceptions import PermissionDenied
|
|||||||
from InvenTree.ready import isGeneratingSchema
|
from InvenTree.ready import isGeneratingSchema
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
from InvenTree.serializers import InvenTreeModelSerializer
|
||||||
|
|
||||||
from .models import ApiToken, Owner, RuleSet, UserProfile, check_user_role
|
from .models import ApiToken, Owner, RuleSet, UserProfile
|
||||||
|
from .permissions import check_user_role
|
||||||
|
from .ruleset import RULESET_CHOICES, RULESET_PERMISSIONS, RuleSetEnum
|
||||||
|
|
||||||
|
|
||||||
class OwnerSerializer(InvenTreeModelSerializer):
|
class OwnerSerializer(InvenTreeModelSerializer):
|
||||||
@ -29,32 +31,24 @@ class OwnerSerializer(InvenTreeModelSerializer):
|
|||||||
label = serializers.CharField(read_only=True)
|
label = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class GroupSerializer(InvenTreeModelSerializer):
|
class RuleSetSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer for a 'Group'."""
|
"""Serializer for a RuleSet."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass defines serializer fields."""
|
"""Metaclass defines serializer fields."""
|
||||||
|
|
||||||
model = Group
|
model = RuleSet
|
||||||
fields = ['pk', 'name', 'permissions']
|
fields = [
|
||||||
|
'pk',
|
||||||
def __init__(self, *args, **kwargs):
|
'name',
|
||||||
"""Initialize this serializer with extra fields as required."""
|
'label',
|
||||||
permission_detail = kwargs.pop('permission_detail', False)
|
'group',
|
||||||
|
'can_view',
|
||||||
super().__init__(*args, **kwargs)
|
'can_add',
|
||||||
|
'can_change',
|
||||||
try:
|
'can_delete',
|
||||||
if not permission_detail and not isGeneratingSchema():
|
]
|
||||||
self.fields.pop('permissions', None)
|
read_only_fields = ['pk', 'name', 'label', 'group']
|
||||||
except AppRegistryNotReady:
|
|
||||||
pass
|
|
||||||
|
|
||||||
permissions = serializers.SerializerMethodField(allow_null=True)
|
|
||||||
|
|
||||||
def get_permissions(self, group: Group) -> dict:
|
|
||||||
"""Return a list of permissions associated with the group."""
|
|
||||||
return generate_permission_dict(group.permissions.all())
|
|
||||||
|
|
||||||
|
|
||||||
class RoleSerializer(InvenTreeModelSerializer):
|
class RoleSerializer(InvenTreeModelSerializer):
|
||||||
@ -81,12 +75,12 @@ class RoleSerializer(InvenTreeModelSerializer):
|
|||||||
"""Roles associated with the user."""
|
"""Roles associated with the user."""
|
||||||
roles = {}
|
roles = {}
|
||||||
|
|
||||||
for ruleset in RuleSet.RULESET_CHOICES:
|
for ruleset in RULESET_CHOICES:
|
||||||
role, _text = ruleset
|
role, _text = ruleset
|
||||||
|
|
||||||
permissions = []
|
permissions = []
|
||||||
|
|
||||||
for permission in RuleSet.RULESET_PERMISSIONS:
|
for permission in RULESET_PERMISSIONS:
|
||||||
if check_user_role(user, role, permission):
|
if check_user_role(user, role, permission):
|
||||||
permissions.append(permission)
|
permissions.append(permission)
|
||||||
|
|
||||||
@ -123,6 +117,23 @@ def generate_permission_dict(permissions) -> dict:
|
|||||||
return perms
|
return perms
|
||||||
|
|
||||||
|
|
||||||
|
def generate_roles_dict(roles) -> dict:
|
||||||
|
"""Generate a dictionary of roles for a given set of roles."""
|
||||||
|
# Build out an (initially empty) dictionary of roles
|
||||||
|
role_dict = {name: [] for name, _ in RULESET_CHOICES}
|
||||||
|
|
||||||
|
for role in roles:
|
||||||
|
permissions = []
|
||||||
|
|
||||||
|
for permission in ['view', 'add', 'change', 'delete']:
|
||||||
|
if getattr(role, f'can_{permission}', False):
|
||||||
|
permissions.append(permission)
|
||||||
|
|
||||||
|
role_dict[role.name] = permissions
|
||||||
|
|
||||||
|
return role_dict
|
||||||
|
|
||||||
|
|
||||||
class ApiTokenSerializer(InvenTreeModelSerializer):
|
class ApiTokenSerializer(InvenTreeModelSerializer):
|
||||||
"""Serializer for the ApiToken model."""
|
"""Serializer for the ApiToken model."""
|
||||||
|
|
||||||
@ -237,19 +248,58 @@ class UserSerializer(InvenTreeModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupSerializer(InvenTreeModelSerializer):
|
||||||
|
"""Serializer for a 'Group'."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass defines serializer fields."""
|
||||||
|
|
||||||
|
model = Group
|
||||||
|
fields = ['pk', 'name', 'permissions', 'roles', 'users']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialize this serializer with extra fields as required."""
|
||||||
|
role_detail = kwargs.pop('role_detail', False)
|
||||||
|
user_detail = kwargs.pop('user_detail', False)
|
||||||
|
permission_detail = kwargs.pop('permission_detail', False)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not isGeneratingSchema():
|
||||||
|
if not permission_detail:
|
||||||
|
self.fields.pop('permissions', None)
|
||||||
|
if not role_detail:
|
||||||
|
self.fields.pop('roles', None)
|
||||||
|
if not user_detail:
|
||||||
|
self.fields.pop('users', None)
|
||||||
|
|
||||||
|
except AppRegistryNotReady:
|
||||||
|
pass
|
||||||
|
|
||||||
|
permissions = serializers.SerializerMethodField(allow_null=True, read_only=True)
|
||||||
|
|
||||||
|
def get_permissions(self, group: Group) -> dict:
|
||||||
|
"""Return a list of permissions associated with the group."""
|
||||||
|
return generate_permission_dict(group.permissions.all())
|
||||||
|
|
||||||
|
roles = RuleSetSerializer(source='rule_sets', many=True, read_only=True)
|
||||||
|
|
||||||
|
users = UserSerializer(source='user_set', many=True, read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class ExtendedUserSerializer(UserSerializer):
|
class ExtendedUserSerializer(UserSerializer):
|
||||||
"""Serializer for a User with a bit more info."""
|
"""Serializer for a User with a bit more info."""
|
||||||
|
|
||||||
from users.serializers import GroupSerializer
|
from users.serializers import GroupSerializer
|
||||||
|
|
||||||
groups = GroupSerializer(read_only=True, many=True)
|
|
||||||
|
|
||||||
class Meta(UserSerializer.Meta):
|
class Meta(UserSerializer.Meta):
|
||||||
"""Metaclass defines serializer fields."""
|
"""Metaclass defines serializer fields."""
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
*UserSerializer.Meta.fields,
|
*UserSerializer.Meta.fields,
|
||||||
'groups',
|
'groups',
|
||||||
|
'group_ids',
|
||||||
'is_staff',
|
'is_staff',
|
||||||
'is_superuser',
|
'is_superuser',
|
||||||
'is_active',
|
'is_active',
|
||||||
@ -258,38 +308,52 @@ class ExtendedUserSerializer(UserSerializer):
|
|||||||
|
|
||||||
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups']
|
read_only_fields = [*UserSerializer.Meta.read_only_fields, 'groups']
|
||||||
|
|
||||||
|
groups = GroupSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
# Write-only field, for updating the groups associated with the user
|
||||||
|
group_ids = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Group.objects.all(), many=True, write_only=True, required=False
|
||||||
|
)
|
||||||
|
|
||||||
is_staff = serializers.BooleanField(
|
is_staff = serializers.BooleanField(
|
||||||
label=_('Staff'), help_text=_('Does this user have staff permissions')
|
label=_('Staff'),
|
||||||
|
help_text=_('Does this user have staff permissions'),
|
||||||
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
is_superuser = serializers.BooleanField(
|
is_superuser = serializers.BooleanField(
|
||||||
label=_('Superuser'), help_text=_('Is this user a superuser')
|
label=_('Superuser'), help_text=_('Is this user a superuser'), required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
is_active = serializers.BooleanField(
|
is_active = serializers.BooleanField(
|
||||||
label=_('Active'), help_text=_('Is this user account active')
|
label=_('Active'), help_text=_('Is this user account active'), required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
profile = BriefUserProfileSerializer(many=False, read_only=True)
|
profile = BriefUserProfileSerializer(many=False, read_only=True)
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate_is_superuser(self, value):
|
||||||
"""Expanded validation for changing user role."""
|
"""Only a superuser account can adjust this value!"""
|
||||||
# Check if is_staff or is_superuser is in attrs
|
|
||||||
role_change = 'is_staff' in attrs or 'is_superuser' in attrs
|
|
||||||
request_user = self.context['request'].user
|
request_user = self.context['request'].user
|
||||||
|
|
||||||
if role_change:
|
if 'is_superuser' in self.context['request'].data:
|
||||||
if request_user.is_superuser:
|
if not request_user.is_superuser:
|
||||||
# Superusers can change any role
|
raise PermissionDenied({
|
||||||
pass
|
'is_superuser': _('Only a superuser can adjust this field')
|
||||||
elif request_user.is_staff and 'is_superuser' not in attrs:
|
})
|
||||||
# Staff can change any role except is_superuser
|
|
||||||
pass
|
return value
|
||||||
else:
|
|
||||||
raise PermissionDenied(
|
def update(self, instance, validated_data):
|
||||||
_('You do not have permission to change this user role.')
|
"""Update the user instance with the provided data."""
|
||||||
)
|
# Update the groups associated with the user
|
||||||
return super().validate(attrs)
|
groups = validated_data.pop('group_ids', None)
|
||||||
|
|
||||||
|
instance = super().update(instance, validated_data)
|
||||||
|
|
||||||
|
if groups is not None:
|
||||||
|
instance.groups.set(groups)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class MeUserSerializer(ExtendedUserSerializer):
|
class MeUserSerializer(ExtendedUserSerializer):
|
||||||
@ -323,9 +387,18 @@ class UserCreateSerializer(ExtendedUserSerializer):
|
|||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
"""Expanded valiadation for auth."""
|
"""Expanded valiadation for auth."""
|
||||||
|
user = self.context['request'].user
|
||||||
|
|
||||||
# Check that the user trying to create a new user is a superuser
|
# Check that the user trying to create a new user is a superuser
|
||||||
if not self.context['request'].user.is_superuser:
|
if not user.is_staff:
|
||||||
raise serializers.ValidationError(_('Only superusers can create new users'))
|
raise serializers.ValidationError(
|
||||||
|
_('Only staff users can create new users')
|
||||||
|
)
|
||||||
|
|
||||||
|
if not check_user_role(user, RuleSetEnum.ADMIN, 'add'):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
_('You do not have permission to create users')
|
||||||
|
)
|
||||||
|
|
||||||
# Generate a random password
|
# Generate a random password
|
||||||
password = User.objects.make_random_password(length=14)
|
password = User.objects.make_random_password(length=14)
|
||||||
|
195
src/backend/InvenTree/users/tasks.py
Normal file
195
src/backend/InvenTree/users/tasks.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
"""Background tasks for the users app."""
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from InvenTree.ready import canAppAccessDatabase
|
||||||
|
from users.models import RuleSet
|
||||||
|
from users.permissions import get_model_permission_string, split_permission
|
||||||
|
from users.ruleset import RULESET_CHANGE_INHERIT, RULESET_CHOICES, RULESET_NAMES
|
||||||
|
|
||||||
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_all_permissions() -> None:
|
||||||
|
"""Rebuild all user permissions.
|
||||||
|
|
||||||
|
This function is called when a user is created or when a group is modified.
|
||||||
|
It rebuilds the permissions for all users in the system.
|
||||||
|
"""
|
||||||
|
logger.info('Rebuilding permissions')
|
||||||
|
|
||||||
|
# Rebuild permissions for each group
|
||||||
|
for group in Group.objects.all():
|
||||||
|
update_group_roles(group)
|
||||||
|
|
||||||
|
|
||||||
|
def update_group_roles(group: Group, debug: bool = False) -> None:
|
||||||
|
"""Update the roles for a particular group.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
group: The group object to update roles for.
|
||||||
|
debug: Whether to enable debug logging
|
||||||
|
|
||||||
|
This function performs the following tasks:
|
||||||
|
- Remove any RuleSet objects which have become outdated
|
||||||
|
- Ensure that the group has a mapped RuleSet for each role
|
||||||
|
- Rebuild the permissions for the group, based on the assigned RuleSet objects
|
||||||
|
"""
|
||||||
|
if not canAppAccessDatabase(allow_test=True):
|
||||||
|
return # pragma: no cover
|
||||||
|
|
||||||
|
logger.info('Updating group roles for %s', group)
|
||||||
|
|
||||||
|
# Remove any outdated RuleSet objects
|
||||||
|
outdated_rules = group.rule_sets.exclude(name__in=RULESET_NAMES)
|
||||||
|
|
||||||
|
if outdated_rules.exists():
|
||||||
|
logger.info(
|
||||||
|
'Deleting %s outdated rulesets from group %s', outdated_rules.count(), group
|
||||||
|
)
|
||||||
|
outdated_rules.delete()
|
||||||
|
|
||||||
|
# Add any missing RuleSet objects
|
||||||
|
for rule in RULESET_NAMES:
|
||||||
|
if not group.rule_sets.filter(name=rule).exists():
|
||||||
|
logger.info('Adding ruleset %s to group %s', rule, group)
|
||||||
|
RuleSet.objects.create(group=group, name=rule)
|
||||||
|
|
||||||
|
# Update the permissions for the group
|
||||||
|
# List of permissions already associated with this group
|
||||||
|
group_permissions = set()
|
||||||
|
|
||||||
|
# Iterate through each permission already assigned to this group,
|
||||||
|
# and create a simplified permission key string
|
||||||
|
for p in group.permissions.all().prefetch_related('content_type'):
|
||||||
|
(permission, app, model) = p.natural_key()
|
||||||
|
permission_string = f'{app}.{permission}'
|
||||||
|
group_permissions.add(permission_string)
|
||||||
|
|
||||||
|
# List of permissions which must be added to the group
|
||||||
|
permissions_to_add = set()
|
||||||
|
|
||||||
|
# List of permissions which must be removed from the group
|
||||||
|
permissions_to_delete = set()
|
||||||
|
|
||||||
|
def add_model(name, action, allowed):
|
||||||
|
"""Add a new model to the pile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The name of the model e.g. part_part
|
||||||
|
action: The permission action e.g. view
|
||||||
|
allowed: Whether or not the action is allowed
|
||||||
|
"""
|
||||||
|
if action not in ['view', 'add', 'change', 'delete']: # pragma: no cover
|
||||||
|
raise ValueError(f'Action {action} is invalid')
|
||||||
|
|
||||||
|
permission_string = get_model_permission_string(model, action)
|
||||||
|
|
||||||
|
if allowed:
|
||||||
|
# An 'allowed' action is always preferenced over a 'forbidden' action
|
||||||
|
if permission_string in permissions_to_delete:
|
||||||
|
permissions_to_delete.remove(permission_string)
|
||||||
|
|
||||||
|
permissions_to_add.add(permission_string)
|
||||||
|
|
||||||
|
elif permission_string not in permissions_to_add:
|
||||||
|
permissions_to_delete.add(permission_string)
|
||||||
|
|
||||||
|
# Pre-fetch all the RuleSet objects
|
||||||
|
rulesets = {
|
||||||
|
r.name: r for r in RuleSet.objects.filter(group=group).prefetch_related('group')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get all the rulesets associated with this group
|
||||||
|
for rule_name, _rule_label in RULESET_CHOICES:
|
||||||
|
if rule_name in rulesets:
|
||||||
|
ruleset = rulesets[rule_name]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ruleset = RuleSet.objects.get(group=group, name=rule_name)
|
||||||
|
except RuleSet.DoesNotExist:
|
||||||
|
ruleset = RuleSet.objects.create(group=group, name=rule_name)
|
||||||
|
|
||||||
|
# Which database tables does this RuleSet touch?
|
||||||
|
models = ruleset.get_models()
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
# Keep track of the available permissions for each model
|
||||||
|
add_model(model, 'view', ruleset.can_view)
|
||||||
|
add_model(model, 'add', ruleset.can_add)
|
||||||
|
add_model(model, 'change', ruleset.can_change)
|
||||||
|
add_model(model, 'delete', ruleset.can_delete)
|
||||||
|
|
||||||
|
def get_permission_object(permission_string):
|
||||||
|
"""Find the permission object in the database, from the simplified permission string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission_string: a simplified permission_string e.g. 'part.view_partcategory'
|
||||||
|
|
||||||
|
Returns the permission object in the database associated with the permission string
|
||||||
|
"""
|
||||||
|
(app, perm) = permission_string.split('.')
|
||||||
|
|
||||||
|
perm, model = split_permission(app, perm)
|
||||||
|
permission = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
content_type = ContentType.objects.get(app_label=app, model=model)
|
||||||
|
permission = Permission.objects.get(
|
||||||
|
content_type=content_type, codename=perm
|
||||||
|
)
|
||||||
|
except ContentType.DoesNotExist: # pragma: no cover
|
||||||
|
logger.warning("No ContentType found matching '%s' and '%s'", app, model)
|
||||||
|
except Permission.DoesNotExist:
|
||||||
|
logger.warning("No Permission found matching '%s' and '%s'", app, perm)
|
||||||
|
|
||||||
|
return permission
|
||||||
|
|
||||||
|
# Add any required permissions to the group
|
||||||
|
for perm in permissions_to_add:
|
||||||
|
# Ignore if permission is already in the group
|
||||||
|
if perm in group_permissions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if permission := get_permission_object(perm):
|
||||||
|
group.permissions.add(permission)
|
||||||
|
if debug: # pragma: no cover
|
||||||
|
logger.debug('Adding permission %s to group %s', perm, group.name)
|
||||||
|
|
||||||
|
# Remove any extra permissions from the group
|
||||||
|
for perm in permissions_to_delete:
|
||||||
|
# Ignore if the permission is not already assigned
|
||||||
|
if perm not in group_permissions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if permission := get_permission_object(perm):
|
||||||
|
group.permissions.remove(permission)
|
||||||
|
if debug: # pragma: no cover
|
||||||
|
logger.debug('Removing permission %s from group %s', perm, group.name)
|
||||||
|
|
||||||
|
# Enable all action permissions for certain children models
|
||||||
|
# if parent model has 'change' permission
|
||||||
|
for parent, child in RULESET_CHANGE_INHERIT:
|
||||||
|
parent_child_string = f'{parent}_{child}'
|
||||||
|
|
||||||
|
# Check each type of permission
|
||||||
|
for action in ['view', 'change', 'add', 'delete']:
|
||||||
|
parent_perm = f'{parent}.{action}_{parent}'
|
||||||
|
|
||||||
|
if parent_perm in group_permissions:
|
||||||
|
child_perm = f'{parent}.{action}_{child}'
|
||||||
|
|
||||||
|
# Check if child permission not already in group
|
||||||
|
if child_perm not in group_permissions:
|
||||||
|
# Create permission object
|
||||||
|
add_model(parent_child_string, action, ruleset.can_delete)
|
||||||
|
# Add to group
|
||||||
|
permission = get_permission_object(child_perm)
|
||||||
|
if permission:
|
||||||
|
group.permissions.add(permission)
|
||||||
|
logger.debug(
|
||||||
|
'Adding permission %s to group %s', child_perm, group.name
|
||||||
|
)
|
@ -7,6 +7,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from users.models import ApiToken
|
from users.models import ApiToken
|
||||||
|
from users.ruleset import RULESET_NAMES, get_ruleset_models
|
||||||
|
|
||||||
|
|
||||||
class UserAPITests(InvenTreeAPITestCase):
|
class UserAPITests(InvenTreeAPITestCase):
|
||||||
@ -17,8 +18,11 @@ class UserAPITests(InvenTreeAPITestCase):
|
|||||||
self.assignRole('admin.add')
|
self.assignRole('admin.add')
|
||||||
response = self.options(reverse('api-user-list'), expected_code=200)
|
response = self.options(reverse('api-user-list'), expected_code=200)
|
||||||
|
|
||||||
# User is *not* a superuser, so user account API is read-only
|
self.user.is_staff = True
|
||||||
self.assertNotIn('POST', response.data['actions'])
|
self.user.save()
|
||||||
|
|
||||||
|
# User is a *staff* user with *admin* role, so can POST against this endpoint
|
||||||
|
self.assertIn('POST', response.data['actions'])
|
||||||
|
|
||||||
fields = response.data['actions']['GET']
|
fields = response.data['actions']['GET']
|
||||||
|
|
||||||
@ -59,25 +63,36 @@ class UserAPITests(InvenTreeAPITestCase):
|
|||||||
self.assertIn('pk', response.data)
|
self.assertIn('pk', response.data)
|
||||||
self.assertIn('username', response.data)
|
self.assertIn('username', response.data)
|
||||||
|
|
||||||
# Test create user
|
data = {
|
||||||
response = self.post(url, expected_code=403)
|
|
||||||
self.assertIn(
|
|
||||||
'You do not have permission to perform this action.', str(response.data)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.user.is_superuser = True
|
|
||||||
self.user.save()
|
|
||||||
|
|
||||||
response = self.post(
|
|
||||||
url,
|
|
||||||
data={
|
|
||||||
'username': 'test',
|
'username': 'test',
|
||||||
'first_name': 'Test',
|
'first_name': 'Test',
|
||||||
'last_name': 'User',
|
'last_name': 'User',
|
||||||
'email': 'aa@example.org',
|
'email': 'aa@example.org',
|
||||||
},
|
}
|
||||||
expected_code=201,
|
|
||||||
|
# Test create user - requires staff access with 'admin' role
|
||||||
|
response = self.post(url, data=data, expected_code=403)
|
||||||
|
self.assertIn(
|
||||||
|
'You do not have permission to perform this action.', str(response.data)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Try again with "staff" access
|
||||||
|
self.user.is_staff = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
# Try again - should fail still, as user does not have "admin" role
|
||||||
|
response = self.post(url, data=data, expected_code=403)
|
||||||
|
|
||||||
|
# Assign the "admin" role to the user
|
||||||
|
self.assignRole('admin.view')
|
||||||
|
|
||||||
|
# Fail again - user does not have "add" permission against the "admin" role
|
||||||
|
response = self.post(url, data=data, expected_code=403)
|
||||||
|
|
||||||
|
self.assignRole('admin.add')
|
||||||
|
|
||||||
|
response = self.post(url, data=data, expected_code=201)
|
||||||
|
|
||||||
self.assertEqual(response.data['username'], 'test')
|
self.assertEqual(response.data['username'], 'test')
|
||||||
self.assertEqual(response.data['first_name'], 'Test')
|
self.assertEqual(response.data['first_name'], 'Test')
|
||||||
self.assertEqual(response.data['last_name'], 'User')
|
self.assertEqual(response.data['last_name'], 'User')
|
||||||
@ -85,6 +100,47 @@ class UserAPITests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.data['is_superuser'], False)
|
self.assertEqual(response.data['is_superuser'], False)
|
||||||
self.assertEqual(response.data['is_active'], True)
|
self.assertEqual(response.data['is_active'], True)
|
||||||
|
|
||||||
|
# Try to adjust the 'is_superuser' field
|
||||||
|
# Only a "superuser" can set this field
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
data={**data, 'username': 'Superuser', 'is_superuser': True},
|
||||||
|
expected_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn('Only a superuser can adjust this field', str(response.data))
|
||||||
|
|
||||||
|
def test_user_detail(self):
|
||||||
|
"""Test the UserDetail API endpoint."""
|
||||||
|
user = User.objects.first()
|
||||||
|
url = reverse('api-user-detail', kwargs={'pk': user.pk})
|
||||||
|
|
||||||
|
user.is_staff = False
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
# Any authenticated user can access the user detail endpoint
|
||||||
|
self.get(url, expected_code=200)
|
||||||
|
|
||||||
|
# Let's try to update the user
|
||||||
|
data = {'is_active': False, 'is_staff': False}
|
||||||
|
|
||||||
|
self.patch(url, data=data, expected_code=403)
|
||||||
|
|
||||||
|
# But, what if we have the "admin" role?
|
||||||
|
self.assignRole('admin.change')
|
||||||
|
|
||||||
|
# Still cannot - we are not staff
|
||||||
|
self.patch(url, data=data, expected_code=403)
|
||||||
|
|
||||||
|
self.user.is_staff = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
self.patch(url, data=data, expected_code=200)
|
||||||
|
|
||||||
|
# Try again, but logged out - expect no access to the endpoint
|
||||||
|
self.logout()
|
||||||
|
self.get(url, expected_code=401)
|
||||||
|
|
||||||
def test_group_api(self):
|
def test_group_api(self):
|
||||||
"""Tests for the Group API endpoints."""
|
"""Tests for the Group API endpoints."""
|
||||||
response = self.get(reverse('api-group-list'), expected_code=200)
|
response = self.get(reverse('api-group-list'), expected_code=200)
|
||||||
@ -115,6 +171,43 @@ class UserAPITests(InvenTreeAPITestCase):
|
|||||||
response = self.get(reverse('api-login-redirect'), expected_code=302)
|
response = self.get(reverse('api-login-redirect'), expected_code=302)
|
||||||
self.assertEqual(response.url, '/web/logged-in/')
|
self.assertEqual(response.url, '/web/logged-in/')
|
||||||
|
|
||||||
|
def test_user_roles(self):
|
||||||
|
"""Test the user 'roles' API endpoint."""
|
||||||
|
url = reverse('api-user-roles')
|
||||||
|
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
data = response.data
|
||||||
|
|
||||||
|
# User has no 'permissions' yet
|
||||||
|
self.assertEqual(len(data['permissions']), 0)
|
||||||
|
self.assertEqual(len(data['roles']), len(RULESET_NAMES))
|
||||||
|
|
||||||
|
# assign the 'purchase_order.add' role to the test group
|
||||||
|
self.assignRole('purchase_order.add')
|
||||||
|
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
data = response.data
|
||||||
|
|
||||||
|
# Expected number of permissions
|
||||||
|
perms = get_ruleset_models()['purchase_order']
|
||||||
|
self.assertEqual(len(data['permissions']), len(perms))
|
||||||
|
|
||||||
|
for P in data['permissions'].values():
|
||||||
|
self.assertIn('add', P)
|
||||||
|
self.assertIn('change', P)
|
||||||
|
self.assertIn('view', P)
|
||||||
|
|
||||||
|
self.assertNotIn('delete', P)
|
||||||
|
|
||||||
|
# assign a different role - check stacking
|
||||||
|
self.assignRole('build.view')
|
||||||
|
|
||||||
|
response = self.get(url, expected_code=200)
|
||||||
|
data = response.data
|
||||||
|
build_perms = get_ruleset_models()['build']
|
||||||
|
|
||||||
|
self.assertEqual(len(data['permissions']), len(perms) + len(build_perms))
|
||||||
|
|
||||||
|
|
||||||
class UserTokenTests(InvenTreeAPITestCase):
|
class UserTokenTests(InvenTreeAPITestCase):
|
||||||
"""Tests for user token functionality."""
|
"""Tests for user token functionality."""
|
||||||
|
@ -7,7 +7,13 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from common.settings import set_global_setting
|
from common.settings import set_global_setting
|
||||||
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
|
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
|
||||||
from users.models import ApiToken, Owner, RuleSet
|
from users.models import ApiToken, Owner
|
||||||
|
from users.ruleset import (
|
||||||
|
RULESET_CHOICES,
|
||||||
|
RULESET_NAMES,
|
||||||
|
get_ruleset_ignore,
|
||||||
|
get_ruleset_models,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RuleSetModelTest(TestCase):
|
class RuleSetModelTest(TestCase):
|
||||||
@ -15,11 +21,11 @@ class RuleSetModelTest(TestCase):
|
|||||||
|
|
||||||
def test_ruleset_models(self):
|
def test_ruleset_models(self):
|
||||||
"""Test that the role rulesets work as intended."""
|
"""Test that the role rulesets work as intended."""
|
||||||
keys = RuleSet.get_ruleset_models().keys()
|
keys = get_ruleset_models().keys()
|
||||||
|
|
||||||
# Check if there are any rulesets which do not have models defined
|
# Check if there are any rulesets which do not have models defined
|
||||||
|
|
||||||
missing = [name for name in RuleSet.RULESET_NAMES if name not in keys]
|
missing = [name for name in RULESET_NAMES if name not in keys]
|
||||||
|
|
||||||
if len(missing) > 0: # pragma: no cover
|
if len(missing) > 0: # pragma: no cover
|
||||||
print('The following rulesets do not have models assigned:')
|
print('The following rulesets do not have models assigned:')
|
||||||
@ -27,7 +33,7 @@ class RuleSetModelTest(TestCase):
|
|||||||
print('-', m)
|
print('-', m)
|
||||||
|
|
||||||
# Check if models have been defined for a ruleset which is incorrect
|
# Check if models have been defined for a ruleset which is incorrect
|
||||||
extra = [name for name in keys if name not in RuleSet.RULESET_NAMES]
|
extra = [name for name in keys if name not in RULESET_NAMES]
|
||||||
|
|
||||||
if len(extra) > 0: # pragma: no cover
|
if len(extra) > 0: # pragma: no cover
|
||||||
print(
|
print(
|
||||||
@ -37,7 +43,7 @@ class RuleSetModelTest(TestCase):
|
|||||||
print('-', e)
|
print('-', e)
|
||||||
|
|
||||||
# Check that each ruleset has models assigned
|
# Check that each ruleset has models assigned
|
||||||
empty = [key for key in keys if len(RuleSet.get_ruleset_models()[key]) == 0]
|
empty = [key for key in keys if len(get_ruleset_models()[key]) == 0]
|
||||||
|
|
||||||
if len(empty) > 0: # pragma: no cover
|
if len(empty) > 0: # pragma: no cover
|
||||||
print('The following rulesets have empty entries in get_ruleset_models():')
|
print('The following rulesets have empty entries in get_ruleset_models():')
|
||||||
@ -63,9 +69,7 @@ class RuleSetModelTest(TestCase):
|
|||||||
assigned_models = set()
|
assigned_models = set()
|
||||||
|
|
||||||
# Now check that each defined model is a valid table name
|
# Now check that each defined model is a valid table name
|
||||||
for key in RuleSet.get_ruleset_models():
|
for models in get_ruleset_models().values():
|
||||||
models = RuleSet.get_ruleset_models()[key]
|
|
||||||
|
|
||||||
for m in models:
|
for m in models:
|
||||||
assigned_models.add(m)
|
assigned_models.add(m)
|
||||||
|
|
||||||
@ -73,8 +77,7 @@ class RuleSetModelTest(TestCase):
|
|||||||
|
|
||||||
for model in available_tables:
|
for model in available_tables:
|
||||||
if (
|
if (
|
||||||
model not in assigned_models
|
model not in assigned_models and model not in get_ruleset_ignore()
|
||||||
and model not in RuleSet.get_ruleset_ignore()
|
|
||||||
): # pragma: no cover
|
): # pragma: no cover
|
||||||
missing_models.add(model)
|
missing_models.add(model)
|
||||||
|
|
||||||
@ -92,7 +95,7 @@ class RuleSetModelTest(TestCase):
|
|||||||
for model in assigned_models:
|
for model in assigned_models:
|
||||||
defined_models.add(model)
|
defined_models.add(model)
|
||||||
|
|
||||||
for model in RuleSet.get_ruleset_ignore():
|
for model in get_ruleset_ignore():
|
||||||
defined_models.add(model)
|
defined_models.add(model)
|
||||||
|
|
||||||
for model in defined_models: # pragma: no cover
|
for model in defined_models: # pragma: no cover
|
||||||
@ -115,12 +118,12 @@ class RuleSetModelTest(TestCase):
|
|||||||
rulesets = group.rule_sets.all()
|
rulesets = group.rule_sets.all()
|
||||||
|
|
||||||
# Rulesets should have been created automatically for this group
|
# Rulesets should have been created automatically for this group
|
||||||
self.assertEqual(rulesets.count(), len(RuleSet.RULESET_CHOICES))
|
self.assertEqual(rulesets.count(), len(RULESET_CHOICES))
|
||||||
|
|
||||||
# Check that all permissions have been assigned permissions?
|
# Check that all permissions have been assigned permissions?
|
||||||
permission_set = set()
|
permission_set = set()
|
||||||
|
|
||||||
for models in RuleSet.get_ruleset_models().values():
|
for models in get_ruleset_models().values():
|
||||||
for model in models:
|
for model in models:
|
||||||
permission_set.add(model)
|
permission_set.add(model)
|
||||||
|
|
||||||
|
232
src/frontend/src/components/items/RoleTable.tsx
Normal file
232
src/frontend/src/components/items/RoleTable.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Tooltip
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconCircleCheck, IconReload } from '@tabler/icons-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api } from '../../App';
|
||||||
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { apiUrl } from '../../states/ApiState';
|
||||||
|
|
||||||
|
export interface RuleSet {
|
||||||
|
pk?: number;
|
||||||
|
group?: number;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
can_view: boolean;
|
||||||
|
can_add: boolean;
|
||||||
|
can_change: boolean;
|
||||||
|
can_delete: boolean;
|
||||||
|
edited?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleTable({
|
||||||
|
roles,
|
||||||
|
editable = false
|
||||||
|
}: {
|
||||||
|
roles: RuleSet[];
|
||||||
|
editable?: boolean;
|
||||||
|
}) {
|
||||||
|
const [rulesets, setRulesets] = useState<RuleSet[]>(roles);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRulesets(roles);
|
||||||
|
}, [roles]);
|
||||||
|
|
||||||
|
const edited = useMemo(() => rulesets.some((r) => r.edited), [rulesets]);
|
||||||
|
|
||||||
|
// Ensure the rulesets are always displayed in the same order
|
||||||
|
const sortedRulesets = useMemo(() => {
|
||||||
|
return rulesets.sort((a, b) => (a.label > b.label ? 1 : -1));
|
||||||
|
}, [rulesets]);
|
||||||
|
|
||||||
|
// Change the edited state of the ruleset
|
||||||
|
const onToggle = useCallback(
|
||||||
|
(rule: RuleSet, field: string) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRulesets((prev) => {
|
||||||
|
const updated = prev.map((r) => {
|
||||||
|
if (r.pk === rule.pk) {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
[field]: !(r as any)[field],
|
||||||
|
edited: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[editable]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSave = async (rulesets: RuleSet[]) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
id: 'group-roles-update',
|
||||||
|
title: t`Updating`,
|
||||||
|
message: t`Updating group roles`,
|
||||||
|
loading: true,
|
||||||
|
color: 'blue',
|
||||||
|
autoClose: false
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ruleset of rulesets.filter((r) => r.edited)) {
|
||||||
|
await api
|
||||||
|
.patch(apiUrl(ApiEndpoints.ruleset_list, ruleset.pk), {
|
||||||
|
can_view: ruleset.can_view,
|
||||||
|
can_add: ruleset.can_add,
|
||||||
|
can_change: ruleset.can_change,
|
||||||
|
can_delete: ruleset.can_delete
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Mark this ruleset as "not edited"
|
||||||
|
setRulesets((prev) => {
|
||||||
|
const updated = prev.map((r) => {
|
||||||
|
if (r.pk === ruleset.pk) {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
edited: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.update({
|
||||||
|
id: 'group-roles-update',
|
||||||
|
title: t`Updated`,
|
||||||
|
message: t`Group roles updated`,
|
||||||
|
autoClose: 2000,
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCircleCheck />,
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap='xs'>
|
||||||
|
<Table striped withColumnBorders withRowBorders withTableBorder>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>
|
||||||
|
<Text fw={700}>
|
||||||
|
<Trans>Role</Trans>
|
||||||
|
</Text>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>
|
||||||
|
<Text fw={700}>
|
||||||
|
<Trans>View</Trans>
|
||||||
|
</Text>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>
|
||||||
|
<Text fw={700}>
|
||||||
|
<Trans>Change</Trans>
|
||||||
|
</Text>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>
|
||||||
|
<Text fw={700}>
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</Text>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>
|
||||||
|
<Text fw={700}>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Text>
|
||||||
|
</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{sortedRulesets.map((rule) => (
|
||||||
|
<Table.Tr key={rule.pk ?? rule.name}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap='xs'>
|
||||||
|
<Text>{rule.label}</Text>
|
||||||
|
{rule.edited && <Text>*</Text>}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
disabled={!editable}
|
||||||
|
checked={rule.can_view}
|
||||||
|
onChange={() => onToggle(rule, 'can_view')}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
disabled={!editable}
|
||||||
|
checked={rule.can_change}
|
||||||
|
onChange={() => onToggle(rule, 'can_change')}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
disabled={!editable}
|
||||||
|
checked={rule.can_add}
|
||||||
|
onChange={() => onToggle(rule, 'can_add')}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
disabled={!editable}
|
||||||
|
checked={rule.can_delete}
|
||||||
|
onChange={() => onToggle(rule, 'can_delete')}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
{editable && (
|
||||||
|
<Group justify='right'>
|
||||||
|
<Tooltip label={t`Reset group roles`} disabled={!edited}>
|
||||||
|
<Button
|
||||||
|
color='red'
|
||||||
|
onClick={() => {
|
||||||
|
setRulesets(roles);
|
||||||
|
}}
|
||||||
|
disabled={!edited}
|
||||||
|
leftSection={<IconReload />}
|
||||||
|
>
|
||||||
|
{t`Reset`}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t`Save group roles`} disabled={!edited}>
|
||||||
|
<Button
|
||||||
|
color='green'
|
||||||
|
onClick={() => {
|
||||||
|
onSave(rulesets);
|
||||||
|
}}
|
||||||
|
disabled={!edited}
|
||||||
|
leftSection={<IconCircleCheck />}
|
||||||
|
>
|
||||||
|
{t`Save`}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
230
src/frontend/src/components/items/TransferList.tsx
Normal file
230
src/frontend/src/components/items/TransferList.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconChevronLeft,
|
||||||
|
IconChevronRight,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCircleChevronLeft,
|
||||||
|
IconCircleChevronRight
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export interface TransferListItem {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransferListGroup({
|
||||||
|
items,
|
||||||
|
itemSelected,
|
||||||
|
itemSwitched
|
||||||
|
}: {
|
||||||
|
items: TransferListItem[];
|
||||||
|
itemSelected: (item: TransferListItem) => void;
|
||||||
|
itemSwitched: (item: TransferListItem) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
p='sm'
|
||||||
|
withBorder
|
||||||
|
style={{ width: '100%', height: '100%', verticalAlign: 'top' }}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
gap='xs'
|
||||||
|
justify='flex-start'
|
||||||
|
align='stretch'
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Text
|
||||||
|
p={2}
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => itemSelected(item)}
|
||||||
|
onDoubleClick={() => itemSwitched(item)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: item.selected
|
||||||
|
? 'var(--mantine-primary-color-light)'
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label || item.value}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{items.length == 0 && <Text size='sm' fs='italic'>{t`No items`}</Text>}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransferList({
|
||||||
|
available,
|
||||||
|
selected,
|
||||||
|
onSave
|
||||||
|
}: {
|
||||||
|
available: TransferListItem[];
|
||||||
|
selected: TransferListItem[];
|
||||||
|
onSave?: (selected: TransferListItem[]) => void;
|
||||||
|
}) {
|
||||||
|
const [leftItems, setLeftItems] = useState<TransferListItem[]>([]);
|
||||||
|
const [rightItems, setRightItems] = useState<TransferListItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRightItems(selected);
|
||||||
|
setLeftItems(
|
||||||
|
available.filter((item) => !selected.some((i) => i.value === item.value))
|
||||||
|
);
|
||||||
|
}, [available, selected]);
|
||||||
|
|
||||||
|
const leftToggled = useCallback(
|
||||||
|
(item: TransferListItem) => {
|
||||||
|
setLeftItems((items) =>
|
||||||
|
items.map((i) => {
|
||||||
|
if (i.value === item.value) {
|
||||||
|
return { ...i, selected: !i.selected };
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setLeftItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rightToggled = useCallback(
|
||||||
|
(item: TransferListItem) => {
|
||||||
|
setRightItems((items) =>
|
||||||
|
items.map((i) => {
|
||||||
|
if (i.value === item.value) {
|
||||||
|
return { ...i, selected: !i.selected };
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setRightItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const leftSelected: boolean = useMemo(
|
||||||
|
() => leftItems.some((i) => i.selected),
|
||||||
|
[leftItems]
|
||||||
|
);
|
||||||
|
const rightSelected: boolean = useMemo(
|
||||||
|
() => rightItems.some((i) => i.selected),
|
||||||
|
[rightItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const transferLeftToRight = useCallback(
|
||||||
|
(transferAll: boolean) => {
|
||||||
|
if (transferAll) {
|
||||||
|
setRightItems((items) => items.concat(leftItems));
|
||||||
|
setLeftItems([]);
|
||||||
|
} else {
|
||||||
|
setRightItems((items) =>
|
||||||
|
items.concat(leftItems.filter((i) => i.selected))
|
||||||
|
);
|
||||||
|
setLeftItems((items) => items.filter((i) => !i.selected));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[leftItems, setLeftItems, setRightItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const transferRightToLeft = useCallback(
|
||||||
|
(transferAll: boolean) => {
|
||||||
|
if (transferAll) {
|
||||||
|
setLeftItems((items) => items.concat(rightItems));
|
||||||
|
setRightItems([]);
|
||||||
|
} else {
|
||||||
|
setLeftItems((items) =>
|
||||||
|
items.concat(rightItems.filter((i) => i.selected))
|
||||||
|
);
|
||||||
|
setRightItems((items) => items.filter((i) => !i.selected));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[rightItems, setLeftItems, setRightItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper p='sm' withBorder style={{ width: '100%' }}>
|
||||||
|
<Stack gap='xs'>
|
||||||
|
<Group justify='space-between'>
|
||||||
|
<Text>{t`Available`}</Text>
|
||||||
|
<Text>{t`Selected`}</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group justify='space-aprt' wrap='nowrap' align='flex-start'>
|
||||||
|
<TransferListGroup
|
||||||
|
items={leftItems}
|
||||||
|
itemSwitched={() => {}}
|
||||||
|
itemSelected={leftToggled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap='xs' flex={1}>
|
||||||
|
<ActionIcon
|
||||||
|
variant='outline'
|
||||||
|
size='md'
|
||||||
|
disabled={leftItems.length == 0}
|
||||||
|
onClick={() => transferLeftToRight(true)}
|
||||||
|
>
|
||||||
|
<IconCircleChevronRight />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant='outline'
|
||||||
|
size='md'
|
||||||
|
disabled={!leftSelected}
|
||||||
|
onClick={() => transferLeftToRight(false)}
|
||||||
|
>
|
||||||
|
<IconChevronRight />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant='outline'
|
||||||
|
size='md'
|
||||||
|
disabled={!rightSelected}
|
||||||
|
onClick={() => transferRightToLeft(false)}
|
||||||
|
>
|
||||||
|
<IconChevronLeft />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant='outline'
|
||||||
|
size='md'
|
||||||
|
disabled={rightItems.length == 0}
|
||||||
|
onClick={() => transferRightToLeft(true)}
|
||||||
|
>
|
||||||
|
<IconCircleChevronLeft />
|
||||||
|
</ActionIcon>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TransferListGroup
|
||||||
|
items={rightItems}
|
||||||
|
itemSelected={rightToggled}
|
||||||
|
itemSwitched={() => {}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<Group justify='right' gap='xs'>
|
||||||
|
<Tooltip label={t`Save`}>
|
||||||
|
<Button
|
||||||
|
color='green'
|
||||||
|
onClick={() => {
|
||||||
|
onSave?.(rightItems);
|
||||||
|
}}
|
||||||
|
leftSection={<IconCircleCheck />}
|
||||||
|
>
|
||||||
|
{t`Save`}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
@ -55,6 +55,7 @@ export enum ApiEndpoints {
|
|||||||
license = 'license/',
|
license = 'license/',
|
||||||
group_list = 'user/group/',
|
group_list = 'user/group/',
|
||||||
owner_list = 'user/owner/',
|
owner_list = 'user/owner/',
|
||||||
|
ruleset_list = 'user/ruleset/',
|
||||||
content_type_list = 'contenttype/',
|
content_type_list = 'contenttype/',
|
||||||
icons = 'icons/',
|
icons = 'icons/',
|
||||||
selectionlist_list = 'selection/',
|
selectionlist_list = 'selection/',
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { t } from '@lingui/core/macro';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Enumeration of available user role groups
|
* Enumeration of available user role groups
|
||||||
*/
|
*/
|
||||||
@ -23,3 +25,30 @@ export enum UserPermissions {
|
|||||||
change = 'change',
|
change = 'change',
|
||||||
delete = 'delete'
|
delete = 'delete'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function userRoleLabel(role: UserRoles): string {
|
||||||
|
switch (role) {
|
||||||
|
case UserRoles.admin:
|
||||||
|
return t`Admin`;
|
||||||
|
case UserRoles.build:
|
||||||
|
return t`Build Orders`;
|
||||||
|
case UserRoles.part:
|
||||||
|
return t`Parts`;
|
||||||
|
case UserRoles.part_category:
|
||||||
|
return t`Part Categories`;
|
||||||
|
case UserRoles.purchase_order:
|
||||||
|
return t`Purchase Orders`;
|
||||||
|
case UserRoles.return_order:
|
||||||
|
return t`Return Orders`;
|
||||||
|
case UserRoles.sales_order:
|
||||||
|
return t`Sales Orders`;
|
||||||
|
case UserRoles.stock:
|
||||||
|
return t`Stock Items`;
|
||||||
|
case UserRoles.stock_location:
|
||||||
|
return t`Stock Location`;
|
||||||
|
case UserRoles.stocktake:
|
||||||
|
return t`Stocktake`;
|
||||||
|
default:
|
||||||
|
return role as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -64,6 +64,10 @@ export function AccountDetailPanel() {
|
|||||||
{ label: t`Username`, value: user?.username },
|
{ label: t`Username`, value: user?.username },
|
||||||
{ label: t`First Name`, value: user?.first_name },
|
{ label: t`First Name`, value: user?.first_name },
|
||||||
{ label: t`Last Name`, value: user?.last_name },
|
{ label: t`Last Name`, value: user?.last_name },
|
||||||
|
{
|
||||||
|
label: t`Active`,
|
||||||
|
value: <YesNoUndefinedButton value={user?.profile?.active} />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t`Staff Access`,
|
label: t`Staff Access`,
|
||||||
value: <YesNoUndefinedButton value={user?.is_staff} />
|
value: <YesNoUndefinedButton value={user?.is_staff} />
|
||||||
@ -82,10 +86,6 @@ export function AccountDetailPanel() {
|
|||||||
{ label: t`Position`, value: user?.profile?.position },
|
{ label: t`Position`, value: user?.profile?.position },
|
||||||
{ label: t`Status`, value: user?.profile?.status },
|
{ label: t`Status`, value: user?.profile?.status },
|
||||||
{ label: t`Location`, value: user?.profile?.location },
|
{ label: t`Location`, value: user?.profile?.location },
|
||||||
{
|
|
||||||
label: t`Active`,
|
|
||||||
value: <YesNoUndefinedButton value={user?.profile?.active} />
|
|
||||||
},
|
|
||||||
{ label: t`Contact`, value: user?.profile?.contact },
|
{ label: t`Contact`, value: user?.profile?.contact },
|
||||||
{ label: t`Type`, value: <Badge>{user?.profile?.type}</Badge> },
|
{ label: t`Type`, value: <Badge>{user?.profile?.type}</Badge> },
|
||||||
{ label: t`Organisation`, value: user?.profile?.organisation },
|
{ label: t`Organisation`, value: user?.profile?.organisation },
|
||||||
|
@ -27,6 +27,7 @@ import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
|
|||||||
import type { PanelType } from '../../../../components/panels/Panel';
|
import type { PanelType } from '../../../../components/panels/Panel';
|
||||||
import { PanelGroup } from '../../../../components/panels/PanelGroup';
|
import { PanelGroup } from '../../../../components/panels/PanelGroup';
|
||||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||||
|
import { UserRoles } from '../../../../enums/Roles';
|
||||||
import { Loadable } from '../../../../functions/loading';
|
import { Loadable } from '../../../../functions/loading';
|
||||||
import { useUserState } from '../../../../states/UserState';
|
import { useUserState } from '../../../../states/UserState';
|
||||||
|
|
||||||
@ -86,14 +87,6 @@ const CustomStateTable = Loadable(
|
|||||||
lazy(() => import('../../../../tables/settings/CustomStateTable'))
|
lazy(() => import('../../../../tables/settings/CustomStateTable'))
|
||||||
);
|
);
|
||||||
|
|
||||||
const CustomUnitsTable = Loadable(
|
|
||||||
lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
|
|
||||||
);
|
|
||||||
|
|
||||||
const PartParameterTemplateTable = Loadable(
|
|
||||||
lazy(() => import('../../../../tables/part/PartParameterTemplateTable'))
|
|
||||||
);
|
|
||||||
|
|
||||||
const PartCategoryTemplateTable = Loadable(
|
const PartCategoryTemplateTable = Loadable(
|
||||||
lazy(() => import('../../../../tables/part/PartCategoryTemplateTable'))
|
lazy(() => import('../../../../tables/part/PartCategoryTemplateTable'))
|
||||||
);
|
);
|
||||||
@ -113,7 +106,8 @@ export default function AdminCenter() {
|
|||||||
name: 'user',
|
name: 'user',
|
||||||
label: t`User Management`,
|
label: t`User Management`,
|
||||||
icon: <IconUsersGroup />,
|
icon: <IconUsersGroup />,
|
||||||
content: <UserManagementPanel />
|
content: <UserManagementPanel />,
|
||||||
|
hidden: !user.hasViewRole(UserRoles.admin)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'import',
|
name: 'import',
|
||||||
@ -178,19 +172,22 @@ export default function AdminCenter() {
|
|||||||
name: 'part-parameters',
|
name: 'part-parameters',
|
||||||
label: t`Part Parameters`,
|
label: t`Part Parameters`,
|
||||||
icon: <IconList />,
|
icon: <IconList />,
|
||||||
content: <PartParameterPanel />
|
content: <PartParameterPanel />,
|
||||||
|
hidden: !user.hasViewRole(UserRoles.part)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'category-parameters',
|
name: 'category-parameters',
|
||||||
label: t`Category Parameters`,
|
label: t`Category Parameters`,
|
||||||
icon: <IconSitemap />,
|
icon: <IconSitemap />,
|
||||||
content: <PartCategoryTemplateTable />
|
content: <PartCategoryTemplateTable />,
|
||||||
|
hidden: !user.hasViewRole(UserRoles.part_category)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'stocktake',
|
name: 'stocktake',
|
||||||
label: t`Stocktake`,
|
label: t`Stocktake`,
|
||||||
icon: <IconClipboardCheck />,
|
icon: <IconClipboardCheck />,
|
||||||
content: <StocktakePanel />
|
content: <StocktakePanel />,
|
||||||
|
hidden: !user.hasViewRole(UserRoles.stocktake)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'labels',
|
name: 'labels',
|
||||||
@ -208,22 +205,25 @@ export default function AdminCenter() {
|
|||||||
name: 'location-types',
|
name: 'location-types',
|
||||||
label: t`Location Types`,
|
label: t`Location Types`,
|
||||||
icon: <IconPackages />,
|
icon: <IconPackages />,
|
||||||
content: <LocationTypesTable />
|
content: <LocationTypesTable />,
|
||||||
|
hidden: !user.hasViewRole(UserRoles.stock_location)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'plugin',
|
name: 'plugin',
|
||||||
label: t`Plugins`,
|
label: t`Plugins`,
|
||||||
icon: <IconPlugConnected />,
|
icon: <IconPlugConnected />,
|
||||||
content: <PluginManagementPanel />
|
content: <PluginManagementPanel />,
|
||||||
|
hidden: !user.hasViewRole(UserRoles.admin)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'machine',
|
name: 'machine',
|
||||||
label: t`Machines`,
|
label: t`Machines`,
|
||||||
icon: <IconDevicesPc />,
|
icon: <IconDevicesPc />,
|
||||||
content: <MachineManagementPanel />
|
content: <MachineManagementPanel />,
|
||||||
|
hidden: !user.hasViewRole(UserRoles.admin)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, []);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -26,6 +26,14 @@ export default function UserManagementPanel() {
|
|||||||
<GroupTable />
|
<GroupTable />
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
|
<Accordion.Item value='tokens' key='tokens'>
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size='lg'>{t`Tokens`}</StylishText>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<ApiTokenTable only_myself={false} />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
<Accordion.Item value='settings' key='settings'>
|
<Accordion.Item value='settings' key='settings'>
|
||||||
<Accordion.Control>
|
<Accordion.Control>
|
||||||
<StylishText size='lg'>{t`Settings`}</StylishText>
|
<StylishText size='lg'>{t`Settings`}</StylishText>
|
||||||
@ -36,14 +44,6 @@ export default function UserManagementPanel() {
|
|||||||
/>
|
/>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value='tokens' key='tokens'>
|
|
||||||
<Accordion.Control>
|
|
||||||
<StylishText size='lg'>{t`Tokens`}</StylishText>
|
|
||||||
</Accordion.Control>
|
|
||||||
<Accordion.Panel>
|
|
||||||
<ApiTokenTable only_myself={false} />
|
|
||||||
</Accordion.Panel>
|
|
||||||
</Accordion.Item>
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,8 +8,8 @@ import { PageDetail } from '../../components/nav/PageDetail';
|
|||||||
import { PanelGroup } from '../../components/panels/PanelGroup';
|
import { PanelGroup } from '../../components/panels/PanelGroup';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import { ContactTable } from '../../tables/company/ContactTable';
|
import { ContactTable } from '../../tables/company/ContactTable';
|
||||||
import { UserTable } from '../../tables/core/UserTable';
|
|
||||||
import { GroupTable } from '../../tables/settings/GroupTable';
|
import { GroupTable } from '../../tables/settings/GroupTable';
|
||||||
|
import { UserTable } from '../../tables/settings/UserTable';
|
||||||
|
|
||||||
export default function CoreIndex() {
|
export default function CoreIndex() {
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
@ -20,7 +20,7 @@ export default function CoreIndex() {
|
|||||||
name: 'users',
|
name: 'users',
|
||||||
label: t`Users`,
|
label: t`Users`,
|
||||||
icon: <IconUser />,
|
icon: <IconUser />,
|
||||||
content: <UserTable />
|
content: <UserTable directLink />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'groups',
|
name: 'groups',
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Grid, Skeleton, Stack } from '@mantine/core';
|
import { Paper, Skeleton, Stack } from '@mantine/core';
|
||||||
import { IconInfoCircle } from '@tabler/icons-react';
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { type ReactNode, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
type DetailsField,
|
type DetailsField,
|
||||||
@ -9,6 +9,8 @@ import {
|
|||||||
} from '../../components/details/Details';
|
} from '../../components/details/Details';
|
||||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||||
import {} from '../../components/items/ActionDropdown';
|
import {} from '../../components/items/ActionDropdown';
|
||||||
|
import { RoleTable, type RuleSet } from '../../components/items/RoleTable';
|
||||||
|
import { StylishText } from '../../components/items/StylishText';
|
||||||
import InstanceDetail from '../../components/nav/InstanceDetail';
|
import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||||
import { PageDetail } from '../../components/nav/PageDetail';
|
import { PageDetail } from '../../components/nav/PageDetail';
|
||||||
import type { PanelType } from '../../components/panels/Panel';
|
import type { PanelType } from '../../components/panels/Panel';
|
||||||
@ -34,6 +36,8 @@ export default function GroupDetail() {
|
|||||||
return <Skeleton />;
|
return <Skeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roles: RuleSet[] = instance?.roles ?? [];
|
||||||
|
|
||||||
const tl: DetailsField[] = [
|
const tl: DetailsField[] = [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -45,11 +49,13 @@ export default function GroupDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemDetailsGrid>
|
<ItemDetailsGrid>
|
||||||
<Grid grow>
|
<DetailsTable fields={tl} item={instance} title={t`Group Details`} />
|
||||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
<Paper p='xs' withBorder>
|
||||||
<DetailsTable fields={tl} item={instance} />
|
<Stack gap='xs'>
|
||||||
</Grid.Col>
|
<StylishText size='lg'>{t`Group Roles`}</StylishText>
|
||||||
</Grid>
|
<RoleTable roles={roles} />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
</ItemDetailsGrid>
|
</ItemDetailsGrid>
|
||||||
);
|
);
|
||||||
}, [instance, instanceQuery]);
|
}, [instance, instanceQuery]);
|
||||||
@ -65,17 +71,12 @@ export default function GroupDetail() {
|
|||||||
];
|
];
|
||||||
}, [instance, id]);
|
}, [instance, id]);
|
||||||
|
|
||||||
const groupBadges: ReactNode[] = useMemo(() => {
|
|
||||||
return instanceQuery.isLoading ? [] : ['group info'];
|
|
||||||
}, [instance, instanceQuery]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
|
<InstanceDetail status={requestStatus} loading={instanceQuery.isFetching}>
|
||||||
<Stack gap='xs'>
|
<Stack gap='xs'>
|
||||||
<PageDetail
|
<PageDetail
|
||||||
title={`${t`Group`}: ${instance.name}`}
|
title={`${t`Group`}: ${instance.name}`}
|
||||||
imageUrl={instance?.image}
|
imageUrl={instance?.image}
|
||||||
badges={groupBadges}
|
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ name: t`System Overview`, url: '/core/' },
|
{ name: t`System Overview`, url: '/core/' },
|
||||||
{ name: t`Groups`, url: '/core/index/groups/' }
|
{ name: t`Groups`, url: '/core/index/groups/' }
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Badge, Grid, Skeleton, Stack } from '@mantine/core';
|
import { Badge, Group, Skeleton, Stack } from '@mantine/core';
|
||||||
import { IconInfoCircle } from '@tabler/icons-react';
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { type ReactNode, useMemo } from 'react';
|
import { type ReactNode, useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
@ -34,18 +34,14 @@ export default function UserDetail() {
|
|||||||
pk: id
|
pk: id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userGroups: any[] = useMemo(() => instance?.groups ?? [], [instance]);
|
||||||
|
|
||||||
const detailsPanel = useMemo(() => {
|
const detailsPanel = useMemo(() => {
|
||||||
if (instanceQuery.isFetching) {
|
if (instanceQuery.isFetching) {
|
||||||
return <Skeleton />;
|
return <Skeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tl: DetailsField[] = [
|
const tl: DetailsField[] = [
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
name: 'email',
|
|
||||||
label: t`Email`,
|
|
||||||
copy: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'username',
|
name: 'username',
|
||||||
@ -58,79 +54,132 @@ export default function UserDetail() {
|
|||||||
name: 'first_name',
|
name: 'first_name',
|
||||||
label: t`First Name`,
|
label: t`First Name`,
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
copy: true
|
copy: true,
|
||||||
|
hidden: !instance.first_name
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'last_name',
|
name: 'last_name',
|
||||||
label: t`Last Name`,
|
label: t`Last Name`,
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
copy: true
|
copy: true,
|
||||||
|
hidden: !instance.last_name
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'email',
|
||||||
|
label: t`Email`,
|
||||||
|
copy: true,
|
||||||
|
hidden: !instance.email
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const tr: DetailsField[] = [
|
const tr: DetailsField[] = [
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
name: 'is_active',
|
||||||
|
label: t`Active`,
|
||||||
|
icon: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
name: 'is_staff',
|
||||||
|
label: t`Staff`,
|
||||||
|
icon: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
name: 'is_superuser',
|
||||||
|
label: t`Superuser`,
|
||||||
|
icon: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'groups',
|
||||||
|
label: t`Groups`,
|
||||||
|
icon: 'group',
|
||||||
|
copy: false,
|
||||||
|
hidden: !userGroups,
|
||||||
|
value_formatter: () => {
|
||||||
|
return (
|
||||||
|
<Group gap='xs'>
|
||||||
|
{userGroups?.map((group) => (
|
||||||
|
<Badge key={group.pk}>{group.name}</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const br: DetailsField[] = [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'displayname',
|
name: 'displayname',
|
||||||
label: t`Display Name`,
|
label: t`Display Name`,
|
||||||
icon: 'user',
|
icon: 'user',
|
||||||
copy: true
|
copy: true,
|
||||||
|
hidden: !instance.displayname
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'position',
|
name: 'position',
|
||||||
label: t`Position`,
|
label: t`Position`,
|
||||||
icon: 'info'
|
icon: 'info',
|
||||||
},
|
hidden: !instance.position
|
||||||
{
|
|
||||||
type: 'boolean',
|
|
||||||
name: 'active',
|
|
||||||
label: t`Active`,
|
|
||||||
icon: 'info'
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'contact',
|
name: 'contact',
|
||||||
label: t`Contact`,
|
label: t`Contact`,
|
||||||
icon: 'email',
|
icon: 'email',
|
||||||
copy: true
|
copy: true,
|
||||||
|
hidden: !instance.contact
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'organisation',
|
name: 'organisation',
|
||||||
label: t`Organisation`,
|
label: t`Organisation`,
|
||||||
icon: 'info',
|
icon: 'info',
|
||||||
copy: true
|
copy: true,
|
||||||
|
hidden: !instance.organisation
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'status',
|
name: 'status',
|
||||||
label: t`Status`,
|
label: t`Status`,
|
||||||
icon: 'note'
|
icon: 'note',
|
||||||
|
hidden: !instance.status
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'location',
|
name: 'location',
|
||||||
label: t`Location`,
|
label: t`Location`,
|
||||||
icon: 'location',
|
icon: 'location',
|
||||||
copy: true
|
copy: true,
|
||||||
|
hidden: !instance.location
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const hasProfile =
|
||||||
|
instance.displayname ||
|
||||||
|
instance.position ||
|
||||||
|
instance.contact ||
|
||||||
|
instance.organisation ||
|
||||||
|
instance.status ||
|
||||||
|
instance.location;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemDetailsGrid>
|
<ItemDetailsGrid>
|
||||||
<Grid grow>
|
<DetailsTable fields={tl} item={instance} title={t`User Information`} />
|
||||||
<Grid.Col span={{ base: 12, sm: 8 }}>
|
<DetailsTable fields={tr} item={instance} title={t`User Permissions`} />
|
||||||
<DetailsTable fields={tl} item={instance} />
|
{hasProfile && settings.isSet('DISPLAY_PROFILE_INFO') && (
|
||||||
</Grid.Col>
|
<DetailsTable fields={br} item={instance} title={t`User Profile`} />
|
||||||
</Grid>
|
|
||||||
{settings.isSet('DISPLAY_PROFILE_INFO') && (
|
|
||||||
<DetailsTable fields={tr} item={instance} />
|
|
||||||
)}
|
)}
|
||||||
</ItemDetailsGrid>
|
</ItemDetailsGrid>
|
||||||
);
|
);
|
||||||
}, [instance, instanceQuery]);
|
}, [instance, userGroups, instanceQuery]);
|
||||||
|
|
||||||
const userPanels: PanelType[] = useMemo(() => {
|
const userPanels: PanelType[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
@ -1,88 +0,0 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
|
||||||
import { ModelType } from '../../enums/ModelType';
|
|
||||||
import {} from '../../hooks/UseFilter';
|
|
||||||
import { useTable } from '../../hooks/UseTable';
|
|
||||||
import { apiUrl } from '../../states/ApiState';
|
|
||||||
import { BooleanColumn } from '../ColumnRenderers';
|
|
||||||
import type { TableFilter } from '../Filter';
|
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
|
||||||
|
|
||||||
export function UserTable() {
|
|
||||||
const table = useTable('users-index');
|
|
||||||
|
|
||||||
const tableFilters: TableFilter[] = useMemo(() => {
|
|
||||||
const filters: TableFilter[] = [
|
|
||||||
{
|
|
||||||
name: 'is_active',
|
|
||||||
label: t`Active`,
|
|
||||||
description: t`Show active users`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'is_staff',
|
|
||||||
label: t`Staff`,
|
|
||||||
description: t`Show staff users`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'is_superuser',
|
|
||||||
label: t`Superuser`,
|
|
||||||
description: t`Show superusers`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return filters;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tableColumns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
accessor: 'username',
|
|
||||||
sortable: true,
|
|
||||||
switchable: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessor: 'first_name',
|
|
||||||
sortable: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessor: 'last_name',
|
|
||||||
sortable: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessor: 'email',
|
|
||||||
sortable: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessor: 'groups',
|
|
||||||
title: t`Groups`,
|
|
||||||
sortable: true,
|
|
||||||
switchable: true,
|
|
||||||
render: (record: any) => {
|
|
||||||
return record.groups.length;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
BooleanColumn({
|
|
||||||
accessor: 'is_staff'
|
|
||||||
}),
|
|
||||||
BooleanColumn({
|
|
||||||
accessor: 'is_superuser'
|
|
||||||
}),
|
|
||||||
BooleanColumn({
|
|
||||||
accessor: 'is_active'
|
|
||||||
})
|
|
||||||
];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<InvenTreeTable
|
|
||||||
url={apiUrl(ApiEndpoints.user_list)}
|
|
||||||
tableState={table}
|
|
||||||
columns={tableColumns}
|
|
||||||
props={{
|
|
||||||
tableFilters: tableFilters,
|
|
||||||
modelType: ModelType.user
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,24 +1,17 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import {
|
import { Accordion, LoadingOverlay, Stack, Text } from '@mantine/core';
|
||||||
Accordion,
|
|
||||||
Group,
|
|
||||||
LoadingOverlay,
|
|
||||||
Pill,
|
|
||||||
PillGroup,
|
|
||||||
Stack,
|
|
||||||
Text
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
import AdminButton from '../../components/buttons/AdminButton';
|
|
||||||
import { EditApiForm } from '../../components/forms/ApiForm';
|
import { EditApiForm } from '../../components/forms/ApiForm';
|
||||||
|
import { RoleTable, type RuleSet } from '../../components/items/RoleTable';
|
||||||
import { StylishText } from '../../components/items/StylishText';
|
import { StylishText } from '../../components/items/StylishText';
|
||||||
import { DetailDrawer } from '../../components/nav/DetailDrawer';
|
import { DetailDrawer } from '../../components/nav/DetailDrawer';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import { ModelType } from '../../enums/ModelType';
|
||||||
|
import { UserRoles } from '../../enums/Roles';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal
|
useDeleteApiFormModal
|
||||||
@ -52,32 +45,14 @@ export function GroupDrawer({
|
|||||||
pk: id,
|
pk: id,
|
||||||
throwError: true,
|
throwError: true,
|
||||||
params: {
|
params: {
|
||||||
permission_detail: true
|
permission_detail: true,
|
||||||
|
role_detail: true,
|
||||||
|
user_detail: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const permissionsAccordion = useMemo(() => {
|
const groupRoles: RuleSet[] = useMemo(() => {
|
||||||
if (!instance?.permissions) return null;
|
return instance?.roles ?? [];
|
||||||
|
|
||||||
const data = instance.permissions;
|
|
||||||
return (
|
|
||||||
<Accordion w={'100%'}>
|
|
||||||
{Object.keys(data).map((key) => (
|
|
||||||
<Accordion.Item key={key} value={key}>
|
|
||||||
<Accordion.Control>
|
|
||||||
<Pill>{instance.permissions[key].length}</Pill> {key}
|
|
||||||
</Accordion.Control>
|
|
||||||
<Accordion.Panel>
|
|
||||||
<PillGroup>
|
|
||||||
{data[key].map((perm: string) => (
|
|
||||||
<Pill key={perm}>{perm}</Pill>
|
|
||||||
))}
|
|
||||||
</PillGroup>
|
|
||||||
</Accordion.Panel>
|
|
||||||
</Accordion.Item>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
}, [instance]);
|
}, [instance]);
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
@ -98,12 +73,23 @@ export function GroupDrawer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<Accordion defaultValue={'details'}>
|
||||||
|
<Accordion.Item key='details' value='details'>
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size='lg'>
|
||||||
|
<Trans>Group Details</Trans>
|
||||||
|
</StylishText>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
<EditApiForm
|
<EditApiForm
|
||||||
props={{
|
props={{
|
||||||
url: ApiEndpoints.group_list,
|
url: ApiEndpoints.group_list,
|
||||||
pk: id,
|
pk: id,
|
||||||
fields: {
|
fields: {
|
||||||
name: {}
|
name: {
|
||||||
|
label: t`Name`,
|
||||||
|
description: t`Name of the user group`
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onFormSuccess: () => {
|
onFormSuccess: () => {
|
||||||
refreshTable();
|
refreshTable();
|
||||||
@ -112,13 +98,20 @@ export function GroupDrawer({
|
|||||||
}}
|
}}
|
||||||
id={`group-detail-drawer-${id}`}
|
id={`group-detail-drawer-${id}`}
|
||||||
/>
|
/>
|
||||||
<Group justify='space-between'>
|
</Accordion.Panel>
|
||||||
<StylishText size='md'>
|
</Accordion.Item>
|
||||||
<Trans>Permission set</Trans>
|
|
||||||
|
<Accordion.Item key='roles' value='roles'>
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size='lg'>
|
||||||
|
<Trans>Group Roles</Trans>
|
||||||
</StylishText>
|
</StylishText>
|
||||||
<AdminButton model={ModelType.group} id={instance.pk} />
|
</Accordion.Control>
|
||||||
</Group>
|
<Accordion.Panel>
|
||||||
<Group>{permissionsAccordion}</Group>
|
<RoleTable roles={groupRoles} editable />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -185,8 +178,13 @@ export function GroupTable({
|
|||||||
|
|
||||||
const newGroup = useCreateApiFormModal({
|
const newGroup = useCreateApiFormModal({
|
||||||
url: ApiEndpoints.group_list,
|
url: ApiEndpoints.group_list,
|
||||||
title: t`Add group`,
|
title: t`Add Group`,
|
||||||
fields: { name: {} },
|
fields: {
|
||||||
|
name: {
|
||||||
|
label: t`Name`,
|
||||||
|
description: t`Name of the user group`
|
||||||
|
}
|
||||||
|
},
|
||||||
table: table
|
table: table
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -205,12 +203,20 @@ export function GroupTable({
|
|||||||
return actions;
|
return actions;
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// Determine whether the GroupTable is editable
|
||||||
|
const editable: boolean = useMemo(
|
||||||
|
() => !directLink && user.isStaff() && user.hasChangeRole(UserRoles.admin),
|
||||||
|
[user, directLink]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{newGroup.modal}
|
{editable && newGroup.modal}
|
||||||
{deleteGroup.modal}
|
{editable && deleteGroup.modal}
|
||||||
|
{editable && (
|
||||||
<DetailDrawer
|
<DetailDrawer
|
||||||
title={t`Edit group`}
|
size='xl'
|
||||||
|
title={t`Edit Group`}
|
||||||
renderContent={(id) => {
|
renderContent={(id) => {
|
||||||
if (!id || !id.startsWith('group-')) return false;
|
if (!id || !id.startsWith('group-')) return false;
|
||||||
return (
|
return (
|
||||||
@ -221,18 +227,18 @@ export function GroupTable({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.group_list)}
|
url={apiUrl(ApiEndpoints.group_list)}
|
||||||
tableState={table}
|
tableState={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
props={{
|
props={{
|
||||||
rowActions: directLink ? undefined : rowActions,
|
rowActions: editable ? rowActions : undefined,
|
||||||
tableActions: tableActions,
|
tableActions: editable ? tableActions : undefined,
|
||||||
onRowClick: directLink
|
modelType: directLink ? ModelType.group : undefined,
|
||||||
? undefined
|
onRowClick: editable
|
||||||
: (record) => openDetailDrawer(record.pk),
|
? (record) => openDetailDrawer(record.pk)
|
||||||
|
: undefined
|
||||||
modelType: ModelType.group
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -1,26 +1,24 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import {
|
import { Accordion, Alert, LoadingOverlay, Stack, Text } from '@mantine/core';
|
||||||
Alert,
|
|
||||||
List,
|
|
||||||
LoadingOverlay,
|
|
||||||
Spoiler,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconInfoCircle } from '@tabler/icons-react';
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
|
import { showNotification } from '@mantine/notifications';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../../App';
|
||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
import { EditApiForm } from '../../components/forms/ApiForm';
|
import { EditApiForm } from '../../components/forms/ApiForm';
|
||||||
|
import { StylishText } from '../../components/items/StylishText';
|
||||||
import {
|
import {
|
||||||
DetailDrawer,
|
TransferList,
|
||||||
DetailDrawerLink
|
type TransferListItem
|
||||||
} from '../../components/nav/DetailDrawer';
|
} from '../../components/items/TransferList';
|
||||||
|
import { DetailDrawer } from '../../components/nav/DetailDrawer';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { ModelType } from '../../enums/ModelType';
|
import { ModelType } from '../../enums/ModelType';
|
||||||
|
import { UserRoles } from '../../enums/Roles';
|
||||||
|
import { showApiErrorMessage } from '../../functions/notifications';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal
|
useDeleteApiFormModal
|
||||||
@ -66,11 +64,70 @@ export function UserDrawer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentUserPk = useUserState((s) => s.user?.pk);
|
const currentUserPk = useUserState((s) => s.user?.pk);
|
||||||
|
|
||||||
const isCurrentUser = useMemo(
|
const isCurrentUser = useMemo(
|
||||||
() => currentUserPk === Number.parseInt(id, 10),
|
() => currentUserPk === Number.parseInt(id, 10),
|
||||||
[currentUserPk, id]
|
[currentUserPk, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const userGroups = useInstance({
|
||||||
|
endpoint: ApiEndpoints.group_list,
|
||||||
|
hasPrimaryKey: false,
|
||||||
|
defaultValue: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableGroups: TransferListItem[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
userGroups.instance?.map((group: any) => {
|
||||||
|
return {
|
||||||
|
value: group.pk,
|
||||||
|
label: group.name
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}, [userGroups.instance]);
|
||||||
|
|
||||||
|
const selectedGroups: TransferListItem[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
userDetail?.groups?.map((group: any) => {
|
||||||
|
return {
|
||||||
|
value: group.pk,
|
||||||
|
label: group.name
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}, [userDetail]);
|
||||||
|
|
||||||
|
const onSaveGroups = useCallback(
|
||||||
|
(selected: TransferListItem[]) => {
|
||||||
|
if (!userDetail.pk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.patch(apiUrl(ApiEndpoints.user_list, userDetail.pk), {
|
||||||
|
group_ids: selected.map((group) => group.value)
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
showNotification({
|
||||||
|
title: t`Groups updated`,
|
||||||
|
message: t`User groups updated successfully`,
|
||||||
|
color: 'green'
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
showApiErrorMessage({
|
||||||
|
error: error,
|
||||||
|
title: t`Error updating user groups`
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshInstance();
|
||||||
|
refreshTable();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[userDetail]
|
||||||
|
);
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return <LoadingOverlay visible={true} />;
|
return <LoadingOverlay visible={true} />;
|
||||||
}
|
}
|
||||||
@ -88,7 +145,15 @@ export function UserDrawer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack gap='xs'>
|
||||||
|
<Accordion defaultValue={'details'}>
|
||||||
|
<Accordion.Item key='details' value='details'>
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size='lg'>
|
||||||
|
<Trans>User Details</Trans>
|
||||||
|
</StylishText>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
<EditApiForm
|
<EditApiForm
|
||||||
props={{
|
props={{
|
||||||
url: ApiEndpoints.user_list,
|
url: ApiEndpoints.user_list,
|
||||||
@ -121,7 +186,8 @@ export function UserDrawer({
|
|||||||
icon={<IconInfoCircle />}
|
icon={<IconInfoCircle />}
|
||||||
>
|
>
|
||||||
<Trans>
|
<Trans>
|
||||||
You cannot edit the rights for the currently logged-in user.
|
You cannot edit the rights for the currently logged-in
|
||||||
|
user.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : undefined,
|
) : undefined,
|
||||||
@ -132,30 +198,24 @@ export function UserDrawer({
|
|||||||
}}
|
}}
|
||||||
id={`user-detail-drawer-${id}`}
|
id={`user-detail-drawer-${id}`}
|
||||||
/>
|
/>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
|
||||||
<Stack>
|
<Accordion.Item key='groups' value='groups'>
|
||||||
<Title order={5}>
|
<Accordion.Control>
|
||||||
<Trans>Groups</Trans>
|
<StylishText size='lg'>
|
||||||
</Title>
|
<Trans>User Groups</Trans>
|
||||||
<Spoiler maxHeight={125} showLabel='Show More' hideLabel='Show Less'>
|
</StylishText>
|
||||||
<Text ml={'md'}>
|
</Accordion.Control>
|
||||||
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
|
<Accordion.Panel>
|
||||||
<List>
|
<TransferList
|
||||||
{userDetail?.groups?.map((group: any) => (
|
available={availableGroups}
|
||||||
<List.Item key={group.pk}>
|
selected={selectedGroups}
|
||||||
<DetailDrawerLink
|
onSave={onSaveGroups}
|
||||||
to={`../group-${group.pk}`}
|
|
||||||
text={group.name}
|
|
||||||
/>
|
/>
|
||||||
</List.Item>
|
</Accordion.Panel>
|
||||||
))}
|
</Accordion.Item>
|
||||||
</List>
|
</Accordion>
|
||||||
) : (
|
|
||||||
<Trans>No groups</Trans>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Spoiler>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -163,7 +223,11 @@ export function UserDrawer({
|
|||||||
/**
|
/**
|
||||||
* Table for displaying list of users
|
* Table for displaying list of users
|
||||||
*/
|
*/
|
||||||
export function UserTable() {
|
export function UserTable({
|
||||||
|
directLink
|
||||||
|
}: {
|
||||||
|
directLink?: boolean;
|
||||||
|
}) {
|
||||||
const table = useTable('users');
|
const table = useTable('users');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
@ -222,13 +286,14 @@ export function UserTable() {
|
|||||||
|
|
||||||
const rowActions = useCallback(
|
const rowActions = useCallback(
|
||||||
(record: UserDetailI): RowAction[] => {
|
(record: UserDetailI): RowAction[] => {
|
||||||
|
const staff: boolean = user.isStaff() || user.isSuperuser();
|
||||||
return [
|
return [
|
||||||
RowEditAction({
|
RowEditAction({
|
||||||
onClick: () => openDetailDrawer(record.pk),
|
onClick: () => openDetailDrawer(record.pk),
|
||||||
hidden: !user.hasChangePermission(ModelType.user)
|
hidden: !staff || !user.hasChangePermission(ModelType.user)
|
||||||
}),
|
}),
|
||||||
RowDeleteAction({
|
RowDeleteAction({
|
||||||
hidden: !user.hasDeletePermission(ModelType.user),
|
hidden: !staff || !user.hasDeletePermission(ModelType.user),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedUser(record.pk);
|
setSelectedUser(record.pk);
|
||||||
deleteUser.open();
|
deleteUser.open();
|
||||||
@ -264,13 +329,14 @@ export function UserTable() {
|
|||||||
|
|
||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
const staff: boolean = user.isStaff() || user.isSuperuser();
|
||||||
|
|
||||||
actions.push(
|
actions.push(
|
||||||
<AddItemButton
|
<AddItemButton
|
||||||
key='add-user'
|
key='add-user'
|
||||||
onClick={newUser.open}
|
onClick={newUser.open}
|
||||||
tooltip={t`Add user`}
|
tooltip={t`Add user`}
|
||||||
hidden={!user.hasAddPermission(ModelType.user)}
|
hidden={!staff || !user.hasAddPermission(ModelType.user)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -297,12 +363,20 @@ export function UserTable() {
|
|||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Determine whether the UserTable is editable
|
||||||
|
const editable: boolean = useMemo(
|
||||||
|
() => !directLink && user.isStaff() && user.hasChangeRole(UserRoles.admin),
|
||||||
|
[user, directLink]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{newUser.modal}
|
{editable && newUser.modal}
|
||||||
{deleteUser.modal}
|
{editable && deleteUser.modal}
|
||||||
|
{editable && (
|
||||||
<DetailDrawer
|
<DetailDrawer
|
||||||
title={t`Edit user`}
|
size='xl'
|
||||||
|
title={t`Edit User`}
|
||||||
renderContent={(id) => {
|
renderContent={(id) => {
|
||||||
if (!id || !id.startsWith('user-')) return false;
|
if (!id || !id.startsWith('user-')) return false;
|
||||||
return (
|
return (
|
||||||
@ -313,15 +387,19 @@ export function UserTable() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.user_list)}
|
url={apiUrl(ApiEndpoints.user_list)}
|
||||||
tableState={table}
|
tableState={table}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
props={{
|
props={{
|
||||||
rowActions: rowActions,
|
rowActions: editable ? rowActions : undefined,
|
||||||
tableActions: tableActions,
|
tableActions: editable ? tableActions : undefined,
|
||||||
tableFilters: tableFilters,
|
tableFilters: tableFilters,
|
||||||
onRowClick: (record) => openDetailDrawer(record.pk)
|
onRowClick: editable
|
||||||
|
? (record) => openDetailDrawer(record.pk)
|
||||||
|
: undefined,
|
||||||
|
modelType: directLink ? ModelType.user : undefined
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
91
src/frontend/tests/pui_permissions.spec.ts
Normal file
91
src/frontend/tests/pui_permissions.spec.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Tests for UI permissions checks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from '@playwright/test';
|
||||||
|
import { loadTab } from './helpers';
|
||||||
|
import { doCachedLogin } from './login';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the "admin" account
|
||||||
|
* - This is a superuser account, so should have *all* permissions available
|
||||||
|
*/
|
||||||
|
test('Permissions - Admin', async ({ browser, request }) => {
|
||||||
|
// Login, and start on the "admin" page
|
||||||
|
const page = await doCachedLogin(browser, {
|
||||||
|
username: 'admin',
|
||||||
|
password: 'inventree',
|
||||||
|
url: '/settings/admin/'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for expected tabs
|
||||||
|
await loadTab(page, 'Machines');
|
||||||
|
await loadTab(page, 'Plugins');
|
||||||
|
await loadTab(page, 'User Management');
|
||||||
|
|
||||||
|
// Let's create a new user
|
||||||
|
await page.getByLabel('action-button-add-user').click();
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the "reader" account
|
||||||
|
* - This account is read-only, but should be able to access *most* pages
|
||||||
|
*/
|
||||||
|
test('Permissions - Reader', async ({ browser, request }) => {
|
||||||
|
// Login, and start on the "admin" page
|
||||||
|
const page = await doCachedLogin(browser, {
|
||||||
|
username: 'reader',
|
||||||
|
password: 'readonly',
|
||||||
|
url: '/part/category/index/'
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadTab(page, 'Category Details');
|
||||||
|
await loadTab(page, 'Parts');
|
||||||
|
|
||||||
|
// Navigate to a specific part
|
||||||
|
await page.getByPlaceholder('Search').fill('Blue Chair');
|
||||||
|
await page
|
||||||
|
.getByRole('cell', { name: 'Thumbnail Blue Chair' })
|
||||||
|
.locator('div')
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByLabel('Part Details')
|
||||||
|
.getByText('A chair - with blue paint')
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
|
// Printing actions *are* available to the reader account
|
||||||
|
await page.getByLabel('action-menu-printing-actions').waitFor();
|
||||||
|
|
||||||
|
// Check that the user *does not* have the part actions menu
|
||||||
|
const actionsMenuVisible = await page
|
||||||
|
.getByLabel('action-menu-part-actions')
|
||||||
|
.isVisible({ timeout: 2500 });
|
||||||
|
|
||||||
|
if (actionsMenuVisible) {
|
||||||
|
throw new Error('Actions menu should not be visible for reader account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the user / group info (via the navigation menu)
|
||||||
|
await page.getByLabel('navigation-menu').click();
|
||||||
|
await page.getByRole('button', { name: 'Users' }).click();
|
||||||
|
await page.getByText('System Overview', { exact: true }).waitFor();
|
||||||
|
await loadTab(page, 'Users');
|
||||||
|
await loadTab(page, 'Groups');
|
||||||
|
await page.getByRole('cell', { name: 'engineering' }).waitFor();
|
||||||
|
|
||||||
|
// Go to the user profile page
|
||||||
|
await page.getByRole('button', { name: 'Ronald Reader' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Account Settings' }).click();
|
||||||
|
|
||||||
|
await loadTab(page, 'Notifications');
|
||||||
|
await loadTab(page, 'Display Options');
|
||||||
|
await loadTab(page, 'Security');
|
||||||
|
await loadTab(page, 'Account');
|
||||||
|
|
||||||
|
await page.getByText('Account Details').waitFor();
|
||||||
|
await page.getByText('Profile Details').waitFor();
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user