From 0d6c47fcd50db976d4c650ce129d801e1eb06dc4 Mon Sep 17 00:00:00 2001
From: Matthias Mair <code@mjmair.com>
Date: Tue, 8 Apr 2025 14:09:57 +0200
Subject: [PATCH] fix(backend): repair remaining schema generation errors
 (#9453)

* Remove hardcoded currency enum from schema

* Convert schema custom key enums to int to allow customized keys to validate

* Convert stock status key enums to int to allow customizations to
validate in schema

* api version bump

* fix remaining operationId errors

* fix errors

* fix another error

* fix missing model

* ensure we do not ignore warnings anymore

* Restore enumerated help text for currencies

* Remove commented block of old code

* Restore custom key enumerated values to schema documentation

* Restore status key enumeration to schema documentation

* fix more enums

* Add debug definitions for schema generation

* fix schema generation for PluginRelationSerializer

* add migrations

* fix enum names for allauth schema duplications

* bump api version

---------

Co-authored-by: Joe Rogers <1337joe@gmail.com>
---
 .github/workflows/qc_checks.yaml              |  1 -
 .vscode/launch.json                           | 22 ++++++++++++
 .../InvenTree/InvenTree/api_version.py        |  6 +++-
 src/backend/InvenTree/InvenTree/schema.py     | 15 ++++----
 src/backend/InvenTree/InvenTree/settings.py   |  9 +++++
 src/backend/InvenTree/common/api.py           |  6 +++-
 .../0038_alter_attachment_model_type.py       | 24 +++++++++++++
 src/backend/InvenTree/common/models.py        | 30 +++++++++++++---
 src/backend/InvenTree/generic/states/api.py   |  1 +
 src/backend/InvenTree/importer/api.py         | 12 ++++++-
 ...0004_alter_dataimportsession_model_type.py | 24 +++++++++++++
 src/backend/InvenTree/importer/models.py      |  8 +++++
 src/backend/InvenTree/plugin/api.py           |  9 +++++
 src/backend/InvenTree/plugin/serializers.py   |  3 ++
 ...alter_labeltemplate_model_type_and_more.py | 34 +++++++++++++++++++
 src/backend/InvenTree/report/models.py        |  8 ++++-
 src/backend/InvenTree/stock/models.py         |  1 -
 src/backend/InvenTree/users/api.py            |  2 +-
 .../migrations/0015_alter_userprofile_type.py | 29 ++++++++++++++++
 src/backend/InvenTree/users/models.py         |  2 +-
 20 files changed, 227 insertions(+), 19 deletions(-)
 create mode 100644 src/backend/InvenTree/common/migrations/0038_alter_attachment_model_type.py
 create mode 100644 src/backend/InvenTree/importer/migrations/0004_alter_dataimportsession_model_type.py
 create mode 100644 src/backend/InvenTree/report/migrations/0030_alter_labeltemplate_model_type_and_more.py
 create mode 100644 src/backend/InvenTree/users/migrations/0015_alter_userprofile_type.py

diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml
index 76ff9491ef..6acdc1ae90 100644
--- a/.github/workflows/qc_checks.yaml
+++ b/.github/workflows/qc_checks.yaml
@@ -193,7 +193,6 @@ jobs:
           diff -u src/backend/InvenTree/schema.yml api.yaml && echo "no difference in API schema " || exit 2
       - name: Check schema - including warnings
         run: invoke dev.schema
-        continue-on-error: true
       - name: Extract version for publishing
         id: version
         if: github.ref == 'refs/heads/master' && needs.paths-filter.outputs.api == 'true'
diff --git a/.vscode/launch.json b/.vscode/launch.json
index a374de9140..c8d61a22d6 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -40,6 +40,28 @@
       "django": true,
       "justMyCode": false
     },
