2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +00:00

Importer permissions (#8419)

* Improved permission checking for data importing

- Permission checks on the imported model type

* Improved validation for DateField

* Fix for unit test
This commit is contained in:
Oliver 2024-11-03 17:43:09 +11:00 committed by GitHub
parent 504655c92d
commit abad36786f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 69 additions and 15 deletions

View File

@ -5,6 +5,7 @@ from django.urls import include, path
from drf_spectacular.utils import extend_schema
from rest_framework import permissions
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.views import APIView
@ -20,6 +21,39 @@ from InvenTree.mixins import (
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
)
from users.models import check_user_permission
class DataImporterPermission(permissions.BasePermission):
"""Mixin class for determining if the user has correct permissions."""
def has_permission(self, request, view):
"""Class level permission checks are handled via permissions.IsAuthenticated."""
return True
def has_object_permission(self, request, view, obj):
"""Check if the user has permission to access the imported object."""
# For safe methods (GET, HEAD, OPTIONS), allow access
if request.method in permissions.SAFE_METHODS:
return True
if isinstance(obj, importer.models.DataImportSession):
session = obj
else:
session = getattr(obj, 'session', None)
if session:
if model_class := session.model_class:
return check_user_permission(request.user, model_class, 'change')
return True
class DataImporterPermissionMixin:
"""Mixin class for checking permissions on DataImporter objects."""
# Default permissions: User must be authenticated
permission_classes = [permissions.IsAuthenticated, DataImporterPermission]
class DataImporterModelList(APIView):
@ -44,11 +78,9 @@ class DataImporterModelList(APIView):
return Response(models)
class DataImportSessionList(BulkDeleteMixin, ListCreateAPI):
class DataImportSessionList(DataImporterPermission, BulkDeleteMixin, ListCreateAPI):
"""API endpoint for accessing a list of DataImportSession objects."""
permission_classes = [permissions.IsAuthenticated]
queryset = importer.models.DataImportSession.objects.all()
serializer_class = importer.serializers.DataImportSessionSerializer
@ -59,7 +91,7 @@ class DataImportSessionList(BulkDeleteMixin, ListCreateAPI):
ordering_fields = ['timestamp', 'status', 'model_type']
class DataImportSessionDetail(RetrieveUpdateDestroyAPI):
class DataImportSessionDetail(DataImporterPermission, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single DataImportSession object."""
queryset = importer.models.DataImportSession.objects.all()
@ -78,13 +110,18 @@ class DataImportSessionAcceptFields(APIView):
"""Accept the field mapping for a DataImportSession."""
session = get_object_or_404(importer.models.DataImportSession, pk=pk)
# Check that the user has permission to accept the field mapping
if model_class := session.model_class:
if not check_user_permission(request.user, model_class, 'change'):
raise PermissionDenied()
# Attempt to accept the mapping (may raise an exception if the mapping is invalid)
session.accept_mapping()
return Response(importer.serializers.DataImportSessionSerializer(session).data)
class DataImportSessionAcceptRows(CreateAPI):
class DataImportSessionAcceptRows(DataImporterPermission, CreateAPI):
"""API endpoint to accept the rows for a DataImportSession."""
queryset = importer.models.DataImportSession.objects.all()
@ -105,7 +142,7 @@ class DataImportSessionAcceptRows(CreateAPI):
return ctx
class DataImportColumnMappingList(ListAPI):
class DataImportColumnMappingList(DataImporterPermissionMixin, ListAPI):
"""API endpoint for accessing a list of DataImportColumnMap objects."""
queryset = importer.models.DataImportColumnMap.objects.all()
@ -116,14 +153,14 @@ class DataImportColumnMappingList(ListAPI):
filterset_fields = ['session']
class DataImportColumnMappingDetail(RetrieveUpdateAPI):
class DataImportColumnMappingDetail(DataImporterPermissionMixin, RetrieveUpdateAPI):
"""Detail endpoint for a single DataImportColumnMap object."""
queryset = importer.models.DataImportColumnMap.objects.all()
serializer_class = importer.serializers.DataImportColumnMapSerializer
class DataImportRowList(BulkDeleteMixin, ListAPI):
class DataImportRowList(DataImporterPermission, BulkDeleteMixin, ListAPI):
"""API endpoint for accessing a list of DataImportRow objects."""
queryset = importer.models.DataImportRow.objects.all()
@ -138,7 +175,7 @@ class DataImportRowList(BulkDeleteMixin, ListAPI):
ordering = 'row_index'
class DataImportRowDetail(RetrieveUpdateDestroyAPI):
class DataImportRowDetail(DataImporterPermission, RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single DataImportRow object."""
queryset = importer.models.DataImportRow.objects.all()

View File

@ -123,6 +123,14 @@ class DataImportSession(models.Model):
return mapping
@property
def model_class(self):
"""Return the model class for this importer."""
serializer = self.serializer_class
if serializer:
return serializer.Meta.model
@property
def serializer_class(self):
"""Return the serializer class for this importer."""

View File

@ -1487,10 +1487,9 @@ class PurchaseOrderLineItem(OrderLineItem):
def __str__(self):
"""Render a string representation of a PurchaseOrderLineItem instance."""
return '{n} x {part} from {supplier} (for {po})'.format(
return '{n} x {part} - {po}'.format(
n=decimal2string(self.quantity),
part=self.part.SKU if self.part else 'unknown part',
supplier=self.order.supplier.name if self.order.supplier else _('deleted'),
po=self.order,
)

View File

@ -50,7 +50,7 @@ class OrderTest(TestCase):
self.assertEqual(order.reference, f'PO-{pk:04d}')
line = PurchaseOrderLineItem.objects.get(pk=1)
self.assertEqual(str(line), '100 x ACME0001 from ACME (for PO-0001 - ACME)')
self.assertEqual(str(line), '100 x ACME0001 - PO-0001 - ACME')
def test_rebuild_reference(self):
"""Test that the reference_int field is correctly updated when the model is saved."""

View File

@ -687,6 +687,9 @@ def check_user_permission(user: User, model, permission):
model: The model class to check (e.g. Part)
permission: The permission to check (e.g. 'view' / 'delete')
"""
if user.is_superuser:
return True
permission_name = f'{model._meta.app_label}.{permission}_{model._meta.model_name}'
return user.has_perm(permission_name)

View File

@ -39,11 +39,18 @@ export default function DateField({
[field.onChange, definition]
);
const dateValue = useMemo(() => {
const dateValue: Date | null = useMemo(() => {
let dv: Date | null = null;
if (field.value) {
return new Date(field.value);
dv = new Date(field.value) ?? null;
}
// Ensure that the date is valid
if (dv instanceof Date && !isNaN(dv.getTime())) {
return dv;
} else {
return undefined;
return null;
}
}, [field.value]);