2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-27 19:16:44 +00:00

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>
This commit is contained in:
Matthias Mair 2025-04-08 14:09:57 +02:00 committed by GitHub
parent f87f4387ed
commit 0d6c47fcd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 227 additions and 19 deletions

View File

@ -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'

22
.vscode/launch.json vendored
View File

@ -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",

View File

@ -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

View File

@ -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()

View File

@ -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:

View File

@ -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."""

View File

@ -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",
),
),
]

View File

@ -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()

View File

@ -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

View File

@ -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)}

View File

@ -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",
),
),
]

View File

@ -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(

View File

@ -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]

View File

@ -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."""

View File

@ -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",
),
),
]

View File

@ -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'),
)

View File

@ -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

View File

@ -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."""

View File

@ -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",
),
),
]

View File

@ -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(