Skip to content

Commit

Permalink
[PUI] User settings panel updates (#7944)
Browse files Browse the repository at this point in the history
* Simplify user theme settings

* Cleanup

* Fix permission on user list endpoint

* Update AccountDetailPanel to use modal form

* Update components

* UI updates

* Implement default colors

* Display more user details (read only)

* Add specific "MeUserSerializer"

- Prevent certain attributes from being adjusted

* Add <YesNoUndefinedButton>

* Allow role checks to be bypassed for a given view

- Override the 'get_permission_model' attribute with None

* Enable 'GET' metadata

- Required for extracting field information even if we only have 'read' permissions
- e.g. getting table columns for users without write perms
- use 'GET' action when reading table cols

* Add info on new user account

* Fix boolean expression wrapper

* Ruff fixes

* Adjust icon

* Update unit test

* Bummp API version

* Table layout fix
  • Loading branch information
SchrodingersGat authored Sep 14, 2024
1 parent a5ab4a3 commit 7fbc1fb
Show file tree
Hide file tree
Showing 13 changed files with 311 additions and 155 deletions.
1 change: 1 addition & 0 deletions docs/docs/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The demo instance has a number of user accounts which you can use to explore the

| Username | Password | Staff Access | Enabled | Description |
| -------- | -------- | ------------ | ------- | ----------- |
| noaccess | youshallnotpass | No | Yes | Can login, but has no permissions |
| allaccess | nolimits | No | Yes | View / create / edit all pages and items |
| reader | readonly | No | Yes | Can view all pages but cannot create, edit or delete database records |
| engineer | partsonly | No | Yes | Can manage parts, view stock, but no access to purchase orders or sales orders |
Expand Down
7 changes: 5 additions & 2 deletions src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 252
INVENTREE_API_VERSION = 253

"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""


INVENTREE_API_TEXT = """
v252 - 2024-09-30 : https://github.com/inventree/InvenTree/pull/8035
v253 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7944
- Adjustments for user API endpoints
v252 - 2024-09-13 : https://github.com/inventree/InvenTree/pull/8040
- Add endpoint for listing all known units
v251 - 2024-09-06 : https://github.com/inventree/InvenTree/pull/8018
Expand Down
45 changes: 40 additions & 5 deletions src/backend/InvenTree/InvenTree/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

import logging

from rest_framework import serializers
from django.core.exceptions import PermissionDenied
from django.http import Http404

from rest_framework import exceptions, serializers
from rest_framework.fields import empty
from rest_framework.metadata import SimpleMetadata
from rest_framework.request import clone_request
from rest_framework.utils import model_meta

import common.models
Expand All @@ -29,6 +33,40 @@ class InvenTreeMetadata(SimpleMetadata):
so we can perform lookup for ForeignKey related fields.
"""

def determine_actions(self, request, view):
"""Determine the 'actions' available to the user for the given view.
Note that this differs from the standard DRF implementation,
in that we also allow annotation for the 'GET' method.
This allows the client to determine what fields are available,
even if they are only for a read (GET) operation.
See SimpleMetadata.determine_actions for more information.
"""
actions = {}

for method in {'PUT', 'POST', 'GET'} & set(view.allowed_methods):
view.request = clone_request(request, method)
try:
# Test global permissions
if hasattr(view, 'check_permissions'):
view.check_permissions(view.request)
# Test object permissions
if method == 'PUT' and hasattr(view, 'get_object'):
view.get_object()
except (exceptions.APIException, PermissionDenied, Http404):
pass
else:
# If user has appropriate permissions for the view, include
# appropriate metadata about the fields that should be supplied.
serializer = view.get_serializer()
actions[method] = self.get_serializer_info(serializer)
finally:
view.request = request

return actions

def determine_metadata(self, request, view):
"""Overwrite the metadata to adapt to the request user."""
self.request = request
Expand Down Expand Up @@ -81,6 +119,7 @@ def determine_metadata(self, request, view):

# Map the request method to a permission type
rolemap = {
'GET': 'view',
'POST': 'add',
'PUT': 'change',
'PATCH': 'change',
Expand All @@ -102,10 +141,6 @@ def determine_metadata(self, request, view):
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
actions['DELETE'] = {}

# Add a 'VIEW' action if we are allowed to view
if 'GET' in view.allowed_methods and check(user, table, 'view'):
actions['GET'] = {}

metadata['actions'] = actions

except AttributeError:
Expand Down
14 changes: 14 additions & 0 deletions src/backend/InvenTree/InvenTree/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ def has_permission(self, request, view):
# Extract the model name associated with this request
model = get_model_for_view(view)

if model is None:
return True

app_label = model._meta.app_label
model_name = model._meta.model_name

Expand All @@ -99,6 +102,17 @@ def has_permission(self, request, view):
return bool(request.user and request.user.is_superuser)


class IsSuperuserOrReadOnly(permissions.IsAdminUser):
"""Allow read-only access to any user, but write access is restricted to superuser users."""

def has_permission(self, request, view):
"""Check if the user is a superuser."""
return bool(
(request.user and request.user.is_superuser)
or request.method in permissions.SAFE_METHODS
)


class IsStaffOrReadOnly(permissions.IsAdminUser):
"""Allows read-only access to any user, but write access is restricted to staff users."""

Expand Down
33 changes: 31 additions & 2 deletions src/backend/InvenTree/InvenTree/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,18 +403,21 @@ class Meta:
read_only_fields = ['username', 'email']

username = serializers.CharField(label=_('Username'), help_text=_('Username'))

first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
)

last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
)

