From 5f067e2c50aedf7a47244012bd35ac0f49a8879a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 10 Nov 2025 11:57:33 +0000 Subject: [PATCH] API endpoints for Parameter instances --- src/backend/InvenTree/common/api.py | 76 +++++++++++++++-- src/backend/InvenTree/common/models.py | 13 +++ src/backend/InvenTree/common/serializers.py | 93 ++++++++++++++++++++- src/backend/InvenTree/common/validators.py | 12 +++ 4 files changed, 185 insertions(+), 9 deletions(-) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 2f1f1bb6d7..612d2dc013 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -44,6 +44,7 @@ from InvenTree.mixins import ( CreateAPI, ListAPI, ListCreateAPI, + OutputOptionsMixin, RetrieveAPI, RetrieveDestroyAPI, RetrieveUpdateAPI, @@ -708,13 +709,17 @@ class AttachmentFilter(FilterSet): return queryset.filter(Q(attachment=None) | Q(attachment='')).distinct() -class AttachmentList(BulkDeleteMixin, ListCreateAPI): - """List API endpoint for Attachment objects.""" +class AttachmentMixin: + """Mixin class for Attachment views.""" queryset = common.models.Attachment.objects.all() serializer_class = common.serializers.AttachmentSerializer permission_classes = [IsAuthenticatedOrReadScope] + +class AttachmentList(AttachmentMixin, BulkDeleteMixin, ListCreateAPI): + """List API endpoint for Attachment objects.""" + filter_backends = SEARCH_ORDER_FILTER filterset_class = AttachmentFilter @@ -746,13 +751,9 @@ class AttachmentList(BulkDeleteMixin, ListCreateAPI): ) -class AttachmentDetail(RetrieveUpdateDestroyAPI): +class AttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): """Detail API endpoint for Attachment objects.""" - queryset = common.models.Attachment.objects.all() - serializer_class = common.serializers.AttachmentSerializer - permission_classes = [IsAuthenticatedOrReadScope] - def destroy(self, request, *args, **kwargs): """Check user permissions before deleting an attachment.""" attachment = self.get_object() @@ -827,6 +828,58 @@ class ParameterTemplateDetail(ParameterTemplateMixin, RetrieveUpdateDestroyAPI): """Detail view for a ParameterTemplate object.""" +class ParameterFilter(FilterSet): + """Custom filters for the ParameterList API endpoint.""" + + class Meta: + """Metaclass options for the filterset.""" + + model = common.models.Parameter + fields = ['model_type', 'model_id', 'template', 'updated_by'] + + +class ParameterMixin: + """Mixin class for Parameter views.""" + + queryset = common.models.Parameter.objects.all() + serializer_class = common.serializers.ParameterSerializer + permission_classes = [IsAuthenticatedOrReadScope] + + +class ParameterList( + OutputOptionsMixin, + ParameterMixin, + BulkDeleteMixin, + DataExportViewMixin, + ListCreateAPI, +): + """List API endpoint for Parameter objects.""" + + filterset_class = ParameterFilter + filter_backends = SEARCH_ORDER_FILTER + + ordering_fields = ['name', 'data', 'units', 'template', 'updated', 'updated_by'] + + ordering_field_aliases = { + 'name': 'template__name', + 'units': 'template__units', + 'data': ['data_numeric', 'data'], + } + + search_fields = [ + 'data', + 'template__name', + 'template__description', + 'template__units', + ] + + unique_create_fields = ['model_type', 'model_id', 'template'] + + +class ParameterDetail(ParameterMixin, RetrieveUpdateDestroyAPI): + """Detail API endpoint for Parameter objects.""" + + @method_decorator(cache_control(public=True, max_age=86400), name='dispatch') class IconList(ListAPI): """List view for available icon packages.""" @@ -1082,7 +1135,14 @@ common_api_urls = [ name='api-parameter-template-list', ), ]), - ) + ), + path( + '/', + include([ + path('', ParameterDetail.as_view(), name='api-parameter-detail') + ]), + ), + path('', ParameterList.as_view(), name='api-parameter-list'), ]), ), path( diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 8a702a0e40..01f7bc149a 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -2621,6 +2621,19 @@ class Parameter( if math.isnan(self.data_numeric) or math.isinf(self.data_numeric): self.data_numeric = None + def check_permission(self, permission, user): + """Check if the user has the required permission for this parameter.""" + from InvenTree.models import InvenTreeParameterMixin + + model_class = common.validators.parameter_model_class_from_label( + self.model_type + ) + + if not issubclass(model_class, InvenTreeParameterMixin): + raise ValidationError(_('Invalid model type specified for parameter')) + + return model_class.check_related_permission(permission, user) + model_type = models.CharField( max_length=100, default='', diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index b28d20b37d..02a094ca28 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -22,9 +22,11 @@ from InvenTree.helpers import get_objectreference from InvenTree.helpers_model import construct_absolute_url from InvenTree.mixins import DataImportExportSerializerMixin from InvenTree.serializers import ( + FilterableSerializerMixin, InvenTreeAttachmentSerializerField, InvenTreeImageSerializerField, InvenTreeModelSerializer, + enable_filter, ) from plugin import registry as plugin_registry from users.serializers import OwnerSerializer, UserSerializer @@ -692,7 +694,7 @@ class AttachmentSerializer(InvenTreeModelSerializer): # Check that the user has the required permissions to attach files to the target model if not target_model_class.check_related_permission('change', user): - raise PermissionDenied(_(permission_error_msg)) + raise PermissionDenied(permission_error_msg) return super().save(**kwargs) @@ -737,6 +739,95 @@ class ParameterTemplateSerializer( ) +class ParameterSerializer( + FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer +): + """Serializer for the Parameter model.""" + + class Meta: + """Meta options for ParameterSerializer.""" + + model = common_models.Parameter + fields = [ + 'pk', + 'template', + 'model_type', + 'model_id', + 'data', + 'data_numeric', + 'note', + 'updated', + 'updated_by', + 'template_detail', + 'updated_by_detail', + ] + + read_only_fields = ['updated', 'updated_by'] + + def __init__(self, *args, **kwargs): + """Override the model_type field to provide dynamic choices.""" + super().__init__(*args, **kwargs) + + if len(self.fields['model_type'].choices) == 0: + self.fields[ + 'model_type' + ].choices = common.validators.parameter_model_options() + + def save(self, **kwargs): + """Save the Parameter instance.""" + from InvenTree.models import InvenTreeParameterMixin + from users.permissions import check_user_permission + + model_type = self.validated_data.get('model_type', None) + + if model_type is None and self.instance: + model_type = self.instance.model_type + + # Ensure that the user has permission to modify parameters for the specified model + user = self.context.get('request').user + + target_model_class = common.validators.parameter_model_class_from_label( + model_type + ) + + if not issubclass(target_model_class, InvenTreeParameterMixin): + raise PermissionDenied(_('Invalid model type specified for parameter')) + + permission_error_msg = _( + 'User does not have permission to create or edit parameters for this model' + ) + + if not check_user_permission(user, target_model_class, 'change'): + raise PermissionDenied(permission_error_msg) + + if not target_model_class.check_related_permission('change', user): + raise PermissionDenied(permission_error_msg) + + instance = super().save(**kwargs) + instance.updated_by = user + instance.save() + + return instance + + # Note: The choices are overridden at run-time on class initialization + model_type = serializers.ChoiceField( + label=_('Model Type'), + default='', + choices=common.validators.parameter_model_options(), + required=False, + allow_blank=True, + allow_null=True, + ) + + updated_by_detail = enable_filter( + UserSerializer(source='updated_by', read_only=True, many=False), True + ) + + template_detail = enable_filter( + ParameterTemplateSerializer(source='template', read_only=True, many=False), True + ) + + class IconSerializer(serializers.Serializer): """Serializer for an icon.""" diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index b49f36b1b1..5908054cb7 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -37,6 +37,18 @@ def parameter_template_model_options(): return [('', _('Any model type')), *parameter_model_options()] +def parameter_model_class_from_label(label: str): + """Return the model class for the given label.""" + if not label: + raise ValidationError(_('No parameter model type provided')) + + for model in parameter_model_types(): + if model.__name__.lower() == label.lower(): + return model + + raise ValidationError(_('Invalid parameter model type') + f": '{label}'") + + def validate_parameter_model_type(value: str): """Ensure that the provided parameter model is valid.""" model_names = [el[0] for el in parameter_model_options()]