+    {
+      "name": "InvenTree invoke schema",
+      "type": "debugpy",
+      "request": "launch",
+      "program": "${workspaceFolder}/.venv/lib/python3.9/site-packages/invoke/__main__.py",
+      "cwd": "${workspaceFolder}",
+      "args": [
+        "dev.schema","--ignore-warnings"
+      ],
+      "justMyCode": false
+    },
+    {
+      "name": "schema generation",
+      "type": "debugpy",
+      "request": "launch",
+      "program": "${workspaceFolder}/src/backend/InvenTree/manage.py",
+      "args": [
+        "schema",
+        "--file","src/frontend/schema.yml"
+      ],
+      "justMyCode": false
+    },
     {
       "name": "InvenTree Frontend - Vite",
       "type": "chrome",
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 887651f2b2..8eb282992c 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,12 +1,16 @@
 """InvenTree API version information."""
 
 # InvenTree API version
-INVENTREE_API_VERSION = 333
+INVENTREE_API_VERSION = 334
 
 """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
 
 
 INVENTREE_API_TEXT = """
+
+v334 - 2025-04-08 : https://github.com/inventree/InvenTree/pull/9453
+    - Fixes various operationId and enum collisions and help texts
+
 v333 - 2025-04-03 : https://github.com/inventree/InvenTree/pull/9452
     - Currency string is no longer restricted to a hardcoded enum
     - Customizable status keys are no longer hardcoded enum values
diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py
index d254418ce6..9bab1370ba 100644
--- a/src/backend/InvenTree/InvenTree/schema.py
+++ b/src/backend/InvenTree/InvenTree/schema.py
@@ -10,16 +10,19 @@ from drf_spectacular.utils import _SchemaType
 class ExtendedAutoSchema(AutoSchema):
     """Extend drf-spectacular to allow customizing the schema to match the actual API behavior."""
 
-    def is_bulk_delete(self) -> bool:
-        """Check the class of the current view for the BulkDeleteMixin."""
-        return 'BulkDeleteMixin' in [c.__name__ for c in type(self.view).__mro__]
+    def is_bulk_action(self, ref: str) -> bool:
+        """Check the class of the current view for the bulk mixins."""
+        return ref in [c.__name__ for c in type(self.view).__mro__]
 
     def get_operation_id(self) -> str:
         """Custom path handling overrides, falling back to default behavior."""
         result_id = super().get_operation_id()
 
-        # rename bulk deletes to deconflict with single delete operation_id
-        if self.method == 'DELETE' and self.is_bulk_delete():
+        # rename bulk actions to deconflict with single action operation_id
+        if (self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin')) or (
+            (self.method == 'PUT' or self.method == 'PATCH')
+            and self.is_bulk_action('BulkUpdateMixin')
+        ):
             action = self.method_mapping[self.method.lower()]
             result_id = result_id.replace(action, 'bulk_' + action)
 
@@ -42,7 +45,7 @@ class ExtendedAutoSchema(AutoSchema):
 
         # drf-spectacular doesn't support a body on DELETE endpoints because the semantics are not well-defined and
         # OpenAPI recommends against it. This allows us to generate a schema that follows existing behavior.
-        if self.method == 'DELETE' and self.is_bulk_delete():
+        if self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin'):
             original_method = self.method
             self.method = 'PUT'
             request_body = self._get_request_body()
diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py
index c4a8f89ec5..dbcf5a8f6a 100644
--- a/src/backend/InvenTree/InvenTree/settings.py
+++ b/src/backend/InvenTree/InvenTree/settings.py
@@ -1435,6 +1435,15 @@ SPECTACULAR_SETTINGS = {
         'drf_spectacular.hooks.postprocess_schema_enums',
         'InvenTree.schema.postprocess_required_nullable',
     ],
+    'ENUM_NAME_OVERRIDES': {
+        'UserTypeEnum': 'users.models.UserProfile.UserType',
+        'TemplateModelTypeEnum': 'report.models.ReportTemplateBase.ModelChoices',
+        'AttachmentModelTypeEnum': 'common.models.Attachment.ModelChoices',
+        'DataImportSessionModelTypeEnum': 'importer.models.DataImportSession.ModelChoices',
+        # Allauth
+        'UnauthorizedStatus': [[401, 401]],
+        'IsTrueEnum': [[True, True]],
+    },
 }
 
 if SITE_URL and not TESTING:
diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py
index d6df7372b4..2ff69b8584 100644
--- a/src/backend/InvenTree/common/api.py
+++ b/src/backend/InvenTree/common/api.py
@@ -699,7 +699,6 @@ class ContentTypeDetail(RetrieveAPI):
     permission_classes = [permissions.IsAuthenticated]
 
 
-@extend_schema(operation_id='contenttype_retrieve_model')
 class ContentTypeModelDetail(ContentTypeDetail):
     """Detail view for a ContentType model."""
 
@@ -714,6 +713,11 @@ class ContentTypeModelDetail(ContentTypeDetail):
                 raise NotFound()
         raise NotFound()
 