email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
)


class ExendedUserSerializer(UserSerializer):
class ExtendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""

from users.serializers import GroupSerializer
Expand All @@ -437,9 +440,11 @@ class Meta(UserSerializer.Meta):
is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions')
)

is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser')
)

is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active')
)
Expand All @@ -464,9 +469,33 @@ def validate(self, attrs):
return super().validate(attrs)


class UserCreateSerializer(ExendedUserSerializer):
class MeUserSerializer(ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""

class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options.
Extends the ExtendedUserSerializer.Meta options,
but ensures that certain fields are read-only.
"""

read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
'is_active',
'is_staff',
'is_superuser',
]


class UserCreateSerializer(ExtendedUserSerializer):
"""Serializer for creating a new User."""

class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options for the UserCreateSerializer."""

# Prevent creation of users with superuser or staff permissions
read_only_fields = ['groups', 'is_staff', 'is_superuser']

def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser
Expand Down
25 changes: 22 additions & 3 deletions src/backend/InvenTree/users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from rest_framework.views import APIView

import InvenTree.helpers
import InvenTree.permissions
from common.models import InvenTreeSetting
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import (
Expand All @@ -33,7 +34,11 @@
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
)
from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer
from InvenTree.serializers import (
ExtendedUserSerializer,
MeUserSerializer,
UserCreateSerializer,
)
from InvenTree.settings import FRONTEND_URL_BASE
from users.models import ApiToken, Owner
from users.serializers import (
Expand Down Expand Up @@ -134,24 +139,38 @@ class UserDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single user."""

queryset = User.objects.all()
serializer_class = ExendedUserSerializer
serializer_class = ExtendedUserSerializer
permission_classes = [permissions.IsAuthenticated]


class MeUserDetail(RetrieveUpdateAPI, UserDetail):
"""Detail endpoint for current user."""

serializer_class = MeUserSerializer

rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'}

def get_object(self):
"""Always return the current user object."""
return self.request.user

def get_permission_model(self):
"""Return the model for the permission check.
Note that for this endpoint, the current user can *always* edit their own details.
"""
return None


class UserList(ListCreateAPI):
"""List endpoint for detail on all users."""

queryset = User.objects.all()
serializer_class = UserCreateSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.IsSuperuserOrReadOnly,
]
filter_backends = SEARCH_ORDER_FILTER

search_fields = ['first_name', 'last_name', 'username']
Expand Down
5 changes: 4 additions & 1 deletion src/backend/InvenTree/users/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ def test_user_options(self):
self.assignRole('admin.add')
response = self.options(reverse('api-user-list'), expected_code=200)

fields = response.data['actions']['POST']
# User is *not* a superuser, so user account API is read-only
self.assertNotIn('POST', response.data['actions'])

fields = response.data['actions']['GET']

# Check some of the field values
self.assertEqual(fields['username']['label'], 'Username')
Expand Down
10 changes: 9 additions & 1 deletion src/frontend/src/components/buttons/YesNoButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Badge } from '@mantine/core';
import { Badge, Skeleton } from '@mantine/core';

import { isTrue } from '../../functions/conversion';

Expand Down Expand Up @@ -32,3 +32,11 @@ export function PassFailButton({
export function YesNoButton({ value }: { value: any }) {
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
}

export function YesNoUndefinedButton({ value }: { value?: boolean }) {
if (value === undefined) {
return <Skeleton height={15} width={32} />;
} else {
return <YesNoButton value={value} />;
}
}
Loading

0 comments on commit 7fbc1fb

Please sign in to comment.