Skip to content

Commit 5b290f4

Browse files
authored
refactor(backend): reduce API surface by unifying metadata endpoints (inventree#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
1 parent 0a685c0 commit 5b290f4

19 files changed

Lines changed: 224 additions & 410 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Breaking Changes
1111

1212
- [#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.
13+
- [#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.
1314

1415
### Added
1516

@@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2728
- Removed python 3.9 / 3.10 support as part of Django 5.2 upgrade in [#10730](https://github.com/inventree/InvenTree/pull/10730)
2829
- Removed the "PartParameter" and "PartParameterTemplate" models (and associated API endpoints) in [#10699](https://github.com/inventree/InvenTree/pull/10699)
2930
- Removed the "ManufacturerPartParameter" model (and associated API endpoints) [#10699](https://github.com/inventree/InvenTree/pull/10699)
31+
- Removed individual metadata endpoints for all models ([#11035](https://github.com/inventree/InvenTree/pull/11035))
3032

3133
## 1.1.0 - 2025-11-02
3234

-87.3 KB
Binary file not shown.

docs/docs/plugins/metadata.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,7 @@ print(part.metadata)
4242

4343
### API Access
4444

45-
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.
46-
47-
For example:
48-
49-
{{ image("plugin/model_metadata_api.png", "Access model metadata via API", maxheight="400px") }}
50-
45+
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.
5146

5247
#### PUT vs PATCH
5348

src/backend/InvenTree/InvenTree/api.py

Lines changed: 107 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
from pathlib import Path
66

77
from django.conf import settings
8+
from django.contrib.contenttypes.models import ContentType
89
from django.db import transaction
910
from django.http import JsonResponse
11+
from django.urls import path, reverse
1012
from django.utils.translation import gettext_lazy as _
13+
from django.views.generic.base import RedirectView
1114

1215
import structlog
1316
from django_q.models import OrmQ
@@ -22,7 +25,7 @@
2225
import InvenTree.permissions
2326
import InvenTree.version
2427
from common.settings import get_global_setting
25-
from InvenTree import helpers
28+
from InvenTree import helpers, ready
2629
from InvenTree.auth_overrides import registration_enabled
2730
from InvenTree.mixins import ListCreateAPI
2831
from InvenTree.sso import sso_registration_enabled
@@ -809,35 +812,122 @@ def post(self, request, *args, **kwargs):
809812
return Response(results)
810813

811814

812-
class MetadataView(RetrieveUpdateAPI):
813-
"""Generic API endpoint for reading and editing metadata for a model."""
815+
class GenericMetadataView(RetrieveUpdateAPI):
816+
"""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."""
814817

815818
model = None # Placeholder for the model class
819+
serializer_class = MetadataSerializer
820+
permission_classes = [InvenTree.permissions.ContentTypePermission]
816821

817-
@classmethod
818-
def as_view(cls, model, lookup_field=None, **initkwargs):
819-
"""Override to ensure model specific rendering."""
820-
if model is None:
822+
def get_permission_model(self):
823+
"""Return the 'permission' model associated with this view."""
824+
model_name = self.kwargs.get('model', None)
825+
826+
if model_name is None:
821827
raise ValidationError(
822-
"MetadataView defined without 'model' arg"
828+
"GenericMetadataView called without 'model' URL parameter"
823829
) # pragma: no cover
824-
initkwargs['model'] = model
825830

826-
# Set custom lookup field (instead of default 'pk' value) if supplied
827-
if lookup_field:
828-
initkwargs['lookup_field'] = lookup_field
831+
model = ContentType.objects.filter(model=model_name).first()
829832

830-
return super().as_view(**initkwargs)
833+
if model is None:
834+
raise ValidationError(
835+
f"GenericMetadataView called with invalid model '{model_name}'"
836+
) # pragma: no cover
831837

832-
def get_permission_model(self):
833-
"""Return the 'permission' model associated with this view."""
834-
return self.model
838+
return model.model_class()
835839

836840
def get_queryset(self):
837841
"""Return the queryset for this endpoint."""
838-
return self.model.objects.all()
842+
model = self.get_permission_model()
843+
return model.objects.all()
839844

840845
def get_serializer(self, *args, **kwargs):
841846
"""Return MetadataSerializer instance."""
847+
is_gen = ready.isGeneratingSchema()
842848
# Detect if we are currently generating the OpenAPI schema
849+
if self.model is None and not is_gen:
850+
self.model = self.get_permission_model()
851+
if self.model is None and is_gen:
852+
# Provide a default model for schema generation
853+
import users.models
854+
855+
self.model = users.models.User
843856
return MetadataSerializer(self.model, *args, **kwargs)
857+
858+
def dispatch(self, request, *args, **kwargs):
859+
"""Override dispatch to set lookup field dynamically."""
860+
self.lookup_field = self.kwargs.get('lookup_field', 'pk')
861+
self.lookup_url_kwarg = (
862+
'lookup_value' if 'lookup_field' in self.kwargs else 'pk'
863+
)
864+
return super().dispatch(request, *args, **kwargs)
865+
866+
867+
class SimpleGenericMetadataView(GenericMetadataView):
868+
"""Simplified version of GenericMetadataView which always uses 'pk' as the lookup field."""
869+
870+
def dispatch(self, request, *args, **kwargs):
871+
"""Override dispatch to set lookup field to 'pk'."""
872+
self.lookup_field = 'pk'
873+
self.lookup_url_kwarg = None
874+
return super().dispatch(request, *args, **kwargs)
875+
876+
@extend_schema(operation_id='metadata_pk_retrieve')
877+
def get(self, request, *args, **kwargs):
878+
"""Perform a GET request to retrieve metadata for the given object."""
879+
return super().get(request, *args, **kwargs)
880+
881+
@extend_schema(operation_id='metadata_pk_update')
882+
def put(self, request, *args, **kwargs):
883+
"""Perform a PUT request to update metadata for the given object."""
884+
return super().put(request, *args, **kwargs)
885+
886+
@extend_schema(operation_id='metadata_pk_partial_update')
887+
def patch(self, request, *args, **kwargs):
888+
"""Perform a PATCH request to partially update metadata for the given object."""
889+
return super().patch(request, *args, **kwargs)
890+
891+
892+
class MetadataRedirectView(RedirectView):
893+
"""Redirect to the generic metadata view for a given model."""
894+
895+
model_name = None # Placeholder for the model class
896+
lookup_field = 'pk'
897+
lookup_field_ref = 'pk'
898+
permanent = True
899+
900+
def get_redirect_url(self, *args, **kwargs) -> str | None:
901+
"""Return the redirect URL for this view."""
902+
_kwargs = {
903+
'model': self.model_name,
904+
'lookup_value': self.kwargs.get(self.lookup_field_ref, None),
905+
'lookup_field': self.lookup_field,
906+
}
907+
return reverse('api-generic-metadata', args=args, kwargs=_kwargs)
908+
909+
910+
def meta_path(model, lookup_field: str = 'pk', lookup_field_ref: str = 'pk'):
911+
"""Helper function for constructing metadata path for a given model.
912+
913+
Arguments:
914+
model: The model class to use
915+
lookup_field: The lookup field to use (if not 'pk')
916+
lookup_field_ref: The reference name for the lookup field in the request(if not 'pk')
917+
918+
Returns:
919+
A path to the generic metadata view for the given model
920+
"""
921+
if model is None:
922+
raise ValidationError(
923+
"redirect_metadata_view called without 'model' arg"
924+
) # pragma: no cover
925+
926+
return path(
927+
'metadata/',
928+
MetadataRedirectView.as_view(
929+
model_name=model._meta.model_name,
930+
lookup_field=lookup_field,
931+
lookup_field_ref=lookup_field_ref,
932+
),
933+
)

src/backend/InvenTree/InvenTree/api_version.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""InvenTree API version information."""
22

33
# InvenTree API version
4-
INVENTREE_API_VERSION = 435
4+
INVENTREE_API_VERSION = 436
55
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
66

77
INVENTREE_API_TEXT = """
88
9+
v436 -> 2026-01-06 : https://github.com/inventree/InvenTree/pull/11035
10+
- Removes model-specific metadata endpoints and replaces them with redirects
11+
- Adds new generic /api/metadata/<model_name>/ endpoint to retrieve metadata for any model
12+
913
v435 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11030
1014
- Adds token refresh endpoint to auth API
1115

src/backend/InvenTree/InvenTree/permissions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,25 @@ def has_object_permission(self, request, view, obj):
470470
)
471471

472472
return True
473+
474+
475+
class ContentTypePermission(OASTokenMixin, permissions.BasePermission):
476+
"""Mixin class for determining if the user has correct permissions."""
477+
478+
ENFORCE_USER_PERMS = True
479+
480+
def has_permission(self, request, view):
481+
"""Class level permission checks are handled via InvenTree.permissions.IsAuthenticatedOrReadScope."""
482+
return request.user and request.user.is_authenticated
483+
484+
def get_required_alternate_scopes(self, request, view):
485+
"""Return the required scopes for the current request."""
486+
return map_scope(roles=_roles)
487+
488+
def has_object_permission(self, request, view, obj):
489+
"""Check if the user has permission to access the object."""
490+
if model_class := obj.__class__:
491+
return users.permissions.check_user_permission(
492+
request.user, model_class, 'change'
493+
)
494+
return False

src/backend/InvenTree/build/api.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from build.status_codes import BuildStatus, BuildStatusGroups
2525
from data_exporter.mixins import DataExportViewMixin
2626
from generic.states.api import StatusView
27-
from InvenTree.api import BulkDeleteMixin, MetadataView, ParameterListMixin
27+
from InvenTree.api import BulkDeleteMixin, ParameterListMixin, meta_path
2828
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
2929
from InvenTree.filters import (
3030
SEARCH_ORDER_FILTER_ALIAS,
@@ -960,11 +960,7 @@ def get_queryset(self):
960960
path(
961961
'<int:pk>/',
962962
include([
963-
path(
964-
'metadata/',
965-
MetadataView.as_view(model=BuildItem),
966-
name='api-build-item-metadata',
967-
),
963+
meta_path(BuildItem),
968964
path('', BuildItemDetail.as_view(), name='api-build-item-detail'),
969965
]),
970966
),
@@ -1007,11 +1003,7 @@ def get_queryset(self):
10071003
path('finish/', BuildFinish.as_view(), name='api-build-finish'),
10081004
path('cancel/', BuildCancel.as_view(), name='api-build-cancel'),
10091005
path('unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
1010-
path(
1011-
'metadata/',
1012-
MetadataView.as_view(model=Build),
1013-
name='api-build-metadata',
1014-
),
1006+
meta_path(Build),
10151007
path('', BuildDetail.as_view(), name='api-build-detail'),
10161008
]),
10171009
),

src/backend/InvenTree/common/api.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@
3737
from common.settings import get_global_setting
3838
from data_exporter.mixins import DataExportViewMixin
3939
from generic.states.api import urlpattern as generic_states_api_urls
40-
from InvenTree.api import BulkCreateMixin, BulkDeleteMixin, MetadataView
40+
from InvenTree.api import (
41+
BulkCreateMixin,
42+
BulkDeleteMixin,
43+
GenericMetadataView,
44+
SimpleGenericMetadataView,
45+
meta_path,
46+
)
4147
from InvenTree.config import CONFIG_LOOKUPS
4248
from InvenTree.filters import (
4349
ORDER_FILTER,
@@ -1154,11 +1160,7 @@ def perform_create(self, serializer):
11541160
path(
11551161
'<int:pk>/',
11561162
include([
1157-
path(
1158-
'metadata/',
1159-
MetadataView.as_view(model=common.models.Attachment),
1160-
name='api-attachment-metadata',
1161-
),
1163+
meta_path(common.models.Attachment),
11621164
path('', AttachmentDetail.as_view(), name='api-attachment-detail'),
11631165
]),
11641166
),
@@ -1175,13 +1177,7 @@ def perform_create(self, serializer):
11751177
path(
11761178
'<int:pk>/',
11771179
include([
1178-
path(
1179-
'metadata/',
1180-
MetadataView.as_view(
1181-
model=common.models.ParameterTemplate
1182-
),
1183-
name='api-parameter-template-metadata',
1184-
),
1180+
meta_path(common.models.ParameterTemplate),
11851181
path(
11861182
'',
11871183
ParameterTemplateDetail.as_view(),
@@ -1199,11 +1195,7 @@ def perform_create(self, serializer):
11991195
path(
12001196
'<int:pk>/',
12011197
include([
1202-
path(
1203-
'metadata/',
1204-
MetadataView.as_view(model=common.models.Parameter),
1205-
name='api-parameter-metadata',
1206-
),
1198+
meta_path(common.models.Parameter),
12071199
path('', ParameterDetail.as_view(), name='api-parameter-detail'),
12081200
]),
12091201
),
@@ -1217,21 +1209,30 @@ def perform_create(self, serializer):
12171209
path('', ErrorMessageList.as_view(), name='api-error-list'),
12181210
]),
12191211
),
1212+
# Metadata
1213+
path(
1214+
'metadata/',
1215+
include([
1216+
path(
1217+
'<str:model>/<str:lookup_field>/<str:lookup_value>/',
1218+
GenericMetadataView.as_view(),
1219+
name='api-generic-metadata',
1220+
),
1221+
path(
1222+
'<str:model>/<int:pk>/',
1223+
SimpleGenericMetadataView.as_view(),
1224+
name='api-generic-metadata',
1225+
),
1226+
]),
1227+
),
12201228
# Project codes
12211229
path(
12221230
'project-code/',
12231231
include([
12241232
path(
12251233
'<int:pk>/',
12261234
include([
1227-
path(
1228-
'metadata/',
1229-
MetadataView.as_view(
1230-
model=common.models.ProjectCode,
1231-
permission_classes=[IsStaffOrReadOnlyScope],
1232-
),
1233-
name='api-project-code-metadata',
1234-
),
1235+
meta_path(common.models.ProjectCode),
12351236
path(
12361237
'', ProjectCodeDetail.as_view(), name='api-project-code-detail'
12371238
),

0 commit comments

Comments
 (0)