+    @extend_schema(operation_id='contenttype_retrieve_model')
+    def get(self, request, *args, **kwargs):
+        """Detail view for a ContentType model."""
+        return super().get(request, *args, **kwargs)
+
 
 class AttachmentFilter(rest_filters.FilterSet):
     """Filterset for the AttachmentList API endpoint."""
diff --git a/src/backend/InvenTree/common/migrations/0038_alter_attachment_model_type.py b/src/backend/InvenTree/common/migrations/0038_alter_attachment_model_type.py
new file mode 100644
index 0000000000..f963689b83
--- /dev/null
+++ b/src/backend/InvenTree/common/migrations/0038_alter_attachment_model_type.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.20 on 2025-04-07 20:53
+
+import common.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("common", "0037_dataoutput"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="attachment",
+            name="model_type",
+            field=models.CharField(
+                help_text="Target model type for image",
+                max_length=100,
+                validators=[common.validators.validate_attachment_model_type],
+                verbose_name="Model type",
+            ),
+        ),
+    ]
diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py
index 813afcb2fb..d498a21143 100644
--- a/src/backend/InvenTree/common/models.py
+++ b/src/backend/InvenTree/common/models.py
@@ -40,15 +40,11 @@ from djmoney.contrib.exchange.models import convert_money
 from rest_framework.exceptions import PermissionDenied
 from taggit.managers import TaggableManager
 
-import common.currency
 import common.validators
-import InvenTree.exceptions
 import InvenTree.fields
 import InvenTree.helpers
 import InvenTree.models
 import InvenTree.ready
-import InvenTree.tasks
-import InvenTree.validators
 import users.models
 from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType
 from generic.states import ColorEnum
@@ -59,6 +55,24 @@ from InvenTree.sanitizer import sanitize_svg
 logger = structlog.get_logger('inventree')
 
 
+class RenderMeta(models.enums.ChoicesMeta):
+    """Metaclass for rendering choices."""
+
+    choice_fnc = None
+
+    @property
+    def choices(self):
+        """Return a list of choices for the enum class."""
+        fnc = getattr(self, 'choice_fnc', None)
+        if fnc:
+            return fnc()
+        return []
+
+
+class RenderChoices(models.TextChoices, metaclass=RenderMeta):
+    """Class for creating enumerated string choices for schema rendering."""
+
+
 class MetaMixin(models.Model):
     """A base class for InvenTree models to include shared meta fields.
 
@@ -1811,6 +1825,11 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
 
         verbose_name = _('Attachment')
 
+    class ModelChoices(RenderChoices):
+        """Model choices for attachments."""
+
+        choice_fnc = common.validators.attachment_model_options
+
     def save(self, *args, **kwargs):
         """Custom 'save' method for the Attachment model.
 
@@ -1859,7 +1878,8 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
     model_type = models.CharField(
         max_length=100,
         validators=[common.validators.validate_attachment_model_type],
-        help_text=_('Target model type for this image'),
+        verbose_name=_('Model type'),
+        help_text=_('Target model type for image'),
     )
 
     model_id = models.PositiveIntegerField()
diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py
index 7eaec223a1..b1d0ebcc50 100644
--- a/src/backend/InvenTree/generic/states/api.py
+++ b/src/backend/InvenTree/generic/states/api.py
@@ -99,6 +99,7 @@ class AllStatusViews(StatusView):
     permission_classes = [permissions.IsAuthenticated]
     serializer_class = EmptySerializer
 
+    @extend_schema(operation_id='generic_status_retrieve_all')
     def get(self, request, *args, **kwargs):
         """Perform a GET request to learn information about status codes."""
         from InvenTree.helpers import inheritors
diff --git a/src/backend/InvenTree/importer/api.py b/src/backend/InvenTree/importer/api.py
index b914e068cb..ac2b1541b4 100644
--- a/src/backend/InvenTree/importer/api.py
+++ b/src/backend/InvenTree/importer/api.py
@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
 from django.urls import include, path
 
 from drf_spectacular.utils import extend_schema
-from rest_framework import permissions
+from rest_framework import permissions, serializers
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
 from rest_framework.views import APIView
@@ -56,10 +56,19 @@ class DataImporterPermissionMixin:
     permission_classes = [permissions.IsAuthenticated, DataImporterPermission]
 
 
