From 228b86c4a1b941ee66a42a60685e585617a303b5 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 17 Dec 2025 08:51:40 +0100 Subject: [PATCH 01/18] replace individual metadata endpoints with a generic endpoint an a lot of permanent redirects --- src/backend/InvenTree/InvenTree/api.py | 68 +++++++++++++++++++------- src/backend/InvenTree/build/api.py | 14 ++---- src/backend/InvenTree/common/api.py | 37 ++++++-------- src/backend/InvenTree/company/api.py | 30 ++++-------- src/backend/InvenTree/order/api.py | 45 +++++------------ src/backend/InvenTree/part/api.py | 32 ++++++------ src/backend/InvenTree/plugin/api.py | 15 +++--- src/backend/InvenTree/report/api.py | 9 ++-- src/backend/InvenTree/stock/api.py | 26 ++-------- 9 files changed, 118 insertions(+), 158 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index cd57fb1cc247..626d1983f76a 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -8,6 +8,7 @@ from django.db import transaction from django.http import JsonResponse from django.utils.translation import gettext_lazy as _ +from django.views.generic.base import RedirectView import structlog from django_q.models import OrmQ @@ -22,7 +23,7 @@ import InvenTree.permissions import InvenTree.version from common.settings import get_global_setting -from InvenTree import helpers +from InvenTree import helpers, ready from InvenTree.auth_overrides import registration_enabled from InvenTree.mixins import ListCreateAPI from InvenTree.sso import sso_registration_enabled @@ -809,35 +810,68 @@ def post(self, request, *args, **kwargs): return Response(results) -class MetadataView(RetrieveUpdateAPI): - """Generic API endpoint for reading and editing metadata for a model.""" +class GenericMetadataView(RetrieveUpdateAPI): + """Metadata for specific instance; see https://docs.inventree.org/en/stable/plugins/metadata/ for more detail on how metadata works. Most core models support metadata.""" model = None # Placeholder for the model class + serializer_class = MetadataSerializer - @classmethod - def as_view(cls, model, lookup_field=None, **initkwargs): - """Override to ensure model specific rendering.""" - if model is None: + def get_permission_model(self): + """Return the 'permission' model associated with this view.""" + import common.models + + model_name = self.kwargs.get('model', None) + + if model_name is None: raise ValidationError( - "MetadataView defined without 'model' arg" + "GenericMetadataView called without 'model' URL parameter" ) # pragma: no cover - initkwargs['model'] = model - # Set custom lookup field (instead of default 'pk' value) if supplied - if lookup_field: - initkwargs['lookup_field'] = lookup_field + model = common.models.get_model_by_name(model_name) - return super().as_view(**initkwargs) + if model is None: + raise ValidationError( + f"GenericMetadataView called with invalid model '{model_name}'" + ) # pragma: no cover - def get_permission_model(self): - """Return the 'permission' model associated with this view.""" - return self.model + return model def get_queryset(self): """Return the queryset for this endpoint.""" - return self.model.objects.all() + model = self.get_permission_model() + return model.objects.all() def get_serializer(self, *args, **kwargs): """Return MetadataSerializer instance.""" + is_gen = ready.isGeneratingSchema() # Detect if we are currently generating the OpenAPI schema + if self.model is None and not is_gen: + self.model = self.get_permission_model() + if self.model is None and is_gen: + # Provide a default model for schema generation + import users.models + + self.model = users.models.User return MetadataSerializer(self.model, *args, **kwargs) + + +def redirect_metadata_view(model, lookup_field: str = 'pk', **initkwargs): + """Helper function for redirecting to the general metadata lookup with appropriate model. + + Arguments: + model: The model class to use + lookup_field: The lookup field to use (if not 'pk') + **initkwargs: Additional keyword arguments for the view + Returns: + A redirect to the generic metadata view + """ + # return MetadataView.as_view(model, lookup_field, **initkwargs) + if model is None: + raise ValidationError( + "redirect_metadata_view called without 'model' arg" + ) # pragma: no cover + return RedirectView.as_view( + url=f'/api/metadata/{model._meta.model_name}//', + permanent=True, + query_string=True, + ) diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index d7b8692bab47..95e16030200b 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -24,7 +24,7 @@ from build.status_codes import BuildStatus, BuildStatusGroups from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView -from InvenTree.api import BulkDeleteMixin, MetadataView, ParameterListMixin +from InvenTree.api import BulkDeleteMixin, ParameterListMixin, redirect_metadata_view from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( SEARCH_ORDER_FILTER_ALIAS, @@ -960,11 +960,7 @@ def get_queryset(self): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=BuildItem), - name='api-build-item-metadata', - ), + path('metadata/', redirect_metadata_view(BuildItem)), path('', BuildItemDetail.as_view(), name='api-build-item-detail'), ]), ), @@ -1007,11 +1003,7 @@ def get_queryset(self): path('finish/', BuildFinish.as_view(), name='api-build-finish'), path('cancel/', BuildCancel.as_view(), name='api-build-cancel'), path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), - path( - 'metadata/', - MetadataView.as_view(model=Build), - name='api-build-metadata', - ), + path('metadata/', redirect_metadata_view(Build)), path('', BuildDetail.as_view(), name='api-build-detail'), ]), ), diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 4118548acc12..ba3c3f37a52f 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -37,7 +37,12 @@ from common.settings import get_global_setting from data_exporter.mixins import DataExportViewMixin from generic.states.api import urlpattern as generic_states_api_urls -from InvenTree.api import BulkCreateMixin, BulkDeleteMixin, MetadataView +from InvenTree.api import ( + BulkCreateMixin, + BulkDeleteMixin, + GenericMetadataView, + redirect_metadata_view, +) from InvenTree.config import CONFIG_LOOKUPS from InvenTree.filters import ( ORDER_FILTER, @@ -1154,11 +1159,7 @@ def perform_create(self, serializer): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=common.models.Attachment), - name='api-attachment-metadata', - ), + path('metadata/', redirect_metadata_view(common.models.Attachment)), path('', AttachmentDetail.as_view(), name='api-attachment-detail'), ]), ), @@ -1177,10 +1178,7 @@ def perform_create(self, serializer): include([ path( 'metadata/', - MetadataView.as_view( - model=common.models.ParameterTemplate - ), - name='api-parameter-template-metadata', + redirect_metadata_view(common.models.ParameterTemplate), ), path( '', @@ -1199,11 +1197,7 @@ def perform_create(self, serializer): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=common.models.Parameter), - name='api-parameter-metadata', - ), + path('metadata/', redirect_metadata_view(common.models.Parameter)), path('', ParameterDetail.as_view(), name='api-parameter-detail'), ]), ), @@ -1217,6 +1211,12 @@ def perform_create(self, serializer): path('', ErrorMessageList.as_view(), name='api-error-list'), ]), ), + # Metadata + path( + 'metadata///', + GenericMetadataView.as_view(), + name='api-generic-metadata', + ), # Project codes path( 'project-code/', @@ -1225,12 +1225,7 @@ def perform_create(self, serializer): '/', include([ path( - 'metadata/', - MetadataView.as_view( - model=common.models.ProjectCode, - permission_classes=[IsStaffOrReadOnlyScope], - ), - name='api-project-code-metadata', + 'metadata/', redirect_metadata_view(common.models.ProjectCode) ), path( '', ProjectCodeDetail.as_view(), name='api-project-code-detail' diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 8ec8a4ae14c6..0f6106b84fb2 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -9,7 +9,11 @@ import part.models from data_exporter.mixins import DataExportViewMixin -from InvenTree.api import ListCreateDestroyAPIView, MetadataView, ParameterListMixin +from InvenTree.api import ( + ListCreateDestroyAPIView, + ParameterListMixin, + redirect_metadata_view, +) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.mixins import ( @@ -476,11 +480,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=ManufacturerPart), - name='api-manufacturer-part-metadata', - ), + path('metadata/', redirect_metadata_view(ManufacturerPart)), path( '', ManufacturerPartDetail.as_view(), @@ -497,11 +497,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=SupplierPart), - name='api-supplier-part-metadata', - ), + path('metadata/', redirect_metadata_view(SupplierPart)), path('', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), ]), ), @@ -532,11 +528,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=Company), - name='api-company-metadata', - ), + path('metadata/', redirect_metadata_view(Company)), path('', CompanyDetail.as_view(), name='api-company-detail'), ]), ), @@ -546,11 +538,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=Contact), - name='api-contact-metadata', - ), + path('metadata/', redirect_metadata_view(Contact)), path('', ContactDetail.as_view(), name='api-contact-detail'), ]), ), diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index a81e338084b3..3256b34f5bbb 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -31,8 +31,8 @@ from InvenTree.api import ( BulkUpdateMixin, ListCreateDestroyAPIView, - MetadataView, ParameterListMixin, + redirect_metadata_view, ) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( @@ -1888,11 +1888,7 @@ def item_link(self, item): name='api-po-complete', ), path('issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'), - path( - 'metadata/', - MetadataView.as_view(model=models.PurchaseOrder), - name='api-po-metadata', - ), + path('metadata/', redirect_metadata_view(models.PurchaseOrder)), path( 'receive/', PurchaseOrderReceive.as_view(), @@ -1922,8 +1918,7 @@ def item_link(self, item): include([ path( 'metadata/', - MetadataView.as_view(model=models.PurchaseOrderLineItem), - name='api-po-line-metadata', + redirect_metadata_view(models.PurchaseOrderLineItem), ), path( '', @@ -1944,8 +1939,7 @@ def item_link(self, item): include([ path( 'metadata/', - MetadataView.as_view(model=models.PurchaseOrderExtraLine), - name='api-po-extra-line-metadata', + redirect_metadata_view(models.PurchaseOrderExtraLine), ), path( '', @@ -1976,8 +1970,7 @@ def item_link(self, item): ), path( 'metadata/', - MetadataView.as_view(model=models.SalesOrderShipment), - name='api-so-shipment-metadata', + redirect_metadata_view(models.SalesOrderShipment), ), path( '', @@ -2015,11 +2008,7 @@ def item_link(self, item): SalesOrderComplete.as_view(), name='api-so-complete', ), - path( - 'metadata/', - MetadataView.as_view(model=models.SalesOrder), - name='api-so-metadata', - ), + path('metadata/', redirect_metadata_view(models.SalesOrder)), # SalesOrder detail endpoint path('', SalesOrderDetail.as_view(), name='api-so-detail'), ]), @@ -2043,9 +2032,7 @@ def item_link(self, item): '/', include([ path( - 'metadata/', - MetadataView.as_view(model=models.SalesOrderLineItem), - name='api-so-line-metadata', + 'metadata/', redirect_metadata_view(models.SalesOrderLineItem) ), path( '', @@ -2065,9 +2052,7 @@ def item_link(self, item): '/', include([ path( - 'metadata/', - MetadataView.as_view(model=models.SalesOrderExtraLine), - name='api-so-extra-line-metadata', + 'metadata/', redirect_metadata_view(models.SalesOrderExtraLine) ), path( '', @@ -2120,11 +2105,7 @@ def item_link(self, item): ReturnOrderReceive.as_view(), name='api-return-order-receive', ), - path( - 'metadata/', - MetadataView.as_view(model=models.ReturnOrder), - name='api-return-order-metadata', - ), + path('metadata/', redirect_metadata_view(models.ReturnOrder)), path( '', ReturnOrderDetail.as_view(), name='api-return-order-detail' ), @@ -2149,9 +2130,7 @@ def item_link(self, item): '/', include([ path( - 'metadata/', - MetadataView.as_view(model=models.ReturnOrderLineItem), - name='api-return-order-line-metadata', + 'metadata/', redirect_metadata_view(models.ReturnOrderLineItem) ), path( '', @@ -2180,9 +2159,7 @@ def item_link(self, item): '/', include([ path( - 'metadata/', - MetadataView.as_view(model=models.ReturnOrderExtraLine), - name='api-return-order-extra-line-metadata', + 'metadata/', redirect_metadata_view(models.ReturnOrderExtraLine) ), path( '', diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 8e969dea30e4..f0867916341d 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -18,8 +18,8 @@ BulkDeleteMixin, BulkUpdateMixin, ListCreateDestroyAPIView, - MetadataView, ParameterListMixin, + redirect_metadata_view, ) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( @@ -1500,10 +1500,8 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): include([ path( 'metadata/', - MetadataView.as_view( - model=PartCategoryParameterTemplate - ), - name='api-part-category-parameter-metadata', + redirect_metadata_view(PartCategoryParameterTemplate), + # name='api-part-category-parameter-metadata', ), path( '', @@ -1525,8 +1523,8 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): include([ path( 'metadata/', - MetadataView.as_view(model=PartCategory), - name='api-part-category-metadata', + redirect_metadata_view(PartCategory), + # name='api-part-category-metadata', ), # PartCategory detail endpoint path('', CategoryDetail.as_view(), name='api-part-category-detail'), @@ -1544,8 +1542,8 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): include([ path( 'metadata/', - MetadataView.as_view(model=PartTestTemplate), - name='api-part-test-template-metadata', + redirect_metadata_view(PartTestTemplate), + # name='api-part-test-template-metadata', ), path( '', @@ -1594,8 +1592,8 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): include([ path( 'metadata/', - MetadataView.as_view(model=PartRelated), - name='api-part-related-metadata', + redirect_metadata_view(PartRelated), + # name='api-part-related-metadata', ), path( '', PartRelatedDetail.as_view(), name='api-part-related-detail' @@ -1647,9 +1645,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): 'bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate' ), # Part metadata - path( - 'metadata/', MetadataView.as_view(model=Part), name='api-part-metadata' - ), + path('metadata/', redirect_metadata_view(Part)), # Part pricing path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'), # Part detail endpoint @@ -1669,8 +1665,8 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): include([ path( 'metadata/', - MetadataView.as_view(model=BomItemSubstitute), - name='api-bom-substitute-metadata', + redirect_metadata_view(BomItemSubstitute), + # name='api-bom-substitute-metadata', ), path( '', @@ -1690,8 +1686,8 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path('validate/', BomItemValidate.as_view(), name='api-bom-item-validate'), path( 'metadata/', - MetadataView.as_view(model=BomItem), - name='api-bom-item-metadata', + redirect_metadata_view(BomItem), + # name='api-bom-item-metadata', ), path('', BomDetail.as_view(), name='api-bom-item-detail'), ]), diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index 8585a37275e1..3e4a5c7c0ed3 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -17,7 +17,7 @@ import InvenTree.permissions import plugin.serializers as PluginSerializers -from InvenTree.api import MetadataView +from InvenTree.api import redirect_metadata_view from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.helpers import str2bool from InvenTree.mixins import ( @@ -509,11 +509,11 @@ def get(self, request): return Response(result) -class PluginMetadataView(MetadataView): - """Metadata API endpoint for the PluginConfig model.""" +# class PluginMetadataView(MetadataView): +# """Metadata API endpoint for the PluginConfig model.""" - lookup_field = 'key' - lookup_url_kwarg = 'plugin' +# lookup_field = 'key' +# lookup_url_kwarg = 'plugin' plugin_api_urls = [ @@ -578,10 +578,7 @@ class PluginMetadataView(MetadataView): ), path( 'metadata/', - PluginMetadataView.as_view( - model=PluginConfig, lookup_field='key' - ), - name='api-plugin-metadata', + redirect_metadata_view(PluginConfig, lookup_field='key'), ), path( 'activate/', diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index d54ea59d96e9..d08a2928e17d 100644 --- a/src/backend/InvenTree/report/api.py +++ b/src/backend/InvenTree/report/api.py @@ -18,7 +18,7 @@ import report.serializers from common.models import DataOutput from common.serializers import DataOutputSerializer -from InvenTree.api import MetadataView +from InvenTree.api import redirect_metadata_view from InvenTree.filters import InvenTreeSearchFilter from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI from plugin import PluginMixinEnum @@ -357,9 +357,7 @@ class ReportAssetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): '/', include([ path( - 'metadata/', - MetadataView.as_view(model=report.models.LabelTemplate), - name='api-label-template-metadata', + 'metadata/', redirect_metadata_view(report.models.LabelTemplate) ), path( '', @@ -385,8 +383,7 @@ class ReportAssetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): include([ path( 'metadata/', - MetadataView.as_view(model=report.models.ReportTemplate), - name='api-report-template-metadata', + redirect_metadata_view(report.models.ReportTemplate), ), path( '', diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 0aa3c65ed918..836e6ebe0100 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -33,7 +33,7 @@ BulkCreateMixin, BulkUpdateMixin, ListCreateDestroyAPIView, - MetadataView, + redirect_metadata_view, ) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( @@ -1610,11 +1610,7 @@ def create(self, request, *args, **kwargs): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=StockLocation), - name='api-location-metadata', - ), + path('metadata/', redirect_metadata_view(StockLocation)), path('', StockLocationDetail.as_view(), name='api-location-detail'), ]), ), @@ -1628,11 +1624,7 @@ def create(self, request, *args, **kwargs): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=StockLocationType), - name='api-location-type-metadata', - ), + path('metadata/', redirect_metadata_view(StockLocationType)), path( '', StockLocationTypeDetail.as_view(), @@ -1659,11 +1651,7 @@ def create(self, request, *args, **kwargs): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=StockItemTestResult), - name='api-stock-test-result-metadata', - ), + path('metadata/', redirect_metadata_view(StockItemTestResult)), path( '', StockItemTestResultDetail.as_view(), @@ -1701,11 +1689,7 @@ def create(self, request, *args, **kwargs): include([ path('convert/', StockItemConvert.as_view(), name='api-stock-item-convert'), path('install/', StockItemInstall.as_view(), name='api-stock-item-install'), - path( - 'metadata/', - MetadataView.as_view(model=StockItem), - name='api-stock-item-metadata', - ), + path('metadata/', redirect_metadata_view(StockItem)), path( 'serialize/', StockItemSerialize.as_view(), From 0af860b86c7502e6be47e553cf11ba3ecea565c8 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 17 Dec 2025 22:19:26 +0100 Subject: [PATCH 02/18] remove more names --- src/backend/InvenTree/part/api.py | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index f0867916341d..7adb9f379c00 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1501,7 +1501,6 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( 'metadata/', redirect_metadata_view(PartCategoryParameterTemplate), - # name='api-part-category-parameter-metadata', ), path( '', @@ -1521,11 +1520,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - redirect_metadata_view(PartCategory), - # name='api-part-category-metadata', - ), + path('metadata/', redirect_metadata_view(PartCategory)), # PartCategory detail endpoint path('', CategoryDetail.as_view(), name='api-part-category-detail'), ]), @@ -1540,11 +1535,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - redirect_metadata_view(PartTestTemplate), - # name='api-part-test-template-metadata', - ), + path('metadata/', redirect_metadata_view(PartTestTemplate)), path( '', PartTestTemplateDetail.as_view(), @@ -1590,11 +1581,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - redirect_metadata_view(PartRelated), - # name='api-part-related-metadata', - ), + path('metadata/', redirect_metadata_view(PartRelated)), path( '', PartRelatedDetail.as_view(), name='api-part-related-detail' ), @@ -1663,11 +1650,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - redirect_metadata_view(BomItemSubstitute), - # name='api-bom-substitute-metadata', - ), + path('metadata/', redirect_metadata_view(BomItemSubstitute)), path( '', BomItemSubstituteDetail.as_view(), @@ -1684,11 +1667,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): '/', include([ path('validate/', BomItemValidate.as_view(), name='api-bom-item-validate'), - path( - 'metadata/', - redirect_metadata_view(BomItem), - # name='api-bom-item-metadata', - ), + path('metadata/', redirect_metadata_view(BomItem)), path('', BomDetail.as_view(), name='api-bom-item-detail'), ]), ), From e8b560c5d98ea7bd822417ecb4a272fb302947cf Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 17 Dec 2025 22:41:56 +0100 Subject: [PATCH 03/18] reduce duplication more --- src/backend/InvenTree/InvenTree/api.py | 14 +++++++++ src/backend/InvenTree/build/api.py | 6 ++-- src/backend/InvenTree/common/api.py | 15 ++++------ src/backend/InvenTree/company/api.py | 14 ++++----- src/backend/InvenTree/order/api.py | 39 ++++++++------------------ src/backend/InvenTree/part/api.py | 19 ++++++------- src/backend/InvenTree/report/api.py | 11 ++------ src/backend/InvenTree/stock/api.py | 10 +++---- 8 files changed, 54 insertions(+), 74 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 626d1983f76a..372cd5db3548 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -7,6 +7,7 @@ from django.conf import settings from django.db import transaction from django.http import JsonResponse +from django.urls import path from django.utils.translation import gettext_lazy as _ from django.views.generic.base import RedirectView @@ -875,3 +876,16 @@ def redirect_metadata_view(model, lookup_field: str = 'pk', **initkwargs): permanent=True, query_string=True, ) + + +def meta_path(model, lookup_field: str = 'pk'): + """Helper function for constructing metadata path for a given model. + + Arguments: + model: The model class to use + lookup_field: The lookup field to use (if not 'pk') + + Returns: + A path to the generic metadata view for the given model + """ + return path('metadata/', redirect_metadata_view(model, lookup_field=lookup_field)) diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 95e16030200b..755a60db766c 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -24,7 +24,7 @@ from build.status_codes import BuildStatus, BuildStatusGroups from data_exporter.mixins import DataExportViewMixin from generic.states.api import StatusView -from InvenTree.api import BulkDeleteMixin, ParameterListMixin, redirect_metadata_view +from InvenTree.api import BulkDeleteMixin, ParameterListMixin, meta_path from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( SEARCH_ORDER_FILTER_ALIAS, @@ -960,7 +960,7 @@ def get_queryset(self): path( '/', include([ - path('metadata/', redirect_metadata_view(BuildItem)), + meta_path(BuildItem), path('', BuildItemDetail.as_view(), name='api-build-item-detail'), ]), ), @@ -1003,7 +1003,7 @@ def get_queryset(self): path('finish/', BuildFinish.as_view(), name='api-build-finish'), path('cancel/', BuildCancel.as_view(), name='api-build-cancel'), path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), - path('metadata/', redirect_metadata_view(Build)), + meta_path(Build), path('', BuildDetail.as_view(), name='api-build-detail'), ]), ), diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index ba3c3f37a52f..ab686c3f5942 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -41,7 +41,7 @@ BulkCreateMixin, BulkDeleteMixin, GenericMetadataView, - redirect_metadata_view, + meta_path, ) from InvenTree.config import CONFIG_LOOKUPS from InvenTree.filters import ( @@ -1159,7 +1159,7 @@ def perform_create(self, serializer): path( '/', include([ - path('metadata/', redirect_metadata_view(common.models.Attachment)), + meta_path(common.models.Attachment), path('', AttachmentDetail.as_view(), name='api-attachment-detail'), ]), ), @@ -1176,10 +1176,7 @@ def perform_create(self, serializer): path( '/', include([ - path( - 'metadata/', - redirect_metadata_view(common.models.ParameterTemplate), - ), + meta_path(common.models.ParameterTemplate), path( '', ParameterTemplateDetail.as_view(), @@ -1197,7 +1194,7 @@ def perform_create(self, serializer): path( '/', include([ - path('metadata/', redirect_metadata_view(common.models.Parameter)), + meta_path(common.models.Parameter), path('', ParameterDetail.as_view(), name='api-parameter-detail'), ]), ), @@ -1224,9 +1221,7 @@ def perform_create(self, serializer): path( '/', include([ - path( - 'metadata/', redirect_metadata_view(common.models.ProjectCode) - ), + meta_path(common.models.ProjectCode), path( '', ProjectCodeDetail.as_view(), name='api-project-code-detail' ), diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 0f6106b84fb2..66ea55be4cea 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -9,11 +9,7 @@ import part.models from data_exporter.mixins import DataExportViewMixin -from InvenTree.api import ( - ListCreateDestroyAPIView, - ParameterListMixin, - redirect_metadata_view, -) +from InvenTree.api import ListCreateDestroyAPIView, ParameterListMixin, meta_path from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.mixins import ( @@ -480,7 +476,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path('metadata/', redirect_metadata_view(ManufacturerPart)), + meta_path(ManufacturerPart), path( '', ManufacturerPartDetail.as_view(), @@ -497,7 +493,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path('metadata/', redirect_metadata_view(SupplierPart)), + meta_path(SupplierPart), path('', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), ]), ), @@ -528,7 +524,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path('metadata/', redirect_metadata_view(Company)), + meta_path(Company), path('', CompanyDetail.as_view(), name='api-company-detail'), ]), ), @@ -538,7 +534,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path('metadata/', redirect_metadata_view(Contact)), + meta_path(Contact), path('', ContactDetail.as_view(), name='api-contact-detail'), ]), ), diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 3256b34f5bbb..046169500d22 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -32,7 +32,7 @@ BulkUpdateMixin, ListCreateDestroyAPIView, ParameterListMixin, - redirect_metadata_view, + meta_path, ) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( @@ -1888,7 +1888,7 @@ def item_link(self, item): name='api-po-complete', ), path('issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'), - path('metadata/', redirect_metadata_view(models.PurchaseOrder)), + meta_path(models.PurchaseOrder), path( 'receive/', PurchaseOrderReceive.as_view(), @@ -1916,10 +1916,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', - redirect_metadata_view(models.PurchaseOrderLineItem), - ), + meta_path(models.PurchaseOrderLineItem), path( '', PurchaseOrderLineItemDetail.as_view(), @@ -1937,10 +1934,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', - redirect_metadata_view(models.PurchaseOrderExtraLine), - ), + meta_path(models.PurchaseOrderExtraLine), path( '', PurchaseOrderExtraLineDetail.as_view(), @@ -1968,10 +1962,7 @@ def item_link(self, item): SalesOrderShipmentComplete.as_view(), name='api-so-shipment-ship', ), - path( - 'metadata/', - redirect_metadata_view(models.SalesOrderShipment), - ), + meta_path(models.SalesOrderShipment), path( '', SalesOrderShipmentDetail.as_view(), @@ -2008,7 +1999,7 @@ def item_link(self, item): SalesOrderComplete.as_view(), name='api-so-complete', ), - path('metadata/', redirect_metadata_view(models.SalesOrder)), + meta_path(models.SalesOrder), # SalesOrder detail endpoint path('', SalesOrderDetail.as_view(), name='api-so-detail'), ]), @@ -2031,9 +2022,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', redirect_metadata_view(models.SalesOrderLineItem) - ), + meta_path(models.SalesOrderLineItem), path( '', SalesOrderLineItemDetail.as_view(), @@ -2051,9 +2040,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', redirect_metadata_view(models.SalesOrderExtraLine) - ), + meta_path(models.SalesOrderExtraLine), path( '', SalesOrderExtraLineDetail.as_view(), @@ -2105,7 +2092,7 @@ def item_link(self, item): ReturnOrderReceive.as_view(), name='api-return-order-receive', ), - path('metadata/', redirect_metadata_view(models.ReturnOrder)), + meta_path(models.ReturnOrder), path( '', ReturnOrderDetail.as_view(), name='api-return-order-detail' ), @@ -2129,9 +2116,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', redirect_metadata_view(models.ReturnOrderLineItem) - ), + meta_path(models.ReturnOrderLineItem), path( '', ReturnOrderLineItemDetail.as_view(), @@ -2158,9 +2143,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', redirect_metadata_view(models.ReturnOrderExtraLine) - ), + meta_path(models.ReturnOrderExtraLine), path( '', ReturnOrderExtraLineDetail.as_view(), diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 7adb9f379c00..25c3c3ad2946 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -19,7 +19,7 @@ BulkUpdateMixin, ListCreateDestroyAPIView, ParameterListMixin, - redirect_metadata_view, + meta_path, ) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( @@ -1498,10 +1498,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - redirect_metadata_view(PartCategoryParameterTemplate), - ), + meta_path(PartCategoryParameterTemplate), path( '', CategoryParameterDetail.as_view(), @@ -1520,7 +1517,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path('metadata/', redirect_metadata_view(PartCategory)), + meta_path(PartCategory), # PartCategory detail endpoint path('', CategoryDetail.as_view(), name='api-part-category-detail'), ]), @@ -1535,7 +1532,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path('metadata/', redirect_metadata_view(PartTestTemplate)), + meta_path(PartTestTemplate), path( '', PartTestTemplateDetail.as_view(), @@ -1581,7 +1578,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path('metadata/', redirect_metadata_view(PartRelated)), + meta_path(PartRelated), path( '', PartRelatedDetail.as_view(), name='api-part-related-detail' ), @@ -1632,7 +1629,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): 'bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate' ), # Part metadata - path('metadata/', redirect_metadata_view(Part)), + meta_path(Part), # Part pricing path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'), # Part detail endpoint @@ -1650,7 +1647,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path('metadata/', redirect_metadata_view(BomItemSubstitute)), + meta_path(BomItemSubstitute), path( '', BomItemSubstituteDetail.as_view(), @@ -1667,7 +1664,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): '/', include([ path('validate/', BomItemValidate.as_view(), name='api-bom-item-validate'), - path('metadata/', redirect_metadata_view(BomItem)), + meta_path(BomItem), path('', BomDetail.as_view(), name='api-bom-item-detail'), ]), ), diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index d08a2928e17d..a02bfe74aea0 100644 --- a/src/backend/InvenTree/report/api.py +++ b/src/backend/InvenTree/report/api.py @@ -18,7 +18,7 @@ import report.serializers from common.models import DataOutput from common.serializers import DataOutputSerializer -from InvenTree.api import redirect_metadata_view +from InvenTree.api import meta_path from InvenTree.filters import InvenTreeSearchFilter from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI from plugin import PluginMixinEnum @@ -356,9 +356,7 @@ class ReportAssetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', redirect_metadata_view(report.models.LabelTemplate) - ), + meta_path(report.models.LabelTemplate), path( '', LabelTemplateDetail.as_view(), @@ -381,10 +379,7 @@ class ReportAssetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - redirect_metadata_view(report.models.ReportTemplate), - ), + meta_path(report.models.ReportTemplate), path( '', ReportTemplateDetail.as_view(), diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 836e6ebe0100..84f21b55d19e 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -33,7 +33,7 @@ BulkCreateMixin, BulkUpdateMixin, ListCreateDestroyAPIView, - redirect_metadata_view, + meta_path, ) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( @@ -1610,7 +1610,7 @@ def create(self, request, *args, **kwargs): path( '/', include([ - path('metadata/', redirect_metadata_view(StockLocation)), + meta_path(StockLocation), path('', StockLocationDetail.as_view(), name='api-location-detail'), ]), ), @@ -1624,7 +1624,7 @@ def create(self, request, *args, **kwargs): path( '/', include([ - path('metadata/', redirect_metadata_view(StockLocationType)), + meta_path(StockLocationType), path( '', StockLocationTypeDetail.as_view(), @@ -1651,7 +1651,7 @@ def create(self, request, *args, **kwargs): path( '/', include([ - path('metadata/', redirect_metadata_view(StockItemTestResult)), + meta_path(StockItemTestResult), path( '', StockItemTestResultDetail.as_view(), @@ -1689,7 +1689,7 @@ def create(self, request, *args, **kwargs): include([ path('convert/', StockItemConvert.as_view(), name='api-stock-item-convert'), path('install/', StockItemInstall.as_view(), name='api-stock-item-install'), - path('metadata/', redirect_metadata_view(StockItem)), + meta_path(StockItem), path( 'serialize/', StockItemSerialize.as_view(), From 810fa545acc90437eac1f883c79909d16f2e754e Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 18 Dec 2025 22:16:42 +0100 Subject: [PATCH 04/18] remove now unneeded tests --- src/backend/InvenTree/company/test_api.py | 61 +---------------------- src/backend/InvenTree/order/test_api.py | 60 ---------------------- src/backend/InvenTree/part/test_api.py | 59 ---------------------- 3 files changed, 1 insertion(+), 179 deletions(-) diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 3599160d93c7..b3701fb08f1c 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -2,14 +2,7 @@ from django.urls import reverse -from company.models import ( - Address, - Company, - Contact, - ManufacturerPart, - SupplierPart, - SupplierPriceBreak, -) +from company.models import Address, Company, Contact, SupplierPart, SupplierPriceBreak from InvenTree.unit_test import InvenTreeAPITestCase from part.models import Part from users.permissions import check_user_permission @@ -747,58 +740,6 @@ def test_filterable_fields(self): ) -class CompanyMetadataAPITest(InvenTreeAPITestCase): - """Unit tests for the various metadata endpoints of API.""" - - fixtures = [ - 'category', - 'part', - 'location', - 'company', - 'contact', - 'manufacturer_part', - 'supplier_part', - ] - - roles = ['company.change', 'purchase_order.change', 'part.change'] - - def metatester(self, apikey, model): - """Generic tester.""" - modeldata = model.objects.first() - - # Useless test unless a model object is found - self.assertIsNotNone(modeldata) - - url = reverse(apikey, kwargs={'pk': modeldata.pk}) - - # Metadata is initially null - self.assertIsNone(modeldata.metadata) - - numstr = f'12{len(apikey)}' - - self.patch( - url, - {'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, - expected_code=200, - ) - - # Refresh - modeldata.refresh_from_db() - self.assertEqual( - modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}' - ) - - def test_metadata(self): - """Test all endpoints.""" - for apikey, model in { - 'api-manufacturer-part-metadata': ManufacturerPart, - 'api-supplier-part-metadata': SupplierPart, - 'api-company-metadata': Company, - 'api-contact-metadata': Contact, - }.items(): - self.metatester(apikey, model) - - class SupplierPriceBreakAPITest(InvenTreeAPITestCase): """Unit tests for the SupplierPart price break API.""" diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 58c74e7f6d35..3166ca3134a8 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -2751,63 +2751,3 @@ def test_update(self): line = models.ReturnOrderLineItem.objects.get(pk=1) self.assertEqual(float(line.price.amount), 15.75) - - -class OrderMetadataAPITest(InvenTreeAPITestCase): - """Unit tests for the various metadata endpoints of API.""" - - fixtures = [ - 'category', - 'part', - 'company', - 'location', - 'supplier_part', - 'stock', - 'order', - 'sales_order', - 'return_order', - ] - - roles = ['purchase_order.change', 'sales_order.change', 'return_order.change'] - - def metatester(self, apikey, model): - """Generic tester.""" - modeldata = model.objects.first() - - # Useless test unless a model object is found - self.assertIsNotNone(modeldata) - - url = reverse(apikey, kwargs={'pk': modeldata.pk}) - - # Metadata is initially null - self.assertIsNone(modeldata.metadata) - - numstr = f'12{len(apikey)}' - - self.patch( - url, - {'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, - expected_code=200, - ) - - # Refresh - modeldata.refresh_from_db() - self.assertEqual( - modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}' - ) - - def test_metadata(self): - """Test all endpoints.""" - for apikey, model in { - 'api-po-metadata': models.PurchaseOrder, - 'api-po-line-metadata': models.PurchaseOrderLineItem, - 'api-po-extra-line-metadata': models.PurchaseOrderExtraLine, - 'api-so-shipment-metadata': models.SalesOrderShipment, - 'api-so-metadata': models.SalesOrder, - 'api-so-line-metadata': models.SalesOrderLineItem, - 'api-so-extra-line-metadata': models.SalesOrderExtraLine, - 'api-return-order-metadata': models.ReturnOrder, - 'api-return-order-line-metadata': models.ReturnOrderLineItem, - 'api-return-order-extra-line-metadata': models.ReturnOrderExtraLine, - }.items(): - self.metatester(apikey, model) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 6892488a7ae4..b36abf95b1f5 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -3174,65 +3174,6 @@ def test_create_price_breaks(self): p.refresh_from_db() -class PartMetadataAPITest(InvenTreeAPITestCase): - """Unit tests for the various metadata endpoints of API.""" - - fixtures = [ - 'category', - 'part', - 'params', - 'location', - 'bom', - 'company', - 'test_templates', - 'manufacturer_part', - 'supplier_part', - 'order', - 'stock', - ] - - roles = ['part.change', 'part_category.change'] - - def metatester(self, apikey, model): - """Generic tester.""" - modeldata = model.objects.first() - - # Useless test unless a model object is found - self.assertIsNotNone(modeldata) - - url = reverse(apikey, kwargs={'pk': modeldata.pk}) - - # Metadata is initially null - self.assertIsNone(modeldata.metadata) - - numstr = randint(100, 900) - - self.patch( - url, - {'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, - expected_code=200, - ) - - # Refresh - modeldata.refresh_from_db() - self.assertEqual( - modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}' - ) - - def test_metadata(self): - """Test all endpoints.""" - for apikey, model in { - 'api-part-category-parameter-metadata': PartCategoryParameterTemplate, - 'api-part-category-metadata': PartCategory, - 'api-part-test-template-metadata': PartTestTemplate, - 'api-part-related-metadata': PartRelated, - 'api-part-metadata': Part, - 'api-bom-substitute-metadata': BomItemSubstitute, - 'api-bom-item-metadata': BomItem, - }.items(): - self.metatester(apikey, model) - - class PartTestTemplateTest(PartAPITestBase): """API unit tests for the PartTestTemplate model.""" From 11f3efaea1e4dc984ff65440bffa0fb0c29cc2ca Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 18 Dec 2025 22:22:20 +0100 Subject: [PATCH 05/18] update remaining tests to use urls --- src/backend/InvenTree/plugin/test_api.py | 3 +-- src/backend/InvenTree/stock/test_api.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 424067b8e40f..5a4391271f0e 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -470,8 +470,7 @@ def test_plugin_metadata(self): cfg = PluginConfig.objects.filter(key='sample').first() self.assertIsNotNone(cfg) - url = reverse('api-plugin-metadata', kwargs={'plugin': cfg.key}) - self.get(url, expected_code=200) + self.get(f'/api/plugins/{cfg.key}/metadata/', expected_code=200) def test_settings(self): """Test settings endpoint for plugin.""" diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 49936d9d6ca4..8a21f69cf55b 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2515,37 +2515,37 @@ class StockMetadataAPITest(InvenTreeAPITestCase): roles = ['stock.change', 'stock_location.change'] - def metatester(self, apikey, model): + def metatester(self, raw_url: str, model): """Generic tester.""" modeldata = model.objects.first() # Useless test unless a model object is found self.assertIsNotNone(modeldata) - url = reverse(apikey, kwargs={'pk': modeldata.pk}) + url = raw_url.format(pk=modeldata.pk) # Metadata is initially null self.assertIsNone(modeldata.metadata) - numstr = f'12{len(apikey)}' + numstr = f'12{len(raw_url)}' self.patch( url, - {'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, + {'metadata': {f'abc-{numstr}': f'xyz-{raw_url}-{numstr}'}}, expected_code=200, ) # Refresh modeldata.refresh_from_db() self.assertEqual( - modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}' + modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{raw_url}-{numstr}' ) def test_metadata(self): """Test all endpoints.""" - for apikey, model in { - 'api-location-metadata': StockLocation, - 'api-stock-test-result-metadata': StockItemTestResult, - 'api-stock-item-metadata': StockItem, + for raw_url, model in { + 'api/stock/location/{pk}/metadata/': StockLocation, + 'api/stock/test/{pk}/metadata/': StockItemTestResult, + 'api/stock/{pk}/metadata/': StockItem, }.items(): - self.metatester(apikey, model) + self.metatester(raw_url, model) From 64d52d595fa12ee2b7b57d55a2b2e115c86fddb9 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 18 Dec 2025 22:31:20 +0100 Subject: [PATCH 06/18] bump api --- src/backend/InvenTree/InvenTree/api_version.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 358530faece5..675aace2aca6 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 434 +INVENTREE_API_VERSION = 435 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v335 -> 2025-12-18 : https://github.com/inventree/InvenTree/pull/11035 + - Removes model-specific metadata endpoints and replaces them with redirects + - Adds new generic /api/metadata// endpoint to retrieve metadata for any model + v434 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11021 - The "tags" fields (on various API endpoints) is now optional, and disabled by default - To request tags information, add "tags=true" to the API request query parameters From 63dd98cdcc0eac76466b6e86d51eeddf3e6539dc Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 18 Dec 2025 22:35:36 +0100 Subject: [PATCH 07/18] follow redirects in tests --- src/backend/InvenTree/plugin/test_api.py | 2 +- src/backend/InvenTree/stock/test_api.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 5a4391271f0e..e1dc29212472 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -470,7 +470,7 @@ def test_plugin_metadata(self): cfg = PluginConfig.objects.filter(key='sample').first() self.assertIsNotNone(cfg) - self.get(f'/api/plugins/{cfg.key}/metadata/', expected_code=200) + self.get(f'/api/plugins/{cfg.key}/metadata/', expected_code=200, follow=True) def test_settings(self): """Test settings endpoint for plugin.""" diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 8a21f69cf55b..71d41942e5c0 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2533,6 +2533,7 @@ def metatester(self, raw_url: str, model): url, {'metadata': {f'abc-{numstr}': f'xyz-{raw_url}-{numstr}'}}, expected_code=200, + follow=True, ) # Refresh @@ -2544,8 +2545,8 @@ def metatester(self, raw_url: str, model): def test_metadata(self): """Test all endpoints.""" for raw_url, model in { - 'api/stock/location/{pk}/metadata/': StockLocation, - 'api/stock/test/{pk}/metadata/': StockItemTestResult, - 'api/stock/{pk}/metadata/': StockItem, + '/api/stock/location/{pk}/metadata/': StockLocation, + '/api/stock/test/{pk}/metadata/': StockItemTestResult, + '/api/stock/{pk}/metadata/': StockItem, }.items(): self.metatester(raw_url, model) From 27edecb45523ee89a51dae27f20e077857163813 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 20 Dec 2025 23:56:07 +0100 Subject: [PATCH 08/18] reduce new fncs --- src/backend/InvenTree/InvenTree/api.py | 38 ++++++++++++-------------- src/backend/InvenTree/plugin/api.py | 7 ++--- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 372cd5db3548..e6c934737efc 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -856,26 +856,14 @@ def get_serializer(self, *args, **kwargs): return MetadataSerializer(self.model, *args, **kwargs) -def redirect_metadata_view(model, lookup_field: str = 'pk', **initkwargs): - """Helper function for redirecting to the general metadata lookup with appropriate model. +class MetadataView(RedirectView): + """Redirect to the generic metadata view for a given model.""" - Arguments: - model: The model class to use - lookup_field: The lookup field to use (if not 'pk') - **initkwargs: Additional keyword arguments for the view - Returns: - A redirect to the generic metadata view - """ - # return MetadataView.as_view(model, lookup_field, **initkwargs) - if model is None: - raise ValidationError( - "redirect_metadata_view called without 'model' arg" - ) # pragma: no cover - return RedirectView.as_view( - url=f'/api/metadata/{model._meta.model_name}//', - permanent=True, - query_string=True, - ) + model_name = None # Placeholder for the model class + lookup_field = 'pk' + + # default + permanent = True def meta_path(model, lookup_field: str = 'pk'): @@ -888,4 +876,14 @@ def meta_path(model, lookup_field: str = 'pk'): Returns: A path to the generic metadata view for the given model """ - return path('metadata/', redirect_metadata_view(model, lookup_field=lookup_field)) + if model is None: + raise ValidationError( + "redirect_metadata_view called without 'model' arg" + ) # pragma: no cover + + return path( + 'metadata/', + MetadataView.as_view( + model_name=model._meta.model_name, lookup_field=lookup_field + ), + ) diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index 3e4a5c7c0ed3..ffb702779524 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -17,7 +17,7 @@ import InvenTree.permissions import plugin.serializers as PluginSerializers -from InvenTree.api import redirect_metadata_view +from InvenTree.api import meta_path from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.helpers import str2bool from InvenTree.mixins import ( @@ -576,10 +576,7 @@ def get(self, request): ), ]), ), - path( - 'metadata/', - redirect_metadata_view(PluginConfig, lookup_field='key'), - ), + meta_path(PluginConfig, lookup_field='key'), path( 'activate/', PluginActivate.as_view(), From 6c52ca68307509a5bb05cfe4552de03093c18174 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 21 Dec 2025 13:26:54 +0100 Subject: [PATCH 09/18] fix redirect setup --- src/backend/InvenTree/InvenTree/api.py | 40 +++++++++++++++++++------- src/backend/InvenTree/common/api.py | 16 +++++++++-- src/backend/InvenTree/plugin/api.py | 4 ++- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index e6c934737efc..626457f4dc56 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -5,9 +5,10 @@ from pathlib import Path from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.db import transaction from django.http import JsonResponse -from django.urls import path +from django.urls import path, reverse from django.utils.translation import gettext_lazy as _ from django.views.generic.base import RedirectView @@ -819,8 +820,6 @@ class GenericMetadataView(RetrieveUpdateAPI): def get_permission_model(self): """Return the 'permission' model associated with this view.""" - import common.models - model_name = self.kwargs.get('model', None) if model_name is None: @@ -828,14 +827,14 @@ def get_permission_model(self): "GenericMetadataView called without 'model' URL parameter" ) # pragma: no cover - model = common.models.get_model_by_name(model_name) + model = ContentType.objects.filter(model=model_name).first() if model is None: raise ValidationError( f"GenericMetadataView called with invalid model '{model_name}'" ) # pragma: no cover - return model + return model.model_class() def get_queryset(self): """Return the queryset for this endpoint.""" @@ -855,23 +854,40 @@ def get_serializer(self, *args, **kwargs): self.model = users.models.User return MetadataSerializer(self.model, *args, **kwargs) + def dispatch(self, request, *args, **kwargs): + """Override dispatch to set lookup field dynamically.""" + self.lookup_field = self.kwargs.get('lookup_field', 'pk') + self.lookup_url_kwarg = ( + 'lookup_value' if 'lookup_field' in self.kwargs else 'pk' + ) + return super().dispatch(request, *args, **kwargs) + -class MetadataView(RedirectView): +class MetadataRedirectView(RedirectView): """Redirect to the generic metadata view for a given model.""" model_name = None # Placeholder for the model class lookup_field = 'pk' - - # default + lookup_field_ref = 'pk' permanent = True + def get_redirect_url(self, *args, **kwargs) -> str | None: + """Return the redirect URL for this view.""" + _kwargs = { + 'model': self.model_name, + 'lookup_value': self.kwargs.get(self.lookup_field_ref, None), + 'lookup_field': self.lookup_field, + } + return reverse('api-generic-metadata', args=args, kwargs=_kwargs) + -def meta_path(model, lookup_field: str = 'pk'): +def meta_path(model, lookup_field: str = 'pk', lookup_field_ref: str = 'pk'): """Helper function for constructing metadata path for a given model. Arguments: model: The model class to use lookup_field: The lookup field to use (if not 'pk') + lookup_field_ref: The reference name for the lookup field in the request(if not 'pk') Returns: A path to the generic metadata view for the given model @@ -883,7 +899,9 @@ def meta_path(model, lookup_field: str = 'pk'): return path( 'metadata/', - MetadataView.as_view( - model_name=model._meta.model_name, lookup_field=lookup_field + MetadataRedirectView.as_view( + model_name=model._meta.model_name, + lookup_field=lookup_field, + lookup_field_ref=lookup_field_ref, ), ) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index ab686c3f5942..07fd7a17953b 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -1210,9 +1210,19 @@ def perform_create(self, serializer): ), # Metadata path( - 'metadata///', - GenericMetadataView.as_view(), - name='api-generic-metadata', + 'metadata/', + include([ + path( + '///', + GenericMetadataView.as_view(), + name='api-generic-metadata', + ), + path( + '//', + GenericMetadataView.as_view(), + name='api-generic-metadata', + ), + ]), ), # Project codes path( diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index ffb702779524..91580062b32d 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -576,7 +576,9 @@ def get(self, request): ), ]), ), - meta_path(PluginConfig, lookup_field='key'), + meta_path( + PluginConfig, lookup_field='key', lookup_field_ref='plugin' + ), path( 'activate/', PluginActivate.as_view(), From 857a655dacaae6b9d7471cd1c1916473af12512c Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 21 Dec 2025 13:27:19 +0100 Subject: [PATCH 10/18] fix test --- src/backend/InvenTree/stock/test_api.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 144fb17d506e..6398f0d596a0 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2529,19 +2529,17 @@ def metatester(self, raw_url: str, model): self.assertIsNone(modeldata.metadata) numstr = f'12{len(raw_url)}' + target_key = f'abc-{numstr}' + target_value = f'xyz-{raw_url}-{numstr}' - self.patch( - url, - {'metadata': {f'abc-{numstr}': f'xyz-{raw_url}-{numstr}'}}, - expected_code=200, - follow=True, - ) + # Create / update metadata entry (first try via old addresses) + data = {'metadata': {target_key: target_value}} + rsp = self.patch(url, data, expected_code=301) + self.patch(rsp.url, data, expected_code=200) - # Refresh + # Refresh and check that metadata has been updated modeldata.refresh_from_db() - self.assertEqual( - modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{raw_url}-{numstr}' - ) + self.assertEqual(modeldata.get_metadata(target_key), target_value) def test_metadata(self): """Test all endpoints.""" From 83de1221e99c0fe55a4f6acf0b7aa2d615f3d0f1 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 21 Dec 2025 13:53:33 +0100 Subject: [PATCH 11/18] update to fix schema collissions --- src/backend/InvenTree/InvenTree/api.py | 25 +++++++++++++++++++++++++ src/backend/InvenTree/common/api.py | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 626457f4dc56..de3e724da6a3 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -863,6 +863,31 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) +class SimpleGenericMetadataView(GenericMetadataView): + """Simplified version of GenericMetadataView which always uses 'pk' as the lookup field.""" + + def dispatch(self, request, *args, **kwargs): + """Override dispatch to set lookup field to 'pk'.""" + self.lookup_field = 'pk' + self.lookup_url_kwarg = None + return super().dispatch(request, *args, **kwargs) + + @extend_schema(operation_id='metadata_pk_retrieve') + def get(self, request, *args, **kwargs): + """Perform a GET request to retrieve metadata for the given object.""" + return super().get(request, *args, **kwargs) + + @extend_schema(operation_id='metadata_pk_update') + def put(self, request, *args, **kwargs): + """Perform a PUT request to update metadata for the given object.""" + return super().put(request, *args, **kwargs) + + @extend_schema(operation_id='metadata_pk_partial_update') + def patch(self, request, *args, **kwargs): + """Perform a PATCH request to partially update metadata for the given object.""" + return super().patch(request, *args, **kwargs) + + class MetadataRedirectView(RedirectView): """Redirect to the generic metadata view for a given model.""" diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 07fd7a17953b..a1fb8e29234a 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -41,6 +41,7 @@ BulkCreateMixin, BulkDeleteMixin, GenericMetadataView, + SimpleGenericMetadataView, meta_path, ) from InvenTree.config import CONFIG_LOOKUPS @@ -1219,7 +1220,7 @@ def perform_create(self, serializer): ), path( '//', - GenericMetadataView.as_view(), + SimpleGenericMetadataView.as_view(), name='api-generic-metadata', ), ]), From eac6c78acd2d36b6eb0b304aa2375d3182301d67 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 22 Dec 2025 00:56:30 +0100 Subject: [PATCH 12/18] fix permission check --- src/backend/InvenTree/InvenTree/api.py | 1 + .../InvenTree/InvenTree/permissions.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index de3e724da6a3..c3fee4cdb9db 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -817,6 +817,7 @@ class GenericMetadataView(RetrieveUpdateAPI): model = None # Placeholder for the model class serializer_class = MetadataSerializer + permission_classes = [InvenTree.permissions.ContentTypePermission] def get_permission_model(self): """Return the 'permission' model associated with this view.""" diff --git a/src/backend/InvenTree/InvenTree/permissions.py b/src/backend/InvenTree/InvenTree/permissions.py index bf4335e94cb2..84fda9a2a66a 100644 --- a/src/backend/InvenTree/InvenTree/permissions.py +++ b/src/backend/InvenTree/InvenTree/permissions.py @@ -470,3 +470,31 @@ def has_object_permission(self, request, view, obj): ) return True + + +class ContentTypePermission(OASTokenMixin, permissions.BasePermission): + """Mixin class for determining if the user has correct permissions.""" + + ENFORCE_USER_PERMS = True + + def has_permission(self, request, view): + """Class level permission checks are handled via InvenTree.permissions.IsAuthenticatedOrReadScope.""" + return request.user and request.user.is_authenticated + + def get_required_alternate_scopes(self, request, view): + """Return the required scopes for the current request.""" + return map_scope(roles=_roles) + + def has_object_permission(self, request, view, obj): + """Check if the user has permission to access the object.""" + # if isinstance(obj, importer.models.DataImportSession): + # session = obj + # else: + session = getattr(obj, 'session', None) + + if session: + if model_class := session.model_class: + return users.permissions.check_user_permission( + request.user, model_class, 'change' + ) + return False From c76f4f573ff4058093074b6d0b5be1c077e6f823 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 22 Dec 2025 09:07:07 +0100 Subject: [PATCH 13/18] simplify and fix lookup --- src/backend/InvenTree/InvenTree/permissions.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/permissions.py b/src/backend/InvenTree/InvenTree/permissions.py index 84fda9a2a66a..07006870c53c 100644 --- a/src/backend/InvenTree/InvenTree/permissions.py +++ b/src/backend/InvenTree/InvenTree/permissions.py @@ -487,14 +487,8 @@ def get_required_alternate_scopes(self, request, view): def has_object_permission(self, request, view, obj): """Check if the user has permission to access the object.""" - # if isinstance(obj, importer.models.DataImportSession): - # session = obj - # else: - session = getattr(obj, 'session', None) - - if session: - if model_class := session.model_class: - return users.permissions.check_user_permission( - request.user, model_class, 'change' - ) + if model_class := obj.__class__: + return users.permissions.check_user_permission( + request.user, model_class, 'change' + ) return False From e8253ed5be39153c59d9b268afcec6155b86967f Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 6 Jan 2026 01:19:01 +0100 Subject: [PATCH 14/18] clone fork for now --- .github/workflows/qc_checks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 8bf7bd8ab0b1..12ff54ebe0bc 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -312,7 +312,7 @@ jobs: dev-install: true update: true - name: Download Python Code For `${WRAPPER_NAME}` - run: git clone --depth 1 https://github.com/inventree/${WRAPPER_NAME} ./${WRAPPER_NAME} + run: git clone --depth 1 https://github.com/matmair/${WRAPPER_NAME} -b new-metadata-endpoint ./${WRAPPER_NAME} - name: Start InvenTree Server run: | invoke dev.delete-data -f From 0fb74f7ce736508a6705233eaa0ee8bad17bcc7f Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 6 Jan 2026 01:21:28 +0100 Subject: [PATCH 15/18] add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af5b9c2437ad..f2b1be6befed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes - [#10699](https://github.com/inventree/InvenTree/pull/10699) removes the `PartParameter` and `PartParameterTempalate` models (and associated API endpoints). These have been replaced with generic `Parameter` and `ParameterTemplate` models (and API endpoints). Any external client applications which made use of the old endpoints will need to be updated. +- [#11035](https://github.com/inventree/InvenTree/pull/11035) moves to a single endpoint for all metadata operations. The previous endpoints for PartMetadata, SupplierPartMetadata, etc have been removed. Any external client applications which made use of the old endpoints will need to be updated. ### Added @@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed python 3.9 / 3.10 support as part of Django 5.2 upgrade in [#10730](https://github.com/inventree/InvenTree/pull/10730) - Removed the "PartParameter" and "PartParameterTemplate" models (and associated API endpoints) in [#10699](https://github.com/inventree/InvenTree/pull/10699) - Removed the "ManufacturerPartParameter" model (and associated API endpoints) [#10699](https://github.com/inventree/InvenTree/pull/10699) +- Removed individual metadata endpoints for all models ([#11035](https://github.com/inventree/InvenTree/pull/11035)) ## 1.1.0 - 2025-11-02 From 2146287cd02507be3b6ffa7cd880579c304c8fea Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 6 Jan 2026 01:22:06 +0100 Subject: [PATCH 16/18] update api version date --- src/backend/InvenTree/InvenTree/api_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 6645d099f22e..461a8f01cef6 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -6,7 +6,7 @@ INVENTREE_API_TEXT = """ -v436 -> 2025-12-18 : https://github.com/inventree/InvenTree/pull/11035 +v436 -> 2026-01-06 : https://github.com/inventree/InvenTree/pull/11035 - Removes model-specific metadata endpoints and replaces them with redirects - Adds new generic /api/metadata// endpoint to retrieve metadata for any model From 7167f1162bdd2c19bd265c5691f26bb6af134e79 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 6 Jan 2026 01:29:00 +0100 Subject: [PATCH 17/18] remove temporary change to python lib --- .github/workflows/qc_checks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 12ff54ebe0bc..8bf7bd8ab0b1 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -312,7 +312,7 @@ jobs: dev-install: true update: true - name: Download Python Code For `${WRAPPER_NAME}` - run: git clone --depth 1 https://github.com/matmair/${WRAPPER_NAME} -b new-metadata-endpoint ./${WRAPPER_NAME} + run: git clone --depth 1 https://github.com/inventree/${WRAPPER_NAME} ./${WRAPPER_NAME} - name: Start InvenTree Server run: | invoke dev.delete-data -f From 4e844692398abe3a5826bbd212d5e401761e7c09 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 6 Jan 2026 01:41:33 +0100 Subject: [PATCH 18/18] update docs --- .../assets/images/plugin/model_metadata_api.png | Bin 89400 -> 0 bytes docs/docs/plugins/metadata.md | 7 +------ 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 docs/docs/assets/images/plugin/model_metadata_api.png diff --git a/docs/docs/assets/images/plugin/model_metadata_api.png b/docs/docs/assets/images/plugin/model_metadata_api.png deleted file mode 100644 index a62340bffa2f4f94f5200a8adcb9d1014c16db48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 89400 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%0AP+qX3;#PnTu{RYlJD{+M|ZkV5Z_ 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 diff --git a/docs/docs/plugins/metadata.md b/docs/docs/plugins/metadata.md index 605436b4c9a3..762deb9c6e1f 100644 --- a/docs/docs/plugins/metadata.md +++ b/docs/docs/plugins/metadata.md @@ -42,12 +42,7 @@ print(part.metadata) ### API Access -For models which provide this metadata field, access is also provided via the API. Append `/metadata/` to the detail endpoint for a particular model instance to access. - -For example: - -{{ image("plugin/model_metadata_api.png", "Access model metadata via API", maxheight="400px") }} - +For models which provide this metadata field, access is also provided via the API. Use the generic `/metadata///` endpoint to retrieve or update metadata information. #### PUT vs PATCH