mirror of
https://github.com/inventree/InvenTree.git
synced 2026-01-10 05:08:09 +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:
@@ -5,9 +5,12 @@ import json
|
||||
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.config
|
||||
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 @@ class APISearchView(GenericAPIView):
|
||||
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
|
||||
|
||||
@classmethod
|
||||
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)
|
||||
serializer_class = MetadataSerializer
|
||||
permission_classes = [InvenTree.permissions.ContentTypePermission]
|
||||
|
||||
def get_permission_model(self):
|
||||
"""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):
|
||||
"""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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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/<model_name>/ 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
|
||||
|
||||
|
||||
@@ -470,3 +470,25 @@ class DataImporterPermission(OASTokenMixin, permissions.BasePermission):
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -24,7 +24,7 @@ from build.models import Build, BuildItem, BuildLine
|
||||
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 @@ build_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ build_api_urls = [
|
||||
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'),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -37,7 +37,13 @@ from common.icons import get_icon_packs
|
||||
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 @@ common_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ common_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ common_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ common_api_urls = [
|
||||
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
|
||||
path(
|
||||
'project-code/',
|
||||
@@ -1224,14 +1232,7 @@ common_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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'
|
||||
),
|
||||
|
||||
@@ -9,7 +9,7 @@ from django_filters.rest_framework.filterset import FilterSet
|
||||
|
||||
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 @@ manufacturer_part_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(model=ManufacturerPart),
|
||||
name='api-manufacturer-part-metadata',
|
||||
),
|
||||
meta_path(ManufacturerPart),
|
||||
path(
|
||||
'',
|
||||
ManufacturerPartDetail.as_view(),
|
||||
@@ -497,11 +493,7 @@ supplier_part_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ company_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ company_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(model=Contact),
|
||||
name='api-contact-metadata',
|
||||
),
|
||||
meta_path(Contact),
|
||||
path('', ContactDetail.as_view(), name='api-contact-detail'),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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 @@ 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):
|
||||
"""Unit tests for the SupplierPart price break API."""
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ from generic.states.api import StatusView
|
||||
from InvenTree.api import (
|
||||
BulkUpdateMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
MetadataView,
|
||||
ParameterListMixin,
|
||||
meta_path,
|
||||
)
|
||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
@@ -1888,11 +1888,7 @@ order_api_urls = [
|
||||
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 @@ order_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ order_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ order_api_urls = [
|
||||
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 @@ order_api_urls = [
|
||||
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 @@ order_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ order_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ order_api_urls = [
|
||||
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 @@ order_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ order_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(model=models.ReturnOrderExtraLine),
|
||||
name='api-return-order-extra-line-metadata',
|
||||
),
|
||||
meta_path(models.ReturnOrderExtraLine),
|
||||
path(
|
||||
'',
|
||||
ReturnOrderExtraLineDetail.as_view(),
|
||||
|
||||
@@ -2751,63 +2751,3 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase):
|
||||
|
||||
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)
|
||||
|
||||
@@ -18,8 +18,8 @@ from InvenTree.api import (
|
||||
BulkDeleteMixin,
|
||||
BulkUpdateMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
MetadataView,
|
||||
ParameterListMixin,
|
||||
meta_path,
|
||||
)
|
||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
@@ -1498,13 +1498,7 @@ part_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ part_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ part_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ part_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ part_api_urls = [
|
||||
'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 @@ bom_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(model=BomItemSubstitute),
|
||||
name='api-bom-substitute-metadata',
|
||||
),
|
||||
meta_path(BomItemSubstitute),
|
||||
path(
|
||||
'',
|
||||
BomItemSubstituteDetail.as_view(),
|
||||
@@ -1688,11 +1664,7 @@ bom_api_urls = [
|
||||
'<int:pk>/',
|
||||
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'),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -3175,65 +3175,6 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
|
||||
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."""
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from rest_framework.views import APIView
|
||||
|
||||
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 @@ class RegistryStatusView(APIView):
|
||||
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 @@ plugin_api_urls = [
|
||||
),
|
||||
]),
|
||||
),
|
||||
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/',
|
||||
|
||||
@@ -470,8 +470,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
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."""
|
||||
|
||||
@@ -18,7 +18,7 @@ import report.models
|
||||
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 @@ label_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ report_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(model=report.models.ReportTemplate),
|
||||
name='api-report-template-metadata',
|
||||
),
|
||||
meta_path(report.models.ReportTemplate),
|
||||
path(
|
||||
'',
|
||||
ReportTemplateDetail.as_view(),
|
||||
|
||||
@@ -33,7 +33,7 @@ from InvenTree.api import (
|
||||
BulkCreateMixin,
|
||||
BulkUpdateMixin,
|
||||
ListCreateDestroyAPIView,
|
||||
MetadataView,
|
||||
meta_path,
|
||||
)
|
||||
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
|
||||
from InvenTree.filters import (
|
||||
@@ -1610,11 +1610,7 @@ stock_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ stock_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(model=StockLocationType),
|
||||
name='api-location-type-metadata',
|
||||
),
|
||||
meta_path(StockLocationType),
|
||||
path(
|
||||
'',
|
||||
StockLocationTypeDetail.as_view(),
|
||||
@@ -1659,11 +1651,7 @@ stock_api_urls = [
|
||||
path(
|
||||
'<int:pk>/',
|
||||
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 @@ stock_api_urls = [
|
||||
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(),
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user