diff --git a/src/backend/InvenTree/importer/api.py b/src/backend/InvenTree/importer/api.py index 6eb4815784..b914e068cb 100644 --- a/src/backend/InvenTree/importer/api.py +++ b/src/backend/InvenTree/importer/api.py @@ -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() diff --git a/src/backend/InvenTree/importer/models.py b/src/backend/InvenTree/importer/models.py index 4db33a08c3..d0c5e67cbc 100644 --- a/src/backend/InvenTree/importer/models.py +++ b/src/backend/InvenTree/importer/models.py @@ -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.""" diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index fc3567d71b..fbb16d3deb 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -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, ) diff --git a/src/backend/InvenTree/order/tests.py b/src/backend/InvenTree/order/tests.py index c797149ae5..1963b586c9 100644 --- a/src/backend/InvenTree/order/tests.py +++ b/src/backend/InvenTree/order/tests.py @@ -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.""" diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index d4c54b5853..c755702a72 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -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) diff --git a/src/frontend/src/components/forms/fields/DateField.tsx b/src/frontend/src/components/forms/fields/DateField.tsx index 3bf10b1e04..6a723a2b84 100644 --- a/src/frontend/src/components/forms/fields/DateField.tsx +++ b/src/frontend/src/components/forms/fields/DateField.tsx @@ -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]);