From 6c76b309bfa8e56a63bd5596eaae43bc66ae2d00 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 24 Nov 2025 13:14:36 +0000 Subject: [PATCH] Add custom serializer field for ContentType with choices --- .../InvenTree/InvenTree/helpers_model.py | 19 ++++++ .../InvenTree/InvenTree/serializers.py | 65 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/backend/InvenTree/InvenTree/helpers_model.py b/src/backend/InvenTree/InvenTree/helpers_model.py index 16338266d8..9e0e207e21 100644 --- a/src/backend/InvenTree/InvenTree/helpers_model.py +++ b/src/backend/InvenTree/InvenTree/helpers_model.py @@ -281,6 +281,25 @@ def getModelsWithMixin(mixin_class) -> list: return [x for x in db_models if x is not None and issubclass(x, mixin_class)] +def getModelChoicesWithMixin( + mixin_class, allow_null: bool = False +) -> list[tuple[str, str]]: + """Return a list of model choices (app_label.model_name, verbose_name) for models that inherit from the given mixin class.""" + choices = [] + + if allow_null: + choices.append((None, _('None'))) + + models = getModelsWithMixin(mixin_class) + + for model in models: + label = f'{model._meta.app_label}.{model._meta.model_name}' + name = model._meta.verbose_name + choices.append((label, name)) + + return choices + + def notify_responsible( instance, sender, diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index dd1e096de7..82d336127c 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -28,6 +28,7 @@ import InvenTree.ready from common.currency import currency_code_default, currency_code_mappings from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField from InvenTree.helpers import str2bool +from InvenTree.helpers_model import getModelChoicesWithMixin from .setting.storages import StorageBackends @@ -716,3 +717,67 @@ class RemoteImageMixin(metaclass=serializers.SerializerMetaclass): raise ValidationError(_('Failed to download image from remote URL')) return url + + +class ContentTypeField(serializers.Field): + """Serializer field which represents a ContentType as 'app_label.model_name'. + + This field converts a ContentType instance to a string representation in the format 'app_label.model_name' during serialization, and vice versa during deserialization. + + Additionally, a "mixin_class" can be supplied to the field, which will restrict the valid content types to only those models which inherit from the specified mixin. + """ + + mixin_class = None + + def __init__(self, *args, mixin_class=None, **kwargs): + """Initialize the ContentTypeField. + + Args: + mixin_class: Optional mixin class to restrict valid content types. + """ + self.mixin_class = mixin_class + super().__init__(*args, **kwargs) + + # Override the 'choices' field, to limit to the appropriate models + if self.mixin_class is not None: + self.choices = getModelChoicesWithMixin( + self.mixin_class, allow_null=kwargs.get('allow_null', False) + ) + + def to_representation(self, value): + """Convert ContentType instance to string representation.""" + return f'{value.app_label}.{value.model}' + + def to_internal_value(self, data): + """Convert string representation back to ContentType instance.""" + from django.contrib.contenttypes.models import ContentType + + content_type = None + + try: + app_label, model = data.split('.') + content_types = ContentType.objects.filter(app_label=app_label, model=model) + + if content_types.exists() and content_types.count() == 1: + # Try exact match first + content_type = content_types.first() + else: + # Try lookup just on model name + content_types = ContentType.objects.filter(model=model) + if content_types.exists() and content_types.count() == 1: + content_type = content_types.first() + + except Exception: + raise ValidationError(_('Invalid content type format')) + + if content_type is None: + raise ValidationError(_('Content type not found')) + + if self.mixin_class is not None: + model_class = content_type.model_class() + if not issubclass(model_class, self.mixin_class): + raise ValidationError( + _('Content type does not match required mixin class') + ) + + return content_type