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."""
|
||||
|
||||
fixtures = ['location', 'category', 'part', 'stock']
|
||||
roles = ['part.view']
|
||||
token = None
|
||||
auto_login = False
|
||||
|
||||
@ -132,6 +133,7 @@ class APITests(InvenTreeAPITestCase):
|
||||
|
||||
# Now log in!
|
||||
self.basicAuth()
|
||||
self.assignRole('part.view')
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
@ -147,12 +149,17 @@ class APITests(InvenTreeAPITestCase):
|
||||
|
||||
role_names = roles.keys()
|
||||
|
||||
# By default, 'view' permissions are provided
|
||||
# By default, no permissions are provided
|
||||
for rule in RuleSet.RULESET_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('change', roles[rule])
|
||||
self.assertNotIn('delete', roles[rule])
|
||||
@ -297,6 +304,7 @@ class SearchTests(InvenTreeAPITestCase):
|
||||
'order',
|
||||
'sales_order',
|
||||
]
|
||||
roles = ['build.view', 'part.view']
|
||||
|
||||
def test_empty(self):
|
||||
"""Test empty request."""
|
||||
@ -331,6 +339,19 @@ class SearchTests(InvenTreeAPITestCase):
|
||||
{'search': '01', 'limit': 2, 'purchaseorder': {}, 'salesorder': {}},
|
||||
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['salesorder']['count'], 0)
|
||||
|
@ -71,6 +71,7 @@ class MiddlewareTests(InvenTreeTestCase):
|
||||
|
||||
def test_error_exceptions(self):
|
||||
"""Test that ignored errors are not logged."""
|
||||
self.assignRole('part.view')
|
||||
|
||||
def check(excpected_nbr=0):
|
||||
# Check that errors are empty
|
||||
|
@ -204,6 +204,7 @@ class UserMixin:
|
||||
ruleset.can_add = True
|
||||
|
||||
ruleset.save()
|
||||
if not assign_all:
|
||||
break
|
||||
|
||||
|
||||
|
@ -160,7 +160,7 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
class ContactTest(InvenTreeAPITestCase):
|
||||
"""Tests for the Contact models."""
|
||||
|
||||
roles = []
|
||||
roles = ['purchase_order.view']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -266,7 +266,7 @@ class ContactTest(InvenTreeAPITestCase):
|
||||
class AddressTest(InvenTreeAPITestCase):
|
||||
"""Test cases for Address API endpoints."""
|
||||
|
||||
roles = []
|
||||
roles = ['purchase_order.view']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@ -2010,6 +2010,7 @@ class ReturnOrderTests(InvenTreeAPITestCase):
|
||||
'supplier_part',
|
||||
'stock',
|
||||
]
|
||||
roles = ['return_order.view']
|
||||
|
||||
def test_options(self):
|
||||
"""Test the OPTIONS endpoint."""
|
||||
|
@ -512,7 +512,7 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
Ensure that the required field details are provided!
|
||||
"""
|
||||
|
||||
roles = ['part.add']
|
||||
roles = ['part.add', 'part_category.view']
|
||||
|
||||
def test_part(self):
|
||||
"""Test the Part API OPTIONS."""
|
||||
@ -2149,7 +2149,7 @@ class BomItemTest(InvenTreeAPITestCase):
|
||||
|
||||
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):
|
||||
"""Set up the test case."""
|
||||
@ -2642,6 +2642,7 @@ class PartStocktakeTest(InvenTreeAPITestCase):
|
||||
|
||||
superuser = False
|
||||
is_staff = False
|
||||
roles = ['stocktake.view']
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock']
|
||||
|
||||
|
@ -162,7 +162,24 @@ class UserList(ListCreateAPI):
|
||||
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."""
|
||||
|
||||
queryset = Group.objects.all()
|
||||
@ -170,7 +187,7 @@ class GroupDetail(RetrieveUpdateDestroyAPI):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class GroupList(ListCreateAPI):
|
||||
class GroupList(GroupMixin, ListCreateAPI):
|
||||
"""List endpoint for all auth groups."""
|
||||
|
||||
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(
|
||||
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(
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""DRF API serializers for the 'users' app."""
|
||||
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db.models import Q
|
||||
|
||||
from rest_framework import serializers
|
||||
@ -31,7 +32,25 @@ class GroupSerializer(InvenTreeModelSerializer):
|
||||
"""Metaclass defines serializer fields."""
|
||||
|
||||
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):
|
||||
@ -83,6 +102,11 @@ class RoleSerializer(InvenTreeModelSerializer):
|
||||
Q(user=user) | Q(group__user=user)
|
||||
).distinct()
|
||||
|
||||
return generate_permission_dict(permissions)
|
||||
|
||||
|
||||
def generate_permission_dict(permissions):
|
||||
"""Generate a dictionary of permissions for a given set of permissions."""
|
||||
perms = {}
|
||||
|
||||
for permission in permissions:
|
||||
|
@ -123,8 +123,8 @@ class RuleSetModelTest(TestCase):
|
||||
for model in models:
|
||||
permission_set.add(model)
|
||||
|
||||
# Every ruleset by default sets one permission, the "view" permission set
|
||||
self.assertEqual(group.permissions.count(), len(permission_set))
|
||||
# By default no permissions should be assigned
|
||||
self.assertEqual(group.permissions.count(), 0)
|
||||
|
||||
# Add some more rules
|
||||
for rule in rulesets:
|
||||
|
@ -37,7 +37,7 @@ import {
|
||||
RenderStockLocation,
|
||||
RenderStockLocationType
|
||||
} from './Stock';
|
||||
import { RenderOwner, RenderUser } from './User';
|
||||
import { RenderGroup, RenderOwner, RenderUser } from './User';
|
||||
|
||||
type EnumDictionary<T extends string | symbol | number, U> = {
|
||||
[K in T]: U;
|
||||
@ -81,6 +81,7 @@ const RendererLookup: EnumDictionary<
|
||||
[ModelType.stockhistory]: RenderStockItem,
|
||||
[ModelType.supplierpart]: RenderSupplierPart,
|
||||
[ModelType.user]: RenderUser,
|
||||
[ModelType.group]: RenderGroup,
|
||||
[ModelType.importsession]: RenderImportSession,
|
||||
[ModelType.reporttemplate]: RenderReportTemplate,
|
||||
[ModelType.labeltemplate]: RenderLabelTemplate,
|
||||
|
@ -201,6 +201,14 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_detail: '/user/:pk/',
|
||||
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: {
|
||||
label: t`Import Session`,
|
||||
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',
|
||||
owner = 'owner',
|
||||
user = 'user',
|
||||
group = 'group',
|
||||
reporttemplate = 'reporttemplate',
|
||||
labeltemplate = 'labeltemplate',
|
||||
pluginconfig = 'pluginconfig'
|
||||
|
@ -1,13 +1,23 @@
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
import { EditApiForm } from '../../components/forms/ApiForm';
|
||||
import { PlaceholderPill } from '../../components/items/Placeholder';
|
||||
import { DetailDrawer } from '../../components/nav/DetailDrawer';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal
|
||||
@ -32,14 +42,42 @@ export function GroupDrawer({
|
||||
refreshTable: () => void;
|
||||
}) {
|
||||
const {
|
||||
instance,
|
||||
refreshInstance,
|
||||
instanceQuery: { isFetching, error }
|
||||
} = useInstance({
|
||||
endpoint: ApiEndpoints.group_list,
|
||||
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) {
|
||||
return <LoadingOverlay visible={true} />;
|
||||
}
|
||||
@ -72,13 +110,13 @@ export function GroupDrawer({
|
||||
}}
|
||||
id={`group-detail-drawer-${id}`}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Title order={5}>
|
||||
<Trans>Permission set</Trans>
|
||||
</Title>
|
||||
<Group>
|
||||
<PlaceholderPill />
|
||||
<AdminButton model={ModelType.group} pk={instance.pk} />
|
||||
</Group>
|
||||
<Group>{permissionsAccordion}</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user