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(