mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[PUI] Add permissions to groups (#7621)
* Add permissions to group API * factor out permission formatting * add group permission details to UI * add nicer accordions with permissions * add group to models * Add Admin button to change permissions * add missing instance renderer * turn off default view permission to everything * add migration * fix rule assigment * Add now missing view permissions * Adjust test for the now new default permission count * add missing view permission * fix permissions for search test * adjust search testing to also account for missing permissions * adjust to new defaults * expand role testing --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
parent
16e535f45f
commit
ffd55cf164
@ -62,6 +62,7 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
"""Tests for the InvenTree API."""
|
"""Tests for the InvenTree API."""
|
||||||
|
|
||||||
fixtures = ['location', 'category', 'part', 'stock']
|
fixtures = ['location', 'category', 'part', 'stock']
|
||||||
|
roles = ['part.view']
|
||||||
token = None
|
token = None
|
||||||
auto_login = False
|
auto_login = False
|
||||||
|
|
||||||
@ -132,6 +133,7 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
# Now log in!
|
# Now log in!
|
||||||
self.basicAuth()
|
self.basicAuth()
|
||||||
|
self.assignRole('part.view')
|
||||||
|
|
||||||
response = self.get(url)
|
response = self.get(url)
|
||||||
|
|
||||||
@ -147,12 +149,17 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
role_names = roles.keys()
|
role_names = roles.keys()
|
||||||
|
|
||||||
# By default, 'view' permissions are provided
|
# By default, no permissions are provided
|
||||||
for rule in RuleSet.RULESET_NAMES:
|
for rule in RuleSet.RULESET_NAMES:
|
||||||
self.assertIn(rule, role_names)
|
self.assertIn(rule, role_names)
|
||||||
|
|
||||||
self.assertIn('view', roles[rule])
|
if roles[rule] is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rule == 'part':
|
||||||
|
self.assertIn('view', roles[rule])
|
||||||
|
else:
|
||||||
|
self.assertNotIn('view', roles[rule])
|
||||||
self.assertNotIn('add', roles[rule])
|
self.assertNotIn('add', roles[rule])
|
||||||
self.assertNotIn('change', roles[rule])
|
self.assertNotIn('change', roles[rule])
|
||||||
self.assertNotIn('delete', roles[rule])
|
self.assertNotIn('delete', roles[rule])
|
||||||
@ -297,6 +304,7 @@ class SearchTests(InvenTreeAPITestCase):
|
|||||||
'order',
|
'order',
|
||||||
'sales_order',
|
'sales_order',
|
||||||
]
|
]
|
||||||
|
roles = ['build.view', 'part.view']
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
"""Test empty request."""
|
"""Test empty request."""
|
||||||
@ -331,6 +339,19 @@ class SearchTests(InvenTreeAPITestCase):
|
|||||||
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
|
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
|
||||||
expected_code=200,
|
expected_code=200,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data['purchaseorder'],
|
||||||
|
{'error': 'User does not have permission to view this model'},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add permissions and try again
|
||||||
|
self.assignRole('purchase_order.view')
|
||||||
|
self.assignRole('sales_order.view')
|
||||||
|
response = self.post(
|
||||||
|
reverse('api-search'),
|
||||||
|
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
|
||||||
|
expected_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(response.data['purchaseorder']['count'], 1)
|
self.assertEqual(response.data['purchaseorder']['count'], 1)
|
||||||
self.assertEqual(response.data['salesorder']['count'], 0)
|
self.assertEqual(response.data['salesorder']['count'], 0)
|
||||||
|
@ -71,6 +71,7 @@ class MiddlewareTests(InvenTreeTestCase):
|
|||||||
|
|
||||||
def test_error_exceptions(self):
|
def test_error_exceptions(self):
|
||||||
"""Test that ignored errors are not logged."""
|
"""Test that ignored errors are not logged."""
|
||||||
|
self.assignRole('part.view')
|
||||||
|
|
||||||
def check(excpected_nbr=0):
|
def check(excpected_nbr=0):
|
||||||
# Check that errors are empty
|
# Check that errors are empty
|
||||||
|
@ -204,6 +204,7 @@ class UserMixin:
|
|||||||
ruleset.can_add = True
|
ruleset.can_add = True
|
||||||
|
|
||||||
ruleset.save()
|
ruleset.save()
|
||||||
|
if not assign_all:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
|
@ -160,7 +160,7 @@ class CompanyTest(InvenTreeAPITestCase):
|
|||||||
class ContactTest(InvenTreeAPITestCase):
|
class ContactTest(InvenTreeAPITestCase):
|
||||||
"""Tests for the Contact models."""
|
"""Tests for the Contact models."""
|
||||||
|
|
||||||
roles = []
|
roles = ['purchase_order.view']
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@ -266,7 +266,7 @@ class ContactTest(InvenTreeAPITestCase):
|
|||||||
class AddressTest(InvenTreeAPITestCase):
|
class AddressTest(InvenTreeAPITestCase):
|
||||||
"""Test cases for Address API endpoints."""
|
"""Test cases for Address API endpoints."""
|
||||||
|
|
||||||
roles = []
|
roles = ['purchase_order.view']
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
@ -2010,6 +2010,7 @@ class ReturnOrderTests(InvenTreeAPITestCase):
|
|||||||
'supplier_part',
|
'supplier_part',
|
||||||
'stock',
|
'stock',
|
||||||
]
|
]
|
||||||
|
roles = ['return_order.view']
|
||||||
|
|
||||||
def test_options(self):
|
def test_options(self):
|
||||||
"""Test the OPTIONS endpoint."""
|
"""Test the OPTIONS endpoint."""
|
||||||
|
@ -512,7 +512,7 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
|||||||
Ensure that the required field details are provided!
|
Ensure that the required field details are provided!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
roles = ['part.add']
|
roles = ['part.add', 'part_category.view']
|
||||||
|
|
||||||
def test_part(self):
|
def test_part(self):
|
||||||
"""Test the Part API OPTIONS."""
|
"""Test the Part API OPTIONS."""
|
||||||
@ -2149,7 +2149,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
fixtures = ['category', 'part', 'location', 'stock', 'bom', 'company']
|
fixtures = ['category', 'part', 'location', 'stock', 'bom', 'company']
|
||||||
|
|
||||||
roles = ['part.add', 'part.change', 'part.delete']
|
roles = ['part.add', 'part.change', 'part.delete', 'stock.view']
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up the test case."""
|
"""Set up the test case."""
|
||||||
@ -2642,6 +2642,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
superuser = False
|
superuser = False
|
||||||
is_staff = False
|
is_staff = False
|
||||||
|
roles = ['stocktake.view']
|
||||||
|
|
||||||
fixtures = ['category', 'part', 'location', 'stock']
|
fixtures = ['category', 'part', 'location', 'stock']
|
||||||
|
|
||||||
|
@ -162,7 +162,24 @@ class UserList(ListCreateAPI):
|
|||||||
filterset_fields = ['is_staff', 'is_active', 'is_superuser']
|
filterset_fields = ['is_staff', 'is_active', 'is_superuser']
|
||||||
|
|
||||||
|
|
||||||
class GroupDetail(RetrieveUpdateDestroyAPI):
|
class GroupMixin:
|
||||||
|
"""Mixin for Group API endpoints to add permissions filter."""
|
||||||
|
|
||||||
|
def get_serializer(self, *args, **kwargs):
|
||||||
|
"""Return serializer instance for this endpoint."""
|
||||||
|
# Do we wish to include extra detail?
|
||||||
|
try:
|
||||||
|
params = self.request.query_params
|
||||||
|
kwargs['permission_detail'] = InvenTree.helpers.str2bool(
|
||||||
|
params.get('permission_detail', None)
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
kwargs['context'] = self.get_serializer_context()
|
||||||
|
return self.serializer_class(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupDetail(GroupMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""Detail endpoint for a particular auth group."""
|
"""Detail endpoint for a particular auth group."""
|
||||||
|
|
||||||
queryset = Group.objects.all()
|
queryset = Group.objects.all()
|
||||||
@ -170,7 +187,7 @@ class GroupDetail(RetrieveUpdateDestroyAPI):
|
|||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
class GroupList(ListCreateAPI):
|
class GroupList(GroupMixin, ListCreateAPI):
|
||||||
"""List endpoint for all auth groups."""
|
"""List endpoint for all auth groups."""
|
||||||
|
|
||||||
queryset = Group.objects.all()
|
queryset = Group.objects.all()
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 4.2.12 on 2024-07-18 21:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0011_auto_20240523_1640"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="ruleset",
|
||||||
|
name="can_view",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="Permission to view items", verbose_name="View"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -389,7 +389,7 @@ class RuleSet(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
can_view = models.BooleanField(
|
can_view = models.BooleanField(
|
||||||
verbose_name=_('View'), default=True, help_text=_('Permission to view items')
|
verbose_name=_('View'), default=False, help_text=_('Permission to view items')
|
||||||
)
|
)
|
||||||
|
|
||||||
can_add = models.BooleanField(
|
can_add = models.BooleanField(
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""DRF API serializers for the 'users' app."""
|
"""DRF API serializers for the 'users' app."""
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, Permission, User
|
from django.contrib.auth.models import Group, Permission, User
|
||||||
|
from django.core.exceptions import AppRegistryNotReady
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@ -31,7 +32,25 @@ class GroupSerializer(InvenTreeModelSerializer):
|
|||||||
"""Metaclass defines serializer fields."""
|
"""Metaclass defines serializer fields."""
|
||||||
|
|
||||||
model = Group
|
model = Group
|
||||||
fields = ['pk', 'name']
|
fields = ['pk', 'name', 'permissions']
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialize this serializer with extra fields as required."""
|
||||||
|
permission_detail = kwargs.pop('permission_detail', False)
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not permission_detail:
|
||||||
|
self.fields.pop('permissions', None)
|
||||||
|
except AppRegistryNotReady:
|
||||||
|
pass
|
||||||
|
|
||||||
|
permissions = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_permissions(self, group: Group):
|
||||||
|
"""Return a list of permissions associated with the group."""
|
||||||
|
return generate_permission_dict(group.permissions.all())
|
||||||
|
|
||||||
|
|
||||||
class RoleSerializer(InvenTreeModelSerializer):
|
class RoleSerializer(InvenTreeModelSerializer):
|
||||||
@ -83,6 +102,11 @@ class RoleSerializer(InvenTreeModelSerializer):
|
|||||||
Q(user=user) | Q(group__user=user)
|
Q(user=user) | Q(group__user=user)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
return generate_permission_dict(permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_permission_dict(permissions):
|
||||||
|
"""Generate a dictionary of permissions for a given set of permissions."""
|
||||||
perms = {}
|
perms = {}
|
||||||
|
|
||||||
for permission in permissions:
|
for permission in permissions:
|
||||||
|
@ -123,8 +123,8 @@ class RuleSetModelTest(TestCase):
|
|||||||
for model in models:
|
for model in models:
|
||||||
permission_set.add(model)
|
permission_set.add(model)
|
||||||
|
|
||||||
# Every ruleset by default sets one permission, the "view" permission set
|
# By default no permissions should be assigned
|
||||||
self.assertEqual(group.permissions.count(), len(permission_set))
|
self.assertEqual(group.permissions.count(), 0)
|
||||||
|
|
||||||
# Add some more rules
|
# Add some more rules
|
||||||
for rule in rulesets:
|
for rule in rulesets:
|
||||||
|
@ -37,7 +37,7 @@ import {
|
|||||||
RenderStockLocation,
|
RenderStockLocation,
|
||||||
RenderStockLocationType
|
RenderStockLocationType
|
||||||
} from './Stock';
|
} from './Stock';
|
||||||
import { RenderOwner, RenderUser } from './User';
|
import { RenderGroup, RenderOwner, RenderUser } from './User';
|
||||||
|
|
||||||
type EnumDictionary<T extends string | symbol | number, U> = {
|
type EnumDictionary<T extends string | symbol | number, U> = {
|
||||||
[K in T]: U;
|
[K in T]: U;
|
||||||
@ -81,6 +81,7 @@ const RendererLookup: EnumDictionary<
|
|||||||
[ModelType.stockhistory]: RenderStockItem,
|
[ModelType.stockhistory]: RenderStockItem,
|
||||||
[ModelType.supplierpart]: RenderSupplierPart,
|
[ModelType.supplierpart]: RenderSupplierPart,
|
||||||
[ModelType.user]: RenderUser,
|
[ModelType.user]: RenderUser,
|
||||||
|
[ModelType.group]: RenderGroup,
|
||||||
[ModelType.importsession]: RenderImportSession,
|
[ModelType.importsession]: RenderImportSession,
|
||||||
[ModelType.reporttemplate]: RenderReportTemplate,
|
[ModelType.reporttemplate]: RenderReportTemplate,
|
||||||
[ModelType.labeltemplate]: RenderLabelTemplate,
|
[ModelType.labeltemplate]: RenderLabelTemplate,
|
||||||
|
@ -201,6 +201,14 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_detail: '/user/:pk/',
|
url_detail: '/user/:pk/',
|
||||||
api_endpoint: ApiEndpoints.user_list
|
api_endpoint: ApiEndpoints.user_list
|
||||||
},
|
},
|
||||||
|
group: {
|
||||||
|
label: t`Group`,
|
||||||
|
label_multiple: t`Groups`,
|
||||||
|
url_overview: '/user/group',
|
||||||
|
url_detail: '/user/group-:pk',
|
||||||
|
api_endpoint: ApiEndpoints.group_list,
|
||||||
|
admin_url: '/auth/group/'
|
||||||
|
},
|
||||||
importsession: {
|
importsession: {
|
||||||
label: t`Import Session`,
|
label: t`Import Session`,
|
||||||
label_multiple: t`Import Sessions`,
|
label_multiple: t`Import Sessions`,
|
||||||
|
@ -28,3 +28,9 @@ export function RenderUser({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenderGroup({
|
||||||
|
instance
|
||||||
|
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||||
|
return instance && <RenderInlineModel primary={instance.name} />;
|
||||||
|
}
|
||||||
|
@ -27,6 +27,7 @@ export enum ModelType {
|
|||||||
contact = 'contact',
|
contact = 'contact',
|
||||||
owner = 'owner',
|
owner = 'owner',
|
||||||
user = 'user',
|
user = 'user',
|
||||||
|
group = 'group',
|
||||||
reporttemplate = 'reporttemplate',
|
reporttemplate = 'reporttemplate',
|
||||||
labeltemplate = 'labeltemplate',
|
labeltemplate = 'labeltemplate',
|
||||||
pluginconfig = 'pluginconfig'
|
pluginconfig = 'pluginconfig'
|
||||||
|
@ -1,13 +1,23 @@
|
|||||||
import { Trans, t } from '@lingui/macro';
|
import { Trans, t } from '@lingui/macro';
|
||||||
import { Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
|
import {
|
||||||
|
Accordion,
|
||||||
|
Group,
|
||||||
|
LoadingOverlay,
|
||||||
|
Pill,
|
||||||
|
PillGroup,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title
|
||||||
|
} 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 { PlaceholderPill } from '../../components/items/Placeholder';
|
|
||||||
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 {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal
|
useDeleteApiFormModal
|
||||||
@ -32,14 +42,42 @@ export function GroupDrawer({
|
|||||||
refreshTable: () => void;
|
refreshTable: () => void;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
|
instance,
|
||||||
refreshInstance,
|
refreshInstance,
|
||||||
instanceQuery: { isFetching, error }
|
instanceQuery: { isFetching, error }
|
||||||
} = useInstance({
|
} = useInstance({
|
||||||
endpoint: ApiEndpoints.group_list,
|
endpoint: ApiEndpoints.group_list,
|
||||||
pk: id,
|
pk: id,
|
||||||
throwError: true
|
throwError: true,
|
||||||
|
params: {
|
||||||
|
permission_detail: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const permissionsAccordion = useMemo(() => {
|
||||||
|
if (!instance?.permissions) return null;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return <LoadingOverlay visible={true} />;
|
return <LoadingOverlay visible={true} />;
|
||||||
}
|
}
|
||||||
@ -72,13 +110,13 @@ export function GroupDrawer({
|
|||||||
}}
|
}}
|
||||||
id={`group-detail-drawer-${id}`}
|
id={`group-detail-drawer-${id}`}
|
||||||
/>
|
/>
|
||||||
|
<Group justify="space-between">
|
||||||
<Title order={5}>
|
<Title order={5}>
|
||||||
<Trans>Permission set</Trans>
|
<Trans>Permission set</Trans>
|
||||||
</Title>
|
</Title>
|
||||||
<Group>
|
<AdminButton model={ModelType.group} pk={instance.pk} />
|
||||||
<PlaceholderPill />
|
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group>{permissionsAccordion}</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user