2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-25 16:17:58 +00:00

refactor(backend): reduce API surface by unifying metadata endpoints (#11035)

* replace individual metadata endpoints with a generic endpoint an a lot of permanent redirects

* remove more names

* reduce duplication more

* remove now unneeded tests

* update remaining tests to use urls

* bump api

* follow redirects in tests

* reduce new fncs

* fix redirect setup

* fix test

* update to fix schema collissions

* fix permission check

* simplify and fix lookup

* clone fork for now

* add changelog entry

* update api version date

* remove temporary change to python lib

* update docs
This commit is contained in:
Matthias Mair
2026-01-06 21:13:25 +01:00
committed by GitHub
parent 0a685c09de
commit 5b290f44c0
19 changed files with 227 additions and 413 deletions

View File

@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes ### 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. - [#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 ### 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 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 "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 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 ## 1.1.0 - 2025-11-02

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -42,12 +42,7 @@ print(part.metadata)
### API Access ### 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 models which provide this metadata field, access is also provided via the API. Use the generic `/metadata/<modelname>/<object id>/` endpoint to retrieve or update metadata information.
For example:
{{ image("plugin/model_metadata_api.png", "Access model metadata via API", maxheight="400px") }}
#### PUT vs PATCH #### PUT vs PATCH

View File

@@ -5,9 +5,12 @@ import json
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
from django.http import JsonResponse from django.http import JsonResponse
from django.urls import path, reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import RedirectView
import structlog import structlog
from django_q.models import OrmQ from django_q.models import OrmQ
@@ -22,7 +25,7 @@ import InvenTree.config
import InvenTree.permissions import InvenTree.permissions
import InvenTree.version import InvenTree.version
from common.settings import get_global_setting 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.auth_overrides import registration_enabled
from InvenTree.mixins import ListCreateAPI from InvenTree.mixins import ListCreateAPI
from InvenTree.sso import sso_registration_enabled from InvenTree.sso import sso_registration_enabled
@@ -809,35 +812,122 @@ class APISearchView(GenericAPIView):
return Response(results) return Response(results)
class MetadataView(RetrieveUpdateAPI): class GenericMetadataView(RetrieveUpdateAPI):
"""Generic API endpoint for reading and editing metadata for a model.""" """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 model = None # Placeholder for the model class
serializer_class = MetadataSerializer
@classmethod permission_classes = [InvenTree.permissions.ContentTypePermission]
def as_view(cls, model, lookup_field=None, **initkwargs):
"""Override to ensure model specific rendering."""
if model is None:
raise ValidationError(
"MetadataView defined without 'model' arg"
) # 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
return super().as_view(**initkwargs)
def get_permission_model(self): def get_permission_model(self):
"""Return the 'permission' model associated with this view.""" """Return the 'permission' model associated with this view."""
return self.model model_name = self.kwargs.get('model', None)
if model_name is None:
raise ValidationError(
"GenericMetadataView called without 'model' URL parameter"
) # pragma: no cover
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.model_class()
def get_queryset(self): def get_queryset(self):
"""Return the queryset for this endpoint.""" """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): def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance.""" """Return MetadataSerializer instance."""
is_gen = ready.isGeneratingSchema()
# Detect if we are currently generating the OpenAPI schema # 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) 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,
),
)

View File

@@ -1,11 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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/<model_name>/ endpoint to retrieve metadata for any model
v435 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11030 v435 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11030
- Adds token refresh endpoint to auth API - Adds token refresh endpoint to auth API

View File

@@ -470,3 +470,25 @@ class DataImporterPermission(OASTokenMixin, permissions.BasePermission):
) )
return True 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

View File

@@ -24,7 +24,7 @@ from build.models import Build, BuildItem, BuildLine
from build.status_codes import BuildStatus, BuildStatusGroups from build.status_codes import BuildStatus, BuildStatusGroups
from data_exporter.mixins import DataExportViewMixin from data_exporter.mixins import DataExportViewMixin
from generic.states.api import StatusView 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.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import (
SEARCH_ORDER_FILTER_ALIAS, SEARCH_ORDER_FILTER_ALIAS,
@@ -960,11 +960,7 @@ build_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(BuildItem),
'metadata/',
MetadataView.as_view(model=BuildItem),
name='api-build-item-metadata',
),
path('', BuildItemDetail.as_view(), name='api-build-item-detail'), path('', BuildItemDetail.as_view(), name='api-build-item-detail'),
]), ]),
), ),
@@ -1007,11 +1003,7 @@ build_api_urls = [
path('finish/', BuildFinish.as_view(), name='api-build-finish'), path('finish/', BuildFinish.as_view(), name='api-build-finish'),
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'), path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'), path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
path( meta_path(Build),
'metadata/',
MetadataView.as_view(model=Build),
name='api-build-metadata',
),
path('', BuildDetail.as_view(), name='api-build-detail'), path('', BuildDetail.as_view(), name='api-build-detail'),
]), ]),
), ),

View File

@@ -37,7 +37,13 @@ from common.icons import get_icon_packs
from common.settings import get_global_setting from common.settings import get_global_setting
from data_exporter.mixins import DataExportViewMixin from data_exporter.mixins import DataExportViewMixin
from generic.states.api import urlpattern as generic_states_api_urls 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.config import CONFIG_LOOKUPS
from InvenTree.filters import ( from InvenTree.filters import (
ORDER_FILTER, ORDER_FILTER,
@@ -1154,11 +1160,7 @@ common_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(common.models.Attachment),
'metadata/',
MetadataView.as_view(model=common.models.Attachment),
name='api-attachment-metadata',
),
path('', AttachmentDetail.as_view(), name='api-attachment-detail'), path('', AttachmentDetail.as_view(), name='api-attachment-detail'),
]), ]),
), ),
@@ -1175,13 +1177,7 @@ common_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(common.models.ParameterTemplate),
'metadata/',
MetadataView.as_view(
model=common.models.ParameterTemplate
),
name='api-parameter-template-metadata',
),
path( path(
'', '',
ParameterTemplateDetail.as_view(), ParameterTemplateDetail.as_view(),
@@ -1199,11 +1195,7 @@ common_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(common.models.Parameter),
'metadata/',
MetadataView.as_view(model=common.models.Parameter),
name='api-parameter-metadata',
),
path('', ParameterDetail.as_view(), name='api-parameter-detail'), path('', ParameterDetail.as_view(), name='api-parameter-detail'),
]), ]),
), ),
@@ -1217,6 +1209,22 @@ common_api_urls = [
path('', ErrorMessageList.as_view(), name='api-error-list'), path('', ErrorMessageList.as_view(), name='api-error-list'),
]), ]),
), ),
# Metadata
path(
'metadata/',
include([
path(
'<str:model>/<str:lookup_field>/<str:lookup_value>/',
GenericMetadataView.as_view(),
name='api-generic-metadata',
),
path(
'<str:model>/<int:pk>/',
SimpleGenericMetadataView.as_view(),
name='api-generic-metadata',
),
]),
),
# Project codes # Project codes
path( path(
'project-code/', 'project-code/',
@@ -1224,14 +1232,7 @@ common_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(common.models.ProjectCode),
'metadata/',
MetadataView.as_view(
model=common.models.ProjectCode,
permission_classes=[IsStaffOrReadOnlyScope],
),
name='api-project-code-metadata',
),
path( path(
'', ProjectCodeDetail.as_view(), name='api-project-code-detail' '', ProjectCodeDetail.as_view(), name='api-project-code-detail'
), ),

View File

@@ -9,7 +9,7 @@ from django_filters.rest_framework.filterset import FilterSet
import part.models import part.models
from data_exporter.mixins import DataExportViewMixin 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.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
from InvenTree.mixins import ( from InvenTree.mixins import (
@@ -476,11 +476,7 @@ manufacturer_part_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(ManufacturerPart),
'metadata/',
MetadataView.as_view(model=ManufacturerPart),
name='api-manufacturer-part-metadata',
),
path( path(
'', '',
ManufacturerPartDetail.as_view(), ManufacturerPartDetail.as_view(),
@@ -497,11 +493,7 @@ supplier_part_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(SupplierPart),
'metadata/',
MetadataView.as_view(model=SupplierPart),
name='api-supplier-part-metadata',
),
path('', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), path('', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
]), ]),
), ),
@@ -532,11 +524,7 @@ company_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(Company),
'metadata/',
MetadataView.as_view(model=Company),
name='api-company-metadata',
),
path('', CompanyDetail.as_view(), name='api-company-detail'), path('', CompanyDetail.as_view(), name='api-company-detail'),
]), ]),
), ),
@@ -546,11 +534,7 @@ company_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(Contact),
'metadata/',
MetadataView.as_view(model=Contact),
name='api-contact-metadata',
),
path('', ContactDetail.as_view(), name='api-contact-detail'), path('', ContactDetail.as_view(), name='api-contact-detail'),
]), ]),
), ),

View File

@@ -2,14 +2,7 @@
from django.urls import reverse from django.urls import reverse
from company.models import ( from company.models import Address, Company, Contact, SupplierPart, SupplierPriceBreak
Address,
Company,
Contact,
ManufacturerPart,
SupplierPart,
SupplierPriceBreak,
)
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part from part.models import Part
from users.permissions import check_user_permission from users.permissions import check_user_permission
@@ -747,58 +740,6 @@ class SupplierPartTest(InvenTreeAPITestCase):
) )
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): class SupplierPriceBreakAPITest(InvenTreeAPITestCase):
"""Unit tests for the SupplierPart price break API.""" """Unit tests for the SupplierPart price break API."""

View File

@@ -31,8 +31,8 @@ from generic.states.api import StatusView
from InvenTree.api import ( from InvenTree.api import (
BulkUpdateMixin, BulkUpdateMixin,
ListCreateDestroyAPIView, ListCreateDestroyAPIView,
MetadataView,
ParameterListMixin, ParameterListMixin,
meta_path,
) )
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import (
@@ -1888,11 +1888,7 @@ order_api_urls = [
name='api-po-complete', name='api-po-complete',
), ),
path('issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'), path('issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
path( meta_path(models.PurchaseOrder),
'metadata/',
MetadataView.as_view(model=models.PurchaseOrder),
name='api-po-metadata',
),
path( path(
'receive/', 'receive/',
PurchaseOrderReceive.as_view(), PurchaseOrderReceive.as_view(),
@@ -1920,11 +1916,7 @@ order_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(models.PurchaseOrderLineItem),
'metadata/',
MetadataView.as_view(model=models.PurchaseOrderLineItem),
name='api-po-line-metadata',
),
path( path(
'', '',
PurchaseOrderLineItemDetail.as_view(), PurchaseOrderLineItemDetail.as_view(),
@@ -1942,11 +1934,7 @@ order_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(models.PurchaseOrderExtraLine),
'metadata/',
MetadataView.as_view(model=models.PurchaseOrderExtraLine),
name='api-po-extra-line-metadata',
),
path( path(
'', '',
PurchaseOrderExtraLineDetail.as_view(), PurchaseOrderExtraLineDetail.as_view(),
@@ -1974,11 +1962,7 @@ order_api_urls = [
SalesOrderShipmentComplete.as_view(), SalesOrderShipmentComplete.as_view(),
name='api-so-shipment-ship', name='api-so-shipment-ship',
), ),
path( meta_path(models.SalesOrderShipment),
'metadata/',
MetadataView.as_view(model=models.SalesOrderShipment),
name='api-so-shipment-metadata',
),
path( path(
'', '',
SalesOrderShipmentDetail.as_view(), SalesOrderShipmentDetail.as_view(),
@@ -2015,11 +1999,7 @@ order_api_urls = [
SalesOrderComplete.as_view(), SalesOrderComplete.as_view(),
name='api-so-complete', name='api-so-complete',
), ),
path( meta_path(models.SalesOrder),
'metadata/',
MetadataView.as_view(model=models.SalesOrder),
name='api-so-metadata',
),
# SalesOrder detail endpoint # SalesOrder detail endpoint
path('', SalesOrderDetail.as_view(), name='api-so-detail'), path('', SalesOrderDetail.as_view(), name='api-so-detail'),
]), ]),
@@ -2042,11 +2022,7 @@ order_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(models.SalesOrderLineItem),
'metadata/',
MetadataView.as_view(model=models.SalesOrderLineItem),
name='api-so-line-metadata',
),
path( path(
'', '',
SalesOrderLineItemDetail.as_view(), SalesOrderLineItemDetail.as_view(),
@@ -2064,11 +2040,7 @@ order_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(models.SalesOrderExtraLine),
'metadata/',
MetadataView.as_view(model=models.SalesOrderExtraLine),
name='api-so-extra-line-metadata',
),
path( path(
'', '',
SalesOrderExtraLineDetail.as_view(), SalesOrderExtraLineDetail.as_view(),
@@ -2120,11 +2092,7 @@ order_api_urls = [
ReturnOrderReceive.as_view(), ReturnOrderReceive.as_view(),
name='api-return-order-receive', name='api-return-order-receive',
), ),
path( meta_path(models.ReturnOrder),
'metadata/',
MetadataView.as_view(model=models.ReturnOrder),
name='api-return-order-metadata',
),
path( path(
'', ReturnOrderDetail.as_view(), name='api-return-order-detail' '', ReturnOrderDetail.as_view(), name='api-return-order-detail'
), ),
@@ -2148,11 +2116,7 @@ order_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(models.ReturnOrderLineItem),
'metadata/',
MetadataView.as_view(model=models.ReturnOrderLineItem),
name='api-return-order-line-metadata',
),
path( path(
'', '',
ReturnOrderLineItemDetail.as_view(), ReturnOrderLineItemDetail.as_view(),
@@ -2179,11 +2143,7 @@ order_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(models.ReturnOrderExtraLine),
'metadata/',
MetadataView.as_view(model=models.ReturnOrderExtraLine),
name='api-return-order-extra-line-metadata',
),
path( path(
'', '',
ReturnOrderExtraLineDetail.as_view(), ReturnOrderExtraLineDetail.as_view(),

View File

@@ -2751,63 +2751,3 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase):
line = models.ReturnOrderLineItem.objects.get(pk=1) line = models.ReturnOrderLineItem.objects.get(pk=1)
self.assertEqual(float(line.price.amount), 15.75) 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)

View File

@@ -18,8 +18,8 @@ from InvenTree.api import (
BulkDeleteMixin, BulkDeleteMixin,
BulkUpdateMixin, BulkUpdateMixin,
ListCreateDestroyAPIView, ListCreateDestroyAPIView,
MetadataView,
ParameterListMixin, ParameterListMixin,
meta_path,
) )
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import (
@@ -1498,13 +1498,7 @@ part_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(PartCategoryParameterTemplate),
'metadata/',
MetadataView.as_view(
model=PartCategoryParameterTemplate
),
name='api-part-category-parameter-metadata',
),
path( path(
'', '',
CategoryParameterDetail.as_view(), CategoryParameterDetail.as_view(),
@@ -1523,11 +1517,7 @@ part_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(PartCategory),
'metadata/',
MetadataView.as_view(model=PartCategory),
name='api-part-category-metadata',
),
# PartCategory detail endpoint # PartCategory detail endpoint
path('', CategoryDetail.as_view(), name='api-part-category-detail'), path('', CategoryDetail.as_view(), name='api-part-category-detail'),
]), ]),
@@ -1542,11 +1532,7 @@ part_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(PartTestTemplate),
'metadata/',
MetadataView.as_view(model=PartTestTemplate),
name='api-part-test-template-metadata',
),
path( path(
'', '',
PartTestTemplateDetail.as_view(), PartTestTemplateDetail.as_view(),
@@ -1592,11 +1578,7 @@ part_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(PartRelated),
'metadata/',
MetadataView.as_view(model=PartRelated),
name='api-part-related-metadata',
),
path( path(
'', PartRelatedDetail.as_view(), name='api-part-related-detail' '', PartRelatedDetail.as_view(), name='api-part-related-detail'
), ),
@@ -1647,9 +1629,7 @@ part_api_urls = [
'bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate' 'bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'
), ),
# Part metadata # Part metadata
path( meta_path(Part),
'metadata/', MetadataView.as_view(model=Part), name='api-part-metadata'
),
# Part pricing # Part pricing
path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'), path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'),
# Part detail endpoint # Part detail endpoint
@@ -1667,11 +1647,7 @@ bom_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(BomItemSubstitute),
'metadata/',
MetadataView.as_view(model=BomItemSubstitute),
name='api-bom-substitute-metadata',
),
path( path(
'', '',
BomItemSubstituteDetail.as_view(), BomItemSubstituteDetail.as_view(),
@@ -1688,11 +1664,7 @@ bom_api_urls = [
'<int:pk>/', '<int:pk>/',
include([ include([
path('validate/', BomItemValidate.as_view(), name='api-bom-item-validate'), path('validate/', BomItemValidate.as_view(), name='api-bom-item-validate'),
path( meta_path(BomItem),
'metadata/',
MetadataView.as_view(model=BomItem),
name='api-bom-item-metadata',
),
path('', BomDetail.as_view(), name='api-bom-item-detail'), path('', BomDetail.as_view(), name='api-bom-item-detail'),
]), ]),
), ),

View File

@@ -3175,65 +3175,6 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
p.refresh_from_db() 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): class PartTestTemplateTest(PartAPITestBase):
"""API unit tests for the PartTestTemplate model.""" """API unit tests for the PartTestTemplate model."""

View File

@@ -17,7 +17,7 @@ from rest_framework.views import APIView
import InvenTree.permissions import InvenTree.permissions
import plugin.serializers as PluginSerializers 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.filters import SEARCH_ORDER_FILTER
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.mixins import ( from InvenTree.mixins import (
@@ -509,11 +509,11 @@ class RegistryStatusView(APIView):
return Response(result) return Response(result)
class PluginMetadataView(MetadataView): # class PluginMetadataView(MetadataView):
"""Metadata API endpoint for the PluginConfig model.""" # """Metadata API endpoint for the PluginConfig model."""
lookup_field = 'key' # lookup_field = 'key'
lookup_url_kwarg = 'plugin' # lookup_url_kwarg = 'plugin'
plugin_api_urls = [ plugin_api_urls = [
@@ -576,12 +576,8 @@ plugin_api_urls = [
), ),
]), ]),
), ),
path( meta_path(
'metadata/', PluginConfig, lookup_field='key', lookup_field_ref='plugin'
PluginMetadataView.as_view(
model=PluginConfig, lookup_field='key'
),
name='api-plugin-metadata',
), ),
path( path(
'activate/', 'activate/',

View File

@@ -470,8 +470,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
cfg = PluginConfig.objects.filter(key='sample').first() cfg = PluginConfig.objects.filter(key='sample').first()
self.assertIsNotNone(cfg) self.assertIsNotNone(cfg)
url = reverse('api-plugin-metadata', kwargs={'plugin': cfg.key}) self.get(f'/api/plugins/{cfg.key}/metadata/', expected_code=200, follow=True)
self.get(url, expected_code=200)
def test_settings(self): def test_settings(self):
"""Test settings endpoint for plugin.""" """Test settings endpoint for plugin."""

View File

@@ -18,7 +18,7 @@ import report.models
import report.serializers import report.serializers
from common.models import DataOutput from common.models import DataOutput
from common.serializers import DataOutputSerializer from common.serializers import DataOutputSerializer
from InvenTree.api import MetadataView from InvenTree.api import meta_path
from InvenTree.filters import InvenTreeSearchFilter from InvenTree.filters import InvenTreeSearchFilter
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from plugin import PluginMixinEnum from plugin import PluginMixinEnum
@@ -356,11 +356,7 @@ label_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(report.models.LabelTemplate),
'metadata/',
MetadataView.as_view(model=report.models.LabelTemplate),
name='api-label-template-metadata',
),
path( path(
'', '',
LabelTemplateDetail.as_view(), LabelTemplateDetail.as_view(),
@@ -383,11 +379,7 @@ report_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(report.models.ReportTemplate),
'metadata/',
MetadataView.as_view(model=report.models.ReportTemplate),
name='api-report-template-metadata',
),
path( path(
'', '',
ReportTemplateDetail.as_view(), ReportTemplateDetail.as_view(),

View File

@@ -33,7 +33,7 @@ from InvenTree.api import (
BulkCreateMixin, BulkCreateMixin,
BulkUpdateMixin, BulkUpdateMixin,
ListCreateDestroyAPIView, ListCreateDestroyAPIView,
MetadataView, meta_path,
) )
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import ( from InvenTree.filters import (
@@ -1610,11 +1610,7 @@ stock_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(StockLocation),
'metadata/',
MetadataView.as_view(model=StockLocation),
name='api-location-metadata',
),
path('', StockLocationDetail.as_view(), name='api-location-detail'), path('', StockLocationDetail.as_view(), name='api-location-detail'),
]), ]),
), ),
@@ -1628,11 +1624,7 @@ stock_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(StockLocationType),
'metadata/',
MetadataView.as_view(model=StockLocationType),
name='api-location-type-metadata',
),
path( path(
'', '',
StockLocationTypeDetail.as_view(), StockLocationTypeDetail.as_view(),
@@ -1659,11 +1651,7 @@ stock_api_urls = [
path( path(
'<int:pk>/', '<int:pk>/',
include([ include([
path( meta_path(StockItemTestResult),
'metadata/',
MetadataView.as_view(model=StockItemTestResult),
name='api-stock-test-result-metadata',
),
path( path(
'', '',
StockItemTestResultDetail.as_view(), StockItemTestResultDetail.as_view(),
@@ -1701,11 +1689,7 @@ stock_api_urls = [
include([ include([
path('convert/', StockItemConvert.as_view(), name='api-stock-item-convert'), path('convert/', StockItemConvert.as_view(), name='api-stock-item-convert'),
path('install/', StockItemInstall.as_view(), name='api-stock-item-install'), path('install/', StockItemInstall.as_view(), name='api-stock-item-install'),
path( meta_path(StockItem),
'metadata/',
MetadataView.as_view(model=StockItem),
name='api-stock-item-metadata',
),
path( path(
'serialize/', 'serialize/',
StockItemSerialize.as_view(), StockItemSerialize.as_view(),

View File

@@ -2565,40 +2565,39 @@ class StockMetadataAPITest(InvenTreeAPITestCase):
roles = ['stock.change', 'stock_location.change'] roles = ['stock.change', 'stock_location.change']
def metatester(self, apikey, model): def metatester(self, raw_url: str, model):
"""Generic tester.""" """Generic tester."""
modeldata = model.objects.first() modeldata = model.objects.first()
# Useless test unless a model object is found # Useless test unless a model object is found
self.assertIsNotNone(modeldata) self.assertIsNotNone(modeldata)
url = reverse(apikey, kwargs={'pk': modeldata.pk}) url = raw_url.format(pk=modeldata.pk)
# Metadata is initially null # Metadata is initially null
self.assertIsNone(modeldata.metadata) 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( # Create / update metadata entry (first try via old addresses)
url, data = {'metadata': {target_key: target_value}}
{'metadata': {f'abc-{numstr}': f'xyz-{apikey}-{numstr}'}}, rsp = self.patch(url, data, expected_code=301)
expected_code=200, self.patch(rsp.url, data, expected_code=200)
)
# Refresh # Refresh and check that metadata has been updated
modeldata.refresh_from_db() modeldata.refresh_from_db()
self.assertEqual( self.assertEqual(modeldata.get_metadata(target_key), target_value)
modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}'
)
def test_metadata(self): def test_metadata(self):
"""Test all endpoints.""" """Test all endpoints."""
for apikey, model in { for raw_url, model in {
'api-location-metadata': StockLocation, '/api/stock/location/{pk}/metadata/': StockLocation,
'api-stock-test-result-metadata': StockItemTestResult, '/api/stock/test/{pk}/metadata/': StockItemTestResult,
'api-stock-item-metadata': StockItem, '/api/stock/{pk}/metadata/': StockItem,
}.items(): }.items():
self.metatester(apikey, model) self.metatester(raw_url, model)
class StockApiPerformanceTest(StockAPITestCase, InvenTreeAPIPerformanceTestCase): class StockApiPerformanceTest(StockAPITestCase, InvenTreeAPIPerformanceTestCase):