Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
228b86c
replace individual metadata endpoints with a generic endpoint an a lo…
matmair Dec 17, 2025
0af860b
remove more names
matmair Dec 17, 2025
e8b560c
reduce duplication more
matmair Dec 17, 2025
810fa54
remove now unneeded tests
matmair Dec 18, 2025
11f3efa
update remaining tests to use urls
matmair Dec 18, 2025
64d52d5
bump api
matmair Dec 18, 2025
63dd98c
follow redirects in tests
matmair Dec 18, 2025
e898e9d
Merge branch 'master' into reduce-metadata-endpoints
matmair Dec 19, 2025
48f16a7
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Dec 20, 2025
27edecb
reduce new fncs
matmair Dec 20, 2025
0ba68bb
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Dec 20, 2025
598aaa1
Merge branch 'reduce-metadata-endpoints' of https://github.com/matmai…
matmair Dec 21, 2025
0fafe23
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Dec 21, 2025
6c52ca6
fix redirect setup
matmair Dec 21, 2025
857a655
fix test
matmair Dec 21, 2025
8e8252c
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Dec 21, 2025
83de122
update to fix schema collissions
matmair Dec 21, 2025
eac6c78
fix permission check
matmair Dec 21, 2025
c76f4f5
simplify and fix lookup
matmair Dec 22, 2025
27c331a
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Jan 5, 2026
e8253ed
clone fork for now
matmair Jan 6, 2026
0fb74f7
add changelog entry
matmair Jan 6, 2026
2146287
update api version date
matmair Jan 6, 2026
7167f11
remove temporary change to python lib
matmair Jan 6, 2026
4e84469
update docs
matmair Jan 6, 2026
fa03f9f
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Jan 6, 2026
d61155d
Merge branch 'master' into reduce-metadata-endpoints
matmair Jan 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
Binary file not shown.
7 changes: 1 addition & 6 deletions docs/docs/plugins/metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<modelname>/<object id>/` endpoint to retrieve or update metadata information.

#### PUT vs PATCH

Expand Down
124 changes: 107 additions & 17 deletions src/backend/InvenTree/InvenTree/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
),
)
6 changes: 5 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
22 changes: 22 additions & 0 deletions src/backend/InvenTree/InvenTree/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 3 additions & 11 deletions src/backend/InvenTree/build/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -960,11 +960,7 @@ def get_queryset(self):
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'),
]),
),
Expand Down Expand Up @@ -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'),
]),
),
Expand Down
53 changes: 27 additions & 26 deletions src/backend/InvenTree/common/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1154,11 +1160,7 @@ def perform_create(self, serializer):
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'),
]),
),
Expand All @@ -1175,13 +1177,7 @@ def perform_create(self, serializer):
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(),
Expand All @@ -1199,11 +1195,7 @@ def perform_create(self, serializer):
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'),
]),
),
Expand All @@ -1217,21 +1209,30 @@ def perform_create(self, serializer):
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/',
include([
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'
),
Expand Down
Loading
Loading