+class DataImporterModelSerializer(serializers.Serializer):
+    """Model references to map info that might get imported."""
+
+    serializer = serializers.CharField(read_only=True)
+    model_type = serializers.CharField(read_only=True)
+    api_url = serializers.URLField(read_only=True)
+
+
 class DataImporterModelList(APIView):
     """API endpoint for displaying a list of models available for import."""
 
     permission_classes = [permissions.IsAuthenticated]
+    serializer_class = DataImporterModelSerializer(many=True)
 
     def get(self, request):
         """Return a list of models available for import."""
@@ -102,6 +111,7 @@ class DataImportSessionAcceptFields(APIView):
     """API endpoint to accept the field mapping for a DataImportSession."""
 
     permission_classes = [permissions.IsAuthenticated]
+    serializer_class = None
 
     @extend_schema(
         responses={200: importer.serializers.DataImportSessionSerializer(many=False)}
diff --git a/src/backend/InvenTree/importer/migrations/0004_alter_dataimportsession_model_type.py b/src/backend/InvenTree/importer/migrations/0004_alter_dataimportsession_model_type.py
new file mode 100644
index 0000000000..76c566a2e1
--- /dev/null
+++ b/src/backend/InvenTree/importer/migrations/0004_alter_dataimportsession_model_type.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.20 on 2025-04-07 20:53
+
+from django.db import migrations, models
+import importer.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("importer", "0003_dataimportsession_field_filters"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="dataimportsession",
+            name="model_type",
+            field=models.CharField(
+                help_text="Target model type for this import session",
+                max_length=100,
+                validators=[importer.validators.validate_importer_model_type],
+                verbose_name="Model Type",
+            ),
+        ),
+    ]
diff --git a/src/backend/InvenTree/importer/models.py b/src/backend/InvenTree/importer/models.py
index 5191242cf3..c634f741cd 100644
--- a/src/backend/InvenTree/importer/models.py
+++ b/src/backend/InvenTree/importer/models.py
@@ -18,6 +18,7 @@ import importer.registry
 import importer.tasks
 import importer.validators
 import InvenTree.helpers
+from common.models import RenderChoices
 from importer.status_codes import DataImportStatusCode
 
 logger = structlog.get_logger('inventree')
