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 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 a62340bffa2f..000000000000 Binary files a/docs/docs/assets/images/plugin/model_metadata_api.png and /dev/null differ 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 diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index cd57fb1cc247..c3fee4cdb9db 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -5,9 +5,12 @@ 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, reverse 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 +25,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 +812,122 @@ 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 + permission_classes = [InvenTree.permissions.ContentTypePermission] - @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.""" + 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 = ContentType.objects.filter(model=model_name).first() - 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.model_class() 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 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 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.""" + + model_name = None # Placeholder for the model class + lookup_field = 'pk' + 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', 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 + """ + if model is None: + raise ValidationError( + "redirect_metadata_view called without 'model' arg" + ) # pragma: no cover + + return path( + 'metadata/', + 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/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 35d64fcb0d22..461a8f01cef6 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 = 435 +INVENTREE_API_VERSION = 436 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +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 + v435 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11030 - Adds token refresh endpoint to auth API diff --git a/src/backend/InvenTree/InvenTree/permissions.py b/src/backend/InvenTree/InvenTree/permissions.py index bf4335e94cb2..07006870c53c 100644 --- a/src/backend/InvenTree/InvenTree/permissions.py +++ b/src/backend/InvenTree/InvenTree/permissions.py @@ -470,3 +470,25 @@ 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 model_class := obj.__class__: + return users.permissions.check_user_permission( + request.user, model_class, 'change' + ) + return False diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index d7b8692bab47..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, MetadataView, ParameterListMixin +from InvenTree.api import BulkDeleteMixin, ParameterListMixin, meta_path 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', - ), + meta_path(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', - ), + 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 4118548acc12..a1fb8e29234a 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -37,7 +37,13 @@ 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, + SimpleGenericMetadataView, + meta_path, +) from InvenTree.config import CONFIG_LOOKUPS from InvenTree.filters import ( ORDER_FILTER, @@ -1154,11 +1160,7 @@ def perform_create(self, serializer): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=common.models.Attachment), - name='api-attachment-metadata', - ), + meta_path(common.models.Attachment), path('', AttachmentDetail.as_view(), name='api-attachment-detail'), ]), ), @@ -1175,13 +1177,7 @@ def perform_create(self, serializer): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view( - model=common.models.ParameterTemplate - ), - name='api-parameter-template-metadata', - ), + meta_path(common.models.ParameterTemplate), path( '', ParameterTemplateDetail.as_view(), @@ -1199,11 +1195,7 @@ def perform_create(self, serializer): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=common.models.Parameter), - name='api-parameter-metadata', - ), + meta_path(common.models.Parameter), path('', ParameterDetail.as_view(), name='api-parameter-detail'), ]), ), @@ -1217,6 +1209,22 @@ def perform_create(self, serializer): path('', ErrorMessageList.as_view(), name='api-error-list'), ]), ), + # Metadata + path( + 'metadata/', + include([ + path( + '///', + GenericMetadataView.as_view(), + name='api-generic-metadata', + ), + path( + '//', + SimpleGenericMetadataView.as_view(), + name='api-generic-metadata', + ), + ]), + ), # Project codes path( 'project-code/', @@ -1224,14 +1232,7 @@ def perform_create(self, serializer): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view( - model=common.models.ProjectCode, - permission_classes=[IsStaffOrReadOnlyScope], - ), - name='api-project-code-metadata', - ), + 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 8ec8a4ae14c6..66ea55be4cea 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -9,7 +9,7 @@ import part.models from data_exporter.mixins import DataExportViewMixin -from InvenTree.api import ListCreateDestroyAPIView, MetadataView, ParameterListMixin +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 ( @@ -476,11 +476,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=ManufacturerPart), - name='api-manufacturer-part-metadata', - ), + meta_path(ManufacturerPart), path( '', ManufacturerPartDetail.as_view(), @@ -497,11 +493,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=SupplierPart), - name='api-supplier-part-metadata', - ), + meta_path(SupplierPart), path('', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), ]), ), @@ -532,11 +524,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=Company), - name='api-company-metadata', - ), + meta_path(Company), path('', CompanyDetail.as_view(), name='api-company-detail'), ]), ), @@ -546,11 +534,7 @@ class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=Contact), - name='api-contact-metadata', - ), + meta_path(Contact), path('', ContactDetail.as_view(), name='api-contact-detail'), ]), ), 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/api.py b/src/backend/InvenTree/order/api.py index 899f2b441121..4c0eec6156db 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, + meta_path, ) 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', - ), + meta_path(models.PurchaseOrder), path( 'receive/', PurchaseOrderReceive.as_view(), @@ -1920,11 +1916,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=models.PurchaseOrderLineItem), - name='api-po-line-metadata', - ), + meta_path(models.PurchaseOrderLineItem), path( '', PurchaseOrderLineItemDetail.as_view(), @@ -1942,11 +1934,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=models.PurchaseOrderExtraLine), - name='api-po-extra-line-metadata', - ), + meta_path(models.PurchaseOrderExtraLine), path( '', PurchaseOrderExtraLineDetail.as_view(), @@ -1974,11 +1962,7 @@ def item_link(self, item): SalesOrderShipmentComplete.as_view(), name='api-so-shipment-ship', ), - path( - 'metadata/', - MetadataView.as_view(model=models.SalesOrderShipment), - name='api-so-shipment-metadata', - ), + meta_path(models.SalesOrderShipment), path( '', SalesOrderShipmentDetail.as_view(), @@ -2015,11 +1999,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', - ), + meta_path(models.SalesOrder), # SalesOrder detail endpoint path('', SalesOrderDetail.as_view(), name='api-so-detail'), ]), @@ -2042,11 +2022,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=models.SalesOrderLineItem), - name='api-so-line-metadata', - ), + meta_path(models.SalesOrderLineItem), path( '', SalesOrderLineItemDetail.as_view(), @@ -2064,11 +2040,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=models.SalesOrderExtraLine), - name='api-so-extra-line-metadata', - ), + meta_path(models.SalesOrderExtraLine), path( '', SalesOrderExtraLineDetail.as_view(), @@ -2120,11 +2092,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', - ), + meta_path(models.ReturnOrder), path( '', ReturnOrderDetail.as_view(), name='api-return-order-detail' ), @@ -2148,11 +2116,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=models.ReturnOrderLineItem), - name='api-return-order-line-metadata', - ), + meta_path(models.ReturnOrderLineItem), path( '', ReturnOrderLineItemDetail.as_view(), @@ -2179,11 +2143,7 @@ def item_link(self, item): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=models.ReturnOrderExtraLine), - name='api-return-order-extra-line-metadata', - ), + meta_path(models.ReturnOrderExtraLine), path( '', ReturnOrderExtraLineDetail.as_view(), 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/api.py b/src/backend/InvenTree/part/api.py index 8e969dea30e4..25c3c3ad2946 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -18,8 +18,8 @@ BulkDeleteMixin, BulkUpdateMixin, ListCreateDestroyAPIView, - MetadataView, ParameterListMixin, + meta_path, ) from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.filters import ( @@ -1498,13 +1498,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view( - model=PartCategoryParameterTemplate - ), - name='api-part-category-parameter-metadata', - ), + meta_path(PartCategoryParameterTemplate), path( '', CategoryParameterDetail.as_view(), @@ -1523,11 +1517,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=PartCategory), - name='api-part-category-metadata', - ), + meta_path(PartCategory), # PartCategory detail endpoint path('', CategoryDetail.as_view(), name='api-part-category-detail'), ]), @@ -1542,11 +1532,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=PartTestTemplate), - name='api-part-test-template-metadata', - ), + meta_path(PartTestTemplate), path( '', PartTestTemplateDetail.as_view(), @@ -1592,11 +1578,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=PartRelated), - name='api-part-related-metadata', - ), + meta_path(PartRelated), path( '', PartRelatedDetail.as_view(), name='api-part-related-detail' ), @@ -1647,9 +1629,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' - ), + meta_path(Part), # Part pricing path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'), # Part detail endpoint @@ -1667,11 +1647,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=BomItemSubstitute), - name='api-bom-substitute-metadata', - ), + meta_path(BomItemSubstitute), path( '', BomItemSubstituteDetail.as_view(), @@ -1688,11 +1664,7 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): '/', include([ path('validate/', BomItemValidate.as_view(), name='api-bom-item-validate'), - path( - 'metadata/', - MetadataView.as_view(model=BomItem), - name='api-bom-item-metadata', - ), + meta_path(BomItem), path('', BomDetail.as_view(), name='api-bom-item-detail'), ]), ), diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 224d0c6ab661..5db1e62f4927 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -3175,65 +3175,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.""" diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index 8585a37275e1..91580062b32d 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 meta_path 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 = [ @@ -576,12 +576,8 @@ class PluginMetadataView(MetadataView): ), ]), ), - path( - 'metadata/', - PluginMetadataView.as_view( - model=PluginConfig, lookup_field='key' - ), - name='api-plugin-metadata', + meta_path( + PluginConfig, lookup_field='key', lookup_field_ref='plugin' ), path( 'activate/', diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 424067b8e40f..e1dc29212472 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, follow=True) def test_settings(self): """Test settings endpoint for plugin.""" diff --git a/src/backend/InvenTree/report/api.py b/src/backend/InvenTree/report/api.py index d54ea59d96e9..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 MetadataView +from InvenTree.api import meta_path from InvenTree.filters import InvenTreeSearchFilter from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI from plugin import PluginMixinEnum @@ -356,11 +356,7 @@ class ReportAssetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=report.models.LabelTemplate), - name='api-label-template-metadata', - ), + meta_path(report.models.LabelTemplate), path( '', LabelTemplateDetail.as_view(), @@ -383,11 +379,7 @@ class ReportAssetDetail(TemplatePermissionMixin, RetrieveUpdateDestroyAPI): path( '/', include([ - path( - 'metadata/', - MetadataView.as_view(model=report.models.ReportTemplate), - name='api-report-template-metadata', - ), + 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 0aa3c65ed918..84f21b55d19e 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -33,7 +33,7 @@ BulkCreateMixin, BulkUpdateMixin, ListCreateDestroyAPIView, - MetadataView, + meta_path, ) 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', - ), + meta_path(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', - ), + meta_path(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', - ), + meta_path(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', - ), + meta_path(StockItem), path( 'serialize/', StockItemSerialize.as_view(), diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index e81c6e0fed51..5785fc3d56e5 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2565,40 +2565,39 @@ 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)}' + target_key = f'abc-{numstr}' + target_value = f'xyz-{raw_url}-{numstr}' - self.patch( - url, - {'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, - expected_code=200, - ) + # 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-{apikey}-{numstr}' - ) + self.assertEqual(modeldata.get_metadata(target_key), target_value) 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) class StockApiPerformanceTest(StockAPITestCase, InvenTreeAPIPerformanceTestCase):