mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-15 15:58:48 +00:00
refactor(backend): move various endpoints to modelviewsets (#11617)
* move to router based path building * fix url names * fix import * fix test helper * fix test * more test fixes * fix api response * remove url patch * bump api version * reduce diff * Fix version tag * fix schema generation errors * adjust texts * fix tests * add test for notification behavior / readakk * add todo for wrong endpoint * rename * adjust docstring * fix outstanding permission todo - this pattern should be changed in the API
This commit is contained in:
@@ -15,7 +15,7 @@ from django.views.generic.base import RedirectView
|
|||||||
import structlog
|
import structlog
|
||||||
from django_q.models import OrmQ
|
from django_q.models import OrmQ
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers, viewsets
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
from rest_framework.request import clone_request
|
from rest_framework.request import clone_request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@@ -625,12 +625,8 @@ class ParameterListMixin:
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class BulkDeleteMixin(BulkOperationMixin):
|
class CommonBulkDeleteMixin(BulkOperationMixin):
|
||||||
"""Mixin class for enabling 'bulk delete' operations for various models.
|
"""Helper for creating bulk delete operation on classic cbv and viewsets."""
|
||||||
|
|
||||||
Bulk delete allows for multiple items to be deleted in a single API query,
|
|
||||||
rather than using multiple API calls to the various detail endpoints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def validate_delete(self, queryset, request) -> None:
|
def validate_delete(self, queryset, request) -> None:
|
||||||
"""Perform validation right before deletion.
|
"""Perform validation right before deletion.
|
||||||
@@ -655,7 +651,7 @@ class BulkDeleteMixin(BulkOperationMixin):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@extend_schema(request=BulkRequestSerializer)
|
@extend_schema(request=BulkRequestSerializer)
|
||||||
def delete(self, request, *args, **kwargs):
|
def _delete(self, request, *args, **kwargs):
|
||||||
"""Perform a DELETE operation against this list endpoint.
|
"""Perform a DELETE operation against this list endpoint.
|
||||||
|
|
||||||
Note that the typical DRF list endpoint does not support DELETE,
|
Note that the typical DRF list endpoint does not support DELETE,
|
||||||
@@ -679,6 +675,37 @@ class BulkDeleteMixin(BulkOperationMixin):
|
|||||||
return Response({'success': f'Deleted {n_deleted} items'}, status=200)
|
return Response({'success': f'Deleted {n_deleted} items'}, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeleteMixin(CommonBulkDeleteMixin):
|
||||||
|
"""Mixin class for enabling 'bulk delete' operations for various models.
|
||||||
|
|
||||||
|
Bulk delete allows for multiple items to be deleted in a single API query,
|
||||||
|
rather than using multiple API calls to the various detail endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@extend_schema(request=BulkRequestSerializer)
|
||||||
|
def delete(self, request, *args, **kwargs):
|
||||||
|
"""Perform a DELETE operation against this list endpoint.
|
||||||
|
|
||||||
|
Note that the typical DRF list endpoint does not support DELETE,
|
||||||
|
so this method is provided as a custom implementation.
|
||||||
|
"""
|
||||||
|
return self._delete(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeleteViewsetMixin(CommonBulkDeleteMixin, viewsets.GenericViewSet):
|
||||||
|
"""Mixin class for enabling 'bulk delete' operations for viewsets."""
|
||||||
|
|
||||||
|
@extend_schema(request=BulkRequestSerializer)
|
||||||
|
def bulk_delete(self, request, *args, **kwargs):
|
||||||
|
"""Perform a bulk delete operation.
|
||||||
|
|
||||||
|
Provide either a list of ids (via `items`) or a filter (via `filters`) to select the items to be deleted.
|
||||||
|
|
||||||
|
This action is performed attomically, so either all items will be deleted, or none will be deleted.
|
||||||
|
"""
|
||||||
|
return self._delete(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ListCreateDestroyAPIView(BulkDeleteMixin, ListCreateAPI):
|
class ListCreateDestroyAPIView(BulkDeleteMixin, ListCreateAPI):
|
||||||
"""Custom API endpoint which provides BulkDelete functionality in addition to List and Create."""
|
"""Custom API endpoint which provides BulkDelete functionality in addition to List and Create."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 476
|
INVENTREE_API_VERSION = 477
|
||||||
"""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 = """
|
||||||
|
|
||||||
|
v477 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11617
|
||||||
|
- Non-functional refactor, adaptations of descriptions
|
||||||
|
|
||||||
v476 -> 2026-04-09 : https://github.com/inventree/InvenTree/pull/11705
|
v476 -> 2026-04-09 : https://github.com/inventree/InvenTree/pull/11705
|
||||||
- Adds sorting / filtering / searching functionality to the SelectionListEntry API endpoint
|
- Adds sorting / filtering / searching functionality to the SelectionListEntry API endpoint
|
||||||
|
|
||||||
|
|||||||
49
src/backend/InvenTree/InvenTree/helpers_api.py
Normal file
49
src/backend/InvenTree/InvenTree/helpers_api.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Helpers for InvenTrees way of using drf viewset."""
|
||||||
|
|
||||||
|
from rest_framework import mixins, routers, viewsets
|
||||||
|
|
||||||
|
from InvenTree.api import BulkDeleteViewsetMixin
|
||||||
|
|
||||||
|
|
||||||
|
class RetrieveUpdateDestroyModelViewSet(
|
||||||
|
mixins.RetrieveModelMixin,
|
||||||
|
mixins.UpdateModelMixin,
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
|
mixins.ListModelMixin,
|
||||||
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
|
"""Viewset which provides 'retrieve', 'update', 'destroy' and 'list' actions."""
|
||||||
|
|
||||||
|
|
||||||
|
class RetrieveDestroyModelViewSet(
|
||||||
|
mixins.RetrieveModelMixin,
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
|
mixins.ListModelMixin,
|
||||||
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
|
"""Viewset which provides 'retrieve', 'destroy' and 'list' actions."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvenTreeApiRouter(routers.SimpleRouter):
|
||||||
|
"""Custom router which adds various specific functions.
|
||||||
|
|
||||||
|
Currently adds the following features:
|
||||||
|
- support for bulk delete operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_routes(self, viewset):
|
||||||
|
"""Override the default get_routes method to add bulk delete support."""
|
||||||
|
routes = super().get_routes(viewset)
|
||||||
|
|
||||||
|
if issubclass(viewset, BulkDeleteViewsetMixin):
|
||||||
|
list_route = next(
|
||||||
|
(route for route in routes if route.mapping.get('get') == 'list'), None
|
||||||
|
)
|
||||||
|
list_route.mapping['delete'] = 'bulk_delete'
|
||||||
|
|
||||||
|
return routes
|
||||||
|
|
||||||
|
def get_default_basename(self, viewset):
|
||||||
|
"""Extract the default base name from the viewset."""
|
||||||
|
basename = super().get_default_basename(viewset)
|
||||||
|
return 'api-' + basename
|
||||||
@@ -55,9 +55,17 @@ class ExtendedAutoSchema(AutoSchema):
|
|||||||
result_id = super().get_operation_id()
|
result_id = super().get_operation_id()
|
||||||
|
|
||||||
# rename bulk actions to deconflict with single action operation_id
|
# rename bulk actions to deconflict with single action operation_id
|
||||||
if (self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin')) or (
|
if (
|
||||||
(self.method == 'PUT' or self.method == 'PATCH')
|
(self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin'))
|
||||||
and self.is_bulk_action('BulkUpdateMixin')
|
or (
|
||||||
|
self.method == 'DELETE'
|
||||||
|
and self.is_bulk_action('BulkDeleteViewsetMixin')
|
||||||
|
and self.view.action == 'bulk_delete'
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
(self.method == 'PUT' or self.method == 'PATCH')
|
||||||
|
and self.is_bulk_action('BulkUpdateMixin')
|
||||||
|
)
|
||||||
):
|
):
|
||||||
action = self.method_mapping[self.method.lower()]
|
action = self.method_mapping[self.method.lower()]
|
||||||
result_id = result_id.replace(action, 'bulk_' + action)
|
result_id = result_id.replace(action, 'bulk_' + action)
|
||||||
@@ -81,7 +89,11 @@ class ExtendedAutoSchema(AutoSchema):
|
|||||||
|
|
||||||
# drf-spectacular doesn't support a body on DELETE endpoints because the semantics are not well-defined and
|
# drf-spectacular doesn't support a body on DELETE endpoints because the semantics are not well-defined and
|
||||||
# OpenAPI recommends against it. This allows us to generate a schema that follows existing behavior.
|
# OpenAPI recommends against it. This allows us to generate a schema that follows existing behavior.
|
||||||
if self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin'):
|
if (self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin')) or (
|
||||||
|
self.method == 'DELETE'
|
||||||
|
and getattr(self.view, 'action', None) == 'bulk_delete'
|
||||||
|
and self.is_bulk_action('BulkDeleteViewsetMixin')
|
||||||
|
):
|
||||||
original_method = self.method
|
original_method = self.method
|
||||||
self.method = 'PUT'
|
self.method = 'PUT'
|
||||||
request_body = self._get_request_body()
|
request_body = self._get_request_body()
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ import django_q.models
|
|||||||
import django_q.tasks
|
import django_q.tasks
|
||||||
from django_filters.rest_framework.filterset import FilterSet
|
from django_filters.rest_framework.filterset import FilterSet
|
||||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
from drf_spectacular.utils import (
|
||||||
|
OpenApiParameter,
|
||||||
|
OpenApiResponse,
|
||||||
|
extend_schema,
|
||||||
|
extend_schema_view,
|
||||||
|
)
|
||||||
from error_report.models import Error
|
from error_report.models import Error
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
from pint._typing import UnitLike
|
from pint._typing import UnitLike
|
||||||
from rest_framework import generics, serializers
|
from rest_framework import serializers, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
|
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
|
||||||
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@@ -44,6 +50,7 @@ from generic.states.api import urlpattern as generic_states_api_urls
|
|||||||
from InvenTree.api import (
|
from InvenTree.api import (
|
||||||
BulkCreateMixin,
|
BulkCreateMixin,
|
||||||
BulkDeleteMixin,
|
BulkDeleteMixin,
|
||||||
|
BulkDeleteViewsetMixin,
|
||||||
GenericMetadataView,
|
GenericMetadataView,
|
||||||
SimpleGenericMetadataView,
|
SimpleGenericMetadataView,
|
||||||
meta_path,
|
meta_path,
|
||||||
@@ -51,6 +58,11 @@ from InvenTree.api import (
|
|||||||
from InvenTree.config import CONFIG_LOOKUPS
|
from InvenTree.config import CONFIG_LOOKUPS
|
||||||
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
||||||
from InvenTree.helpers import inheritors, str2bool
|
from InvenTree.helpers import inheritors, str2bool
|
||||||
|
from InvenTree.helpers_api import (
|
||||||
|
InvenTreeApiRouter,
|
||||||
|
RetrieveDestroyModelViewSet,
|
||||||
|
RetrieveUpdateDestroyModelViewSet,
|
||||||
|
)
|
||||||
from InvenTree.helpers_email import send_email
|
from InvenTree.helpers_email import send_email
|
||||||
from InvenTree.mixins import (
|
from InvenTree.mixins import (
|
||||||
CreateAPI,
|
CreateAPI,
|
||||||
@@ -58,7 +70,6 @@ from InvenTree.mixins import (
|
|||||||
ListCreateAPI,
|
ListCreateAPI,
|
||||||
OutputOptionsMixin,
|
OutputOptionsMixin,
|
||||||
RetrieveAPI,
|
RetrieveAPI,
|
||||||
RetrieveDestroyAPI,
|
|
||||||
RetrieveUpdateAPI,
|
RetrieveUpdateAPI,
|
||||||
RetrieveUpdateDestroyAPI,
|
RetrieveUpdateDestroyAPI,
|
||||||
)
|
)
|
||||||
@@ -71,6 +82,10 @@ from InvenTree.permissions import (
|
|||||||
IsSuperuserOrSuperScope,
|
IsSuperuserOrSuperScope,
|
||||||
UserSettingsPermissionsOrScope,
|
UserSettingsPermissionsOrScope,
|
||||||
)
|
)
|
||||||
|
from InvenTree.serializers import EmptySerializer
|
||||||
|
|
||||||
|
admin_router = InvenTreeApiRouter()
|
||||||
|
common_router = InvenTreeApiRouter()
|
||||||
|
|
||||||
|
|
||||||
class CsrfExemptMixin:
|
class CsrfExemptMixin:
|
||||||
@@ -155,14 +170,18 @@ class WebhookView(CsrfExemptMixin, APIView):
|
|||||||
raise NotFound()
|
raise NotFound()
|
||||||
|
|
||||||
|
|
||||||
class CurrencyExchangeView(APIView):
|
class CurrencyViewSet(viewsets.GenericViewSet):
|
||||||
"""API endpoint for displaying currency information."""
|
"""Viewset for currency exchange information."""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticatedOrReadScope]
|
permission_classes = [IsAuthenticatedOrReadScope]
|
||||||
serializer_class = None
|
serializer_class = EmptySerializer
|
||||||
|
|
||||||
@extend_schema(responses={200: common.serializers.CurrencyExchangeSerializer})
|
@action(
|
||||||
def get(self, request, fmt=None):
|
detail=False,
|
||||||
|
methods=['get'],
|
||||||
|
serializer_class=common.serializers.CurrencyExchangeSerializer,
|
||||||
|
)
|
||||||
|
def exchange(self, request, fmt=None):
|
||||||
"""Return information on available currency conversions."""
|
"""Return information on available currency conversions."""
|
||||||
# Extract a list of all available rates
|
# Extract a list of all available rates
|
||||||
try:
|
try:
|
||||||
@@ -195,17 +214,12 @@ class CurrencyExchangeView(APIView):
|
|||||||
|
|
||||||
return Response(response)
|
return Response(response)
|
||||||
|
|
||||||
|
@action(
|
||||||
class CurrencyRefreshView(APIView):
|
detail=False,
|
||||||
"""API endpoint for manually refreshing currency exchange rates.
|
methods=['post'],
|
||||||
|
permission_classes=[IsAuthenticatedOrReadScope, IsAdminUser],
|
||||||
User must be a 'staff' user to access this endpoint
|
)
|
||||||
"""
|
def refresh(self, request, *args, **kwargs):
|
||||||
|
|
||||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
|
||||||
serializer_class = None
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
"""Performing a POST request will update currency exchange rates."""
|
"""Performing a POST request will update currency exchange rates."""
|
||||||
from InvenTree.tasks import update_exchange_rates
|
from InvenTree.tasks import update_exchange_rates
|
||||||
|
|
||||||
@@ -214,6 +228,9 @@ class CurrencyRefreshView(APIView):
|
|||||||
return Response({'success': 'Exchange rates updated'})
|
return Response({'success': 'Exchange rates updated'})
|
||||||
|
|
||||||
|
|
||||||
|
common_router.register('currency', CurrencyViewSet, basename='api-currency')
|
||||||
|
|
||||||
|
|
||||||
class SettingsList(ListAPI):
|
class SettingsList(ListAPI):
|
||||||
"""Generic ListView for settings.
|
"""Generic ListView for settings.
|
||||||
|
|
||||||
@@ -325,13 +342,23 @@ class UserSettingsDetail(RetrieveUpdateAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NotificationMessageMixin:
|
class NotificationMessageViewSet(
|
||||||
"""Generic mixin for NotificationMessage."""
|
BulkDeleteViewsetMixin, RetrieveUpdateDestroyModelViewSet
|
||||||
|
):
|
||||||
|
"""Notifications for the current user.
|
||||||
|
|
||||||
|
- User can only view / delete their own notification objects
|
||||||
|
"""
|
||||||
|
|
||||||
queryset = common.models.NotificationMessage.objects.all()
|
queryset = common.models.NotificationMessage.objects.all()
|
||||||
serializer_class = common.serializers.NotificationMessageSerializer
|
serializer_class = common.serializers.NotificationMessageSerializer
|
||||||
permission_classes = [UserSettingsPermissionsOrScope]
|
permission_classes = [UserSettingsPermissionsOrScope]
|
||||||
|
|
||||||
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
ordering_fields = ['category', 'name', 'read', 'creation']
|
||||||
|
search_fields = ['name', 'message']
|
||||||
|
filterset_fields = ['category', 'read']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return prefetched queryset."""
|
"""Return prefetched queryset."""
|
||||||
queryset = (
|
queryset = (
|
||||||
@@ -348,20 +375,6 @@ class NotificationMessageMixin:
|
|||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
|
|
||||||
"""List view for all notifications of the current user."""
|
|
||||||
|
|
||||||
permission_classes = [IsAuthenticatedOrReadScope]
|
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
|
||||||
|
|
||||||
ordering_fields = ['category', 'name', 'read', 'creation']
|
|
||||||
|
|
||||||
search_fields = ['name', 'message']
|
|
||||||
|
|
||||||
filterset_fields = ['category', 'read']
|
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""Only list notifications which apply to the current user."""
|
"""Only list notifications which apply to the current user."""
|
||||||
try:
|
try:
|
||||||
@@ -382,18 +395,23 @@ class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
|
|||||||
queryset = queryset.filter(user=request.user)
|
queryset = queryset.filter(user=request.user)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
"""Override permissions for list view."""
|
||||||
|
if self.action == 'list':
|
||||||
|
return [IsAuthenticatedOrReadScope()]
|
||||||
|
else:
|
||||||
|
return super().get_permissions()
|
||||||
|
|
||||||
class NotificationDetail(NotificationMessageMixin, RetrieveUpdateDestroyAPI):
|
def list(self, request, *args, **kwargs):
|
||||||
"""Detail view for an individual notification object.
|
"""List view for all notifications of the current user."""
|
||||||
|
# TODO @matmair permissions for this are currently being overwritten in get_permissions - this should be moved to a dedicated endpoint
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
- User can only view / delete their own notification objects
|
# TODO @matmair this should really be a POST
|
||||||
"""
|
@action(
|
||||||
|
detail=False, methods=['get'], permission_classes=[IsAuthenticatedOrReadScope]
|
||||||
|
)
|
||||||
class NotificationReadAll(NotificationMessageMixin, RetrieveAPI):
|
def readall(self, request, *args, **kwargs):
|
||||||
"""API endpoint to mark all notifications as read."""
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
"""Set all messages for the current user as read."""
|
"""Set all messages for the current user as read."""
|
||||||
try:
|
try:
|
||||||
self.queryset.filter(user=request.user, read=False).update(read=True)
|
self.queryset.filter(user=request.user, read=False).update(read=True)
|
||||||
@@ -404,47 +422,50 @@ class NotificationReadAll(NotificationMessageMixin, RetrieveAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NewsFeedMixin:
|
common_router.register(
|
||||||
"""Generic mixin for NewsFeedEntry."""
|
'notifications', NotificationMessageViewSet, basename='api-notifications'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsFeedViewSet(BulkDeleteViewsetMixin, RetrieveUpdateDestroyModelViewSet):
|
||||||
|
"""Newsfeed from the official inventree.org website."""
|
||||||
|
|
||||||
queryset = common.models.NewsFeedEntry.objects.all()
|
queryset = common.models.NewsFeedEntry.objects.all()
|
||||||
serializer_class = common.serializers.NewsFeedEntrySerializer
|
serializer_class = common.serializers.NewsFeedEntrySerializer
|
||||||
permission_classes = [IsAdminOrAdminScope]
|
permission_classes = [IsAdminOrAdminScope]
|
||||||
|
|
||||||
|
|
||||||
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
|
|
||||||
"""List view for all news items."""
|
|
||||||
|
|
||||||
filter_backends = ORDER_FILTER
|
filter_backends = ORDER_FILTER
|
||||||
|
|
||||||
ordering = '-published'
|
ordering = '-published'
|
||||||
|
|
||||||
ordering_fields = ['published', 'author', 'read']
|
ordering_fields = ['published', 'author', 'read']
|
||||||
|
|
||||||
filterset_fields = ['read']
|
filterset_fields = ['read']
|
||||||
|
|
||||||
|
|
||||||
class NewsFeedEntryDetail(NewsFeedMixin, RetrieveUpdateDestroyAPI):
|
common_router.register('news', NewsFeedViewSet, basename='api-news')
|
||||||
"""Detail view for an individual news feed object."""
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigList(ListAPI):
|
@extend_schema_view(
|
||||||
"""List view for all accessed configurations."""
|
retrieve=extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name='key',
|
||||||
|
description='Unique identifier for this configuration',
|
||||||
|
required=True,
|
||||||
|
location=OpenApiParameter.PATH,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
class ConfigViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""All accessed/in-use configurations."""
|
||||||
|
|
||||||
queryset = CONFIG_LOOKUPS
|
queryset = CONFIG_LOOKUPS
|
||||||
serializer_class = common.serializers.ConfigSerializer
|
serializer_class = common.serializers.ConfigSerializer
|
||||||
permission_classes = [IsSuperuserOrSuperScope]
|
permission_classes = [IsSuperuserOrSuperScope]
|
||||||
|
lookup_field = 'key'
|
||||||
|
|
||||||
# Specifically disable pagination for this view
|
# Specifically disable pagination for this view
|
||||||
pagination_class = None
|
pagination_class = None
|
||||||
|
|
||||||
|
|
||||||
class ConfigDetail(RetrieveAPI):
|
|
||||||
"""Detail view for an individual configuration."""
|
|
||||||
|
|
||||||
serializer_class = common.serializers.ConfigSerializer
|
|
||||||
permission_classes = [IsSuperuserOrSuperScope]
|
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
"""Attempt to find a config object with the provided key."""
|
"""Attempt to find a config object with the provided key."""
|
||||||
key = self.kwargs['key']
|
key = self.kwargs['key']
|
||||||
@@ -454,6 +475,9 @@ class ConfigDetail(RetrieveAPI):
|
|||||||
return {key: value}
|
return {key: value}
|
||||||
|
|
||||||
|
|
||||||
|
admin_router.register('config', ConfigViewSet, basename='api-config')
|
||||||
|
|
||||||
|
|
||||||
class NotesImageList(ListCreateAPI):
|
class NotesImageList(ListCreateAPI):
|
||||||
"""List view for all notes images."""
|
"""List view for all notes images."""
|
||||||
|
|
||||||
@@ -493,7 +517,7 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
|
|||||||
permission_classes = [IsStaffOrReadOnlyScope]
|
permission_classes = [IsStaffOrReadOnlyScope]
|
||||||
|
|
||||||
|
|
||||||
class CustomUnitList(DataExportViewMixin, ListCreateAPI):
|
class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet):
|
||||||
"""List view for custom units."""
|
"""List view for custom units."""
|
||||||
|
|
||||||
queryset = common.models.CustomUnit.objects.all()
|
queryset = common.models.CustomUnit.objects.all()
|
||||||
@@ -501,22 +525,12 @@ class CustomUnitList(DataExportViewMixin, ListCreateAPI):
|
|||||||
permission_classes = [IsStaffOrReadOnlyScope]
|
permission_classes = [IsStaffOrReadOnlyScope]
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
|
@action(
|
||||||
class CustomUnitDetail(RetrieveUpdateDestroyAPI):
|
detail=False,
|
||||||
"""Detail view for a particular custom unit."""
|
methods=['get'],
|
||||||
|
serializer_class=common.serializers.AllUnitListResponseSerializer,
|
||||||
queryset = common.models.CustomUnit.objects.all()
|
)
|
||||||
serializer_class = common.serializers.CustomUnitSerializer
|
def all(self, request, *args, **kwargs):
|
||||||
permission_classes = [IsStaffOrReadOnlyScope]
|
|
||||||
|
|
||||||
|
|
||||||
class AllUnitList(RetrieveAPI):
|
|
||||||
"""List of all defined units."""
|
|
||||||
|
|
||||||
serializer_class = common.serializers.AllUnitListResponseSerializer
|
|
||||||
permission_classes = [IsStaffOrReadOnlyScope]
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
"""Return a list of all available units."""
|
"""Return a list of all available units."""
|
||||||
reg = InvenTree.conversion.get_unit_registry()
|
reg = InvenTree.conversion.get_unit_registry()
|
||||||
all_units = {k: self.get_unit(reg, k) for k in reg}
|
all_units = {k: self.get_unit(reg, k) for k in reg}
|
||||||
@@ -540,7 +554,10 @@ class AllUnitList(RetrieveAPI):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ErrorMessageList(BulkDeleteMixin, ListAPI):
|
common_router.register('units', CustomUnitViewset, basename='api-custom-unit')
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorMessageViewSet(BulkDeleteViewsetMixin, RetrieveUpdateDestroyModelViewSet):
|
||||||
"""List view for server error messages."""
|
"""List view for server error messages."""
|
||||||
|
|
||||||
queryset = Error.objects.all()
|
queryset = Error.objects.all()
|
||||||
@@ -556,12 +573,7 @@ class ErrorMessageList(BulkDeleteMixin, ListAPI):
|
|||||||
search_fields = ['info', 'data']
|
search_fields = ['info', 'data']
|
||||||
|
|
||||||
|
|
||||||
class ErrorMessageDetail(RetrieveUpdateDestroyAPI):
|
common_router.register('error-report', ErrorMessageViewSet, basename='api-error')
|
||||||
"""Detail view for a single error message."""
|
|
||||||
|
|
||||||
queryset = Error.objects.all()
|
|
||||||
serializer_class = common.serializers.ErrorMessageSerializer
|
|
||||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
|
||||||
|
|
||||||
|
|
||||||
class BackgroundTaskDetail(APIView):
|
class BackgroundTaskDetail(APIView):
|
||||||
@@ -1179,13 +1191,17 @@ class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
|
|||||||
"""Detail view for a SelectionEntry object."""
|
"""Detail view for a SelectionEntry object."""
|
||||||
|
|
||||||
|
|
||||||
class DataOutputEndpointMixin:
|
class DataOutputViewSet(BulkDeleteViewsetMixin, RetrieveDestroyModelViewSet):
|
||||||
"""Mixin class for DataOutput endpoints."""
|
"""Mixin class for DataOutput endpoints."""
|
||||||
|
|
||||||
queryset = common.models.DataOutput.objects.all()
|
queryset = common.models.DataOutput.objects.all()
|
||||||
serializer_class = common.serializers.DataOutputSerializer
|
serializer_class = common.serializers.DataOutputSerializer
|
||||||
permission_classes = [IsAuthenticatedOrReadScope]
|
permission_classes = [IsAuthenticatedOrReadScope]
|
||||||
|
|
||||||
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
|
||||||
|
filterset_fields = ['user']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return the set of DataOutput objects which the user has permission to view."""
|
"""Return the set of DataOutput objects which the user has permission to view."""
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
@@ -1203,29 +1219,16 @@ class DataOutputEndpointMixin:
|
|||||||
return queryset.filter(user=user)
|
return queryset.filter(user=user)
|
||||||
|
|
||||||
|
|
||||||
class DataOutputList(DataOutputEndpointMixin, BulkDeleteMixin, ListAPI):
|
common_router.register('data-output', DataOutputViewSet, basename='api-data-output')
|
||||||
"""List view for DataOutput objects."""
|
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
|
||||||
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
|
|
||||||
filterset_fields = ['user']
|
|
||||||
|
|
||||||
|
|
||||||
class DataOutputDetail(DataOutputEndpointMixin, generics.DestroyAPIView, RetrieveAPI):
|
class EmailViewSet(BulkDeleteViewsetMixin, RetrieveDestroyModelViewSet):
|
||||||
"""Detail view for a DataOutput object."""
|
"""Backend E-Mail management for administrative purposes."""
|
||||||
|
|
||||||
|
|
||||||
class EmailMessageMixin:
|
|
||||||
"""Mixin class for Email endpoints."""
|
|
||||||
|
|
||||||
queryset = common.models.EmailMessage.objects.all()
|
queryset = common.models.EmailMessage.objects.all()
|
||||||
serializer_class = common.serializers.EmailMessageSerializer
|
serializer_class = common.serializers.EmailMessageSerializer
|
||||||
permission_classes = [IsSuperuserOrSuperScope]
|
permission_classes = [IsSuperuserOrSuperScope]
|
||||||
|
|
||||||
|
|
||||||
class EmailMessageList(EmailMessageMixin, BulkDeleteMixin, ListAPI):
|
|
||||||
"""List view for email objects."""
|
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'created',
|
'created',
|
||||||
@@ -1245,19 +1248,17 @@ class EmailMessageList(EmailMessageMixin, BulkDeleteMixin, ListAPI):
|
|||||||
'thread_id_key',
|
'thread_id_key',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@extend_schema(responses={201: common.serializers.TestEmailSerializer})
|
||||||
class EmailMessageDetail(EmailMessageMixin, RetrieveDestroyAPI):
|
@action(
|
||||||
"""Detail view for an email object."""
|
detail=False,
|
||||||
|
methods=['post'],
|
||||||
|
serializer_class=common.serializers.TestEmailSerializer,
|
||||||
class TestEmail(CreateAPI):
|
)
|
||||||
"""Send a test email."""
|
def test(self, request):
|
||||||
|
|
||||||
serializer_class = common.serializers.TestEmailSerializer
|
|
||||||
permission_classes = [IsSuperuserOrSuperScope]
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
"""Send a test email."""
|
"""Send a test email."""
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
data = serializer.validated_data
|
data = serializer.validated_data
|
||||||
|
|
||||||
delivered, reason = send_email(
|
delivered, reason = send_email(
|
||||||
@@ -1269,6 +1270,11 @@ class TestEmail(CreateAPI):
|
|||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
detail=f'Failed to send test email: "{reason}"'
|
detail=f'Failed to send test email: "{reason}"'
|
||||||
) # pragma: no cover
|
) # pragma: no cover
|
||||||
|
# TODO @matmair - breaking change: this should be a 200
|
||||||
|
return Response(serializer.data, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
admin_router.register('email', EmailViewSet, basename='api-email')
|
||||||
|
|
||||||
|
|
||||||
class HealthCheckStatusSerializer(serializers.Serializer):
|
class HealthCheckStatusSerializer(serializers.Serializer):
|
||||||
@@ -1491,13 +1497,6 @@ common_api_urls = [
|
|||||||
path('', ParameterList.as_view(), name='api-parameter-list'),
|
path('', ParameterList.as_view(), name='api-parameter-list'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
'error-report/',
|
|
||||||
include([
|
|
||||||
path('<int:pk>/', ErrorMessageDetail.as_view(), name='api-error-detail'),
|
|
||||||
path('', ErrorMessageList.as_view(), name='api-error-list'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# Metadata
|
# Metadata
|
||||||
path(
|
path(
|
||||||
'metadata/',
|
'metadata/',
|
||||||
@@ -1530,72 +1529,6 @@ common_api_urls = [
|
|||||||
path('', ProjectCodeList.as_view(), name='api-project-code-list'),
|
path('', ProjectCodeList.as_view(), name='api-project-code-list'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
# Custom physical units
|
|
||||||
path(
|
|
||||||
'units/',
|
|
||||||
include([
|
|
||||||
path(
|
|
||||||
'<int:pk>/',
|
|
||||||
include([
|
|
||||||
path('', CustomUnitDetail.as_view(), name='api-custom-unit-detail')
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
path('all/', AllUnitList.as_view(), name='api-all-unit-list'),
|
|
||||||
path('', CustomUnitList.as_view(), name='api-custom-unit-list'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# Currencies
|
|
||||||
path(
|
|
||||||
'currency/',
|
|
||||||
include([
|
|
||||||
path(
|
|
||||||
'exchange/',
|
|
||||||
CurrencyExchangeView.as_view(),
|
|
||||||
name='api-currency-exchange',
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
'refresh/', CurrencyRefreshView.as_view(), name='api-currency-refresh'
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# Notifications
|
|
||||||
path(
|
|
||||||
'notifications/',
|
|
||||||
include([
|
|
||||||
# Individual purchase order detail URLs
|
|
||||||
path(
|
|
||||||
'<int:pk>/',
|
|
||||||
include([
|
|
||||||
path(
|
|
||||||
'',
|
|
||||||
NotificationDetail.as_view(),
|
|
||||||
name='api-notifications-detail',
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# Read all
|
|
||||||
path(
|
|
||||||
'readall/',
|
|
||||||
NotificationReadAll.as_view(),
|
|
||||||
name='api-notifications-readall',
|
|
||||||
),
|
|
||||||
# Notification messages list
|
|
||||||
path('', NotificationList.as_view(), name='api-notifications-list'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# News
|
|
||||||
path(
|
|
||||||
'news/',
|
|
||||||
include([
|
|
||||||
path(
|
|
||||||
'<int:pk>/',
|
|
||||||
include([
|
|
||||||
path('', NewsFeedEntryDetail.as_view(), name='api-news-detail')
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
path('', NewsFeedEntryList.as_view(), name='api-news-list'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# Flags
|
# Flags
|
||||||
path(
|
path(
|
||||||
'flags/',
|
'flags/',
|
||||||
@@ -1625,16 +1558,6 @@ common_api_urls = [
|
|||||||
path('icons/', IconList.as_view(), name='api-icon-list'),
|
path('icons/', IconList.as_view(), name='api-icon-list'),
|
||||||
# Selection lists
|
# Selection lists
|
||||||
path('selection/', include(selection_urls)),
|
path('selection/', include(selection_urls)),
|
||||||
# Data output
|
|
||||||
path(
|
|
||||||
'data-output/',
|
|
||||||
include([
|
|
||||||
path(
|
|
||||||
'<int:pk>/', DataOutputDetail.as_view(), name='api-data-output-detail'
|
|
||||||
),
|
|
||||||
path('', DataOutputList.as_view(), name='api-data-output-list'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
# System APIs (related to basic system functions)
|
# System APIs (related to basic system functions)
|
||||||
path(
|
path(
|
||||||
'system/',
|
'system/',
|
||||||
@@ -1655,19 +1578,8 @@ common_api_urls = [
|
|||||||
)
|
)
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
# Router
|
||||||
|
path('', include(common_router.urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
admin_api_urls = [
|
admin_api_urls = admin_router.urls
|
||||||
# Admin
|
|
||||||
path('config/', ConfigList.as_view(), name='api-config-list'),
|
|
||||||
path('config/<str:key>/', ConfigDetail.as_view(), name='api-config-detail'),
|
|
||||||
# Email
|
|
||||||
path(
|
|
||||||
'email/',
|
|
||||||
include([
|
|
||||||
path('test/', TestEmail.as_view(), name='api-email-test'),
|
|
||||||
path('<str:pk>/', EmailMessageDetail.as_view(), name='api-email-detail'),
|
|
||||||
path('', EmailMessageList.as_view(), name='api-email-list'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1304,12 +1304,41 @@ class NotificationTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.data['description'],
|
response.data['description'],
|
||||||
'List view for all notifications of the current user.',
|
'Notifications for the current user.\n\n- User can only view / delete their own notification objects',
|
||||||
)
|
)
|
||||||
|
|
||||||
# POST action should fail (not allowed)
|
# POST action should fail (not allowed)
|
||||||
response = self.post(url, {}, expected_code=405)
|
response = self.post(url, {}, expected_code=405)
|
||||||
|
|
||||||
|
def test_api_read(self):
|
||||||
|
"""Test that NotificationMessage can be marked as read."""
|
||||||
|
# Create a notification message
|
||||||
|
NotificationMessage.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
category='test',
|
||||||
|
message='This is a test notification',
|
||||||
|
target_object=self.user,
|
||||||
|
)
|
||||||
|
user2 = get_user_model().objects.get(pk=2)
|
||||||
|
NotificationMessage.objects.create(
|
||||||
|
user=user2,
|
||||||
|
category='test',
|
||||||
|
message='This is a second test notification',
|
||||||
|
target_object=user2,
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse('api-notifications-list')
|
||||||
|
self.assertEqual(NotificationMessage.objects.filter(read=True).count(), 0)
|
||||||
|
self.assertEqual(len(self.get(url, expected_code=200).data), 1)
|
||||||
|
|
||||||
|
# Read with readall endpoint
|
||||||
|
self.get(reverse('api-notifications-readall'), {}, expected_code=200)
|
||||||
|
|
||||||
|
self.assertEqual(NotificationMessage.objects.filter(read=True).count(), 1)
|
||||||
|
self.assertEqual(len(self.get(url, expected_code=200).data), 1)
|
||||||
|
# filtered by read status should be 0
|
||||||
|
self.assertEqual(len(self.get(url, {'read': False}, expected_code=200).data), 0)
|
||||||
|
|
||||||
def test_bulk_delete(self):
|
def test_bulk_delete(self):
|
||||||
"""Tests for bulk deletion of user notifications."""
|
"""Tests for bulk deletion of user notifications."""
|
||||||
from error_report.models import Error
|
from error_report.models import Error
|
||||||
@@ -1835,7 +1864,7 @@ class CustomUnitAPITest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
def test_api(self):
|
def test_api(self):
|
||||||
"""Test the CustomUnit API."""
|
"""Test the CustomUnit API."""
|
||||||
response = self.get(reverse('api-all-unit-list'))
|
response = self.get(reverse('api-custom-unit-all'))
|
||||||
self.assertIn('default_system', response.data)
|
self.assertIn('default_system', response.data)
|
||||||
self.assertIn('available_systems', response.data)
|
self.assertIn('available_systems', response.data)
|
||||||
self.assertIn('available_units', response.data)
|
self.assertIn('available_units', response.data)
|
||||||
|
|||||||
Reference in New Issue
Block a user