@@ -38,6 +39,11 @@ class DataImportSession(models.Model):
         field_filters: JSONField for field filter values - optional field API filters
     """
 
+    class ModelChoices(RenderChoices):
+        """Model choices for data import sessions."""
+
+        choice_fnc = importer.registry.supported_models
+
     @staticmethod
     def get_api_url():
         """Return the API URL associated with the DataImportSession model."""
@@ -77,6 +83,8 @@ class DataImportSession(models.Model):
         blank=False,
         max_length=100,
         validators=[importer.validators.validate_importer_model_type],
+        verbose_name=_('Model Type'),
+        help_text=_('Target model type for this import session'),
     )
 
     status = models.PositiveIntegerField(
diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py
index f0f8a93808..896ef7cb1a 100644
--- a/src/backend/InvenTree/plugin/api.py
+++ b/src/backend/InvenTree/plugin/api.py
@@ -292,6 +292,15 @@ class PluginSettingList(ListAPI):
 
     filterset_fields = ['plugin__active', 'plugin__key']
 
+    @extend_schema(operation_id='plugins_settings_list_all')
+    def get(self, request, *args, **kwargs):
+        """List endpoint for all plugin related settings.
+
+        - read only
+        - only accessible by staff users
+        """
+        return super().get(request, *args, **kwargs)
+
 
 def check_plugin(
     plugin_slug: Optional[str], plugin_pk: Optional[int]
diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py
index 6ec679c78e..86ae3edbca 100644
--- a/src/backend/InvenTree/plugin/serializers.py
+++ b/src/backend/InvenTree/plugin/serializers.py
@@ -3,6 +3,8 @@
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from common.serializers import GenericReferencedSettingSerializer
@@ -307,6 +309,7 @@ class PluginRegistryStatusSerializer(serializers.Serializer):
     registry_errors = serializers.ListField(child=PluginRegistryErrorSerializer())
 
 
+@extend_schema_field(OpenApiTypes.STR)
 class PluginRelationSerializer(serializers.PrimaryKeyRelatedField):
     """Serializer for a plugin field. Uses the 'slug' of the plugin as the lookup."""
 
diff --git a/src/backend/InvenTree/report/migrations/0030_alter_labeltemplate_model_type_and_more.py b/src/backend/InvenTree/report/migrations/0030_alter_labeltemplate_model_type_and_more.py
new file mode 100644
index 0000000000..e7c41f9b2c
--- /dev/null
+++ b/src/backend/InvenTree/report/migrations/0030_alter_labeltemplate_model_type_and_more.py
@@ -0,0 +1,34 @@
+# Generated by Django 4.2.20 on 2025-04-07 20:53
+
+from django.db import migrations, models
+import report.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("report", "0029_remove_reportoutput_template_and_more"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="labeltemplate",
+            name="model_type",
+            field=models.CharField(
+                help_text="Target model type for template",
+                max_length=100,
+                validators=[report.validators.validate_report_model_type],
+                verbose_name="Model Type",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="reporttemplate",
+            name="model_type",
+            field=models.CharField(
+                help_text="Target model type for template",
+                max_length=100,
+                validators=[report.validators.validate_report_model_type],
+                verbose_name="Model Type",
+            ),
+        ),
+    ]
diff --git a/src/backend/InvenTree/report/models.py b/src/backend/InvenTree/report/models.py
index 4383f5b833..8cc046c91d 100644
--- a/src/backend/InvenTree/report/models.py
+++ b/src/backend/InvenTree/report/models.py
@@ -27,7 +27,7 @@ import InvenTree.helpers
 import InvenTree.models
 import report.helpers
 import report.validators
-from common.models import DataOutput
+from common.models import DataOutput, RenderChoices
 from common.settings import get_global_setting
 from InvenTree.helpers_model import get_base_url
 from InvenTree.models import MetadataMixin
@@ -179,6 +179,11 @@ class ReportContextExtension(TypedDict):
 class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
     """Base class for reports, labels."""
 
+    class ModelChoices(RenderChoices):
+        """Model choices for report templates."""
+
+        choice_fnc = report.helpers.report_model_options
+
     class Meta:
         """Metaclass options."""
 
@@ -269,6 +274,7 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
     model_type = models.CharField(
         max_length=100,
         validators=[report.validators.validate_report_model_type],
+        verbose_name=_('Model Type'),
         help_text=_('Target model type for template'),
     )
 
diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py
index 1475d5c101..0328074af9 100644
--- a/src/backend/InvenTree/stock/models.py
+++ b/src/backend/InvenTree/stock/models.py
@@ -33,7 +33,6 @@ import InvenTree.models
 import InvenTree.ready
 import InvenTree.tasks
 import report.mixins
-import report.models
 import stock.tasks
 from common.icons import validate_icon
 from common.settings import get_global_setting
diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py
index 48d5c04f46..89a6612e2a 100644
--- a/src/backend/InvenTree/users/api.py
+++ b/src/backend/InvenTree/users/api.py
@@ -328,8 +328,8 @@ class TokenListView(TokenMixin, ListCreateAPI):
         'revoked',
         'revoked',
     ]
-
     filterset_fields = ['revoked', 'user']
+    queryset = ApiToken.objects.none()
 
     def create(self, request, *args, **kwargs):
         """Create token and show key to user."""
diff --git a/src/backend/InvenTree/users/migrations/0015_alter_userprofile_type.py b/src/backend/InvenTree/users/migrations/0015_alter_userprofile_type.py
new file mode 100644
index 0000000000..0993b15178
--- /dev/null
+++ b/src/backend/InvenTree/users/migrations/0015_alter_userprofile_type.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.20 on 2025-04-07 20:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("users", "0014_userprofile"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="userprofile",
+            name="type",
+            field=models.CharField(
+                choices=[
+                    ("bot", "Bot"),
+                    ("internal", "Internal"),
+                    ("external", "External"),
+                    ("guest", "Guest"),
+                ],
+                default="internal",
+                help_text="Which type of user is this?",
+                max_length=10,
+                verbose_name="User Type",
+            ),
+        ),
+    ]
diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py
index 8d09c334ac..8a453e2fb7 100644
--- a/src/backend/InvenTree/users/models.py
+++ b/src/backend/InvenTree/users/models.py
@@ -1025,7 +1025,7 @@ class UserProfile(InvenTree.models.MetadataMixin):
         max_length=10,
         choices=UserType.choices,
         default=UserType.INTERNAL,
-        verbose_name=_('Type'),
+        verbose_name=_('User Type'),
         help_text=_('Which type of user is this?'),
     )
     organisation = models.CharField(