mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-15 07:48:51 +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
|
||||
from django_q.models import OrmQ
|
||||
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.request import clone_request
|
||||
from rest_framework.response import Response
|
||||
@@ -625,12 +625,8 @@ class ParameterListMixin:
|
||||
return queryset
|
||||
|
||||
|
||||
class BulkDeleteMixin(BulkOperationMixin):
|
||||
"""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.
|
||||
"""
|
||||
class CommonBulkDeleteMixin(BulkOperationMixin):
|
||||
"""Helper for creating bulk delete operation on classic cbv and viewsets."""
|
||||
|
||||
def validate_delete(self, queryset, request) -> None:
|
||||
"""Perform validation right before deletion.
|
||||
@@ -655,7 +651,7 @@ class BulkDeleteMixin(BulkOperationMixin):
|
||||
return queryset
|
||||
|
||||
@extend_schema(request=BulkRequestSerializer)
|
||||
def delete(self, request, *args, **kwargs):
|
||||
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,
|
||||
@@ -679,6 +675,37 @@ class BulkDeleteMixin(BulkOperationMixin):
|
||||
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):
|
||||
"""Custom API endpoint which provides BulkDelete functionality in addition to List and Create."""
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# 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."""
|
||||
|
||||
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
|
||||
- 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()
|
||||
|
||||
# rename bulk actions to deconflict with single action operation_id
|
||||
if (self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin')) or (
|
||||
(self.method == 'PUT' or self.method == 'PATCH')
|
||||
and self.is_bulk_action('BulkUpdateMixin')
|
||||
if (
|
||||
(self.method == 'DELETE' and self.is_bulk_action('BulkDeleteMixin'))
|
||||
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()]
|
||||
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
|
||||
# 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
|
||||
self.method = 'PUT'
|
||||
request_body = self._get_request_body()
|
||||
|
||||
@@ -20,11 +20,17 @@ import django_q.models
|
||||
import django_q.tasks
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
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 opentelemetry import trace
|
||||
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.permissions import IsAdminUser, IsAuthenticated
|
||||
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 (
|
||||
BulkCreateMixin,
|
||||
BulkDeleteMixin,
|
||||
BulkDeleteViewsetMixin,
|
||||
GenericMetadataView,
|
||||
SimpleGenericMetadataView,
|
||||
meta_path,
|
||||
@@ -51,6 +58,11 @@ from InvenTree.api import (
|
||||
from InvenTree.config import CONFIG_LOOKUPS
|
||||
from InvenTree.filters import ORDER_FILTER, SEARCH_ORDER_FILTER
|
||||
from InvenTree.helpers import inheritors, str2bool
|
||||
from InvenTree.helpers_api import (
|
||||
InvenTreeApiRouter,
|
||||
RetrieveDestroyModelViewSet,
|
||||
RetrieveUpdateDestroyModelViewSet,
|
||||
)
|
||||
from InvenTree.helpers_email import send_email
|
||||
from InvenTree.mixins import (
|
||||
CreateAPI,
|
||||
@@ -58,7 +70,6 @@ from InvenTree.mixins import (
|
||||
ListCreateAPI,
|
||||
OutputOptionsMixin,
|
||||
RetrieveAPI,
|
||||
RetrieveDestroyAPI,
|
||||
RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI,
|
||||
)
|
||||
@@ -71,6 +82,10 @@ from InvenTree.permissions import (
|
||||
IsSuperuserOrSuperScope,
|
||||
UserSettingsPermissionsOrScope,
|
||||
)
|
||||
from InvenTree.serializers import EmptySerializer
|
||||
|
||||
admin_router = InvenTreeApiRouter()
|
||||
common_router = InvenTreeApiRouter()
|
||||
|
||||
|
||||
class CsrfExemptMixin:
|
||||
@@ -155,14 +170,18 @@ class WebhookView(CsrfExemptMixin, APIView):
|
||||
raise NotFound()
|
||||
|
||||
|
||||
class CurrencyExchangeView(APIView):
|
||||
"""API endpoint for displaying currency information."""
|
||||
class CurrencyViewSet(viewsets.GenericViewSet):
|
||||
"""Viewset for currency exchange information."""
|
||||
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
serializer_class = None
|
||||
serializer_class = EmptySerializer
|
||||
|
||||
@extend_schema(responses={200: common.serializers.CurrencyExchangeSerializer})
|
||||
def get(self, request, fmt=None):
|
||||
@action(
|
||||
detail=False,
|
||||
methods=['get'],
|
||||
serializer_class=common.serializers.CurrencyExchangeSerializer,
|
||||
)
|
||||
def exchange(self, request, fmt=None):
|
||||
"""Return information on available currency conversions."""
|
||||
# Extract a list of all available rates
|
||||
try:
|
||||
@@ -195,17 +214,12 @@ class CurrencyExchangeView(APIView):
|
||||
|
||||
return Response(response)
|
||||
|
||||
|
||||
class CurrencyRefreshView(APIView):
|
||||
"""API endpoint for manually refreshing currency exchange rates.
|
||||
|
||||
User must be a 'staff' user to access this endpoint
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
serializer_class = None
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@action(
|
||||
detail=False,
|
||||
methods=['post'],
|
||||
permission_classes=[IsAuthenticatedOrReadScope, IsAdminUser],
|
||||
)
|
||||
def refresh(self, request, *args, **kwargs):
|
||||
"""Performing a POST request will update currency exchange rates."""
|
||||
from InvenTree.tasks import update_exchange_rates
|
||||
|
||||
@@ -214,6 +228,9 @@ class CurrencyRefreshView(APIView):
|
||||
return Response({'success': 'Exchange rates updated'})
|
||||
|
||||
|
||||
common_router.register('currency', CurrencyViewSet, basename='api-currency')
|
||||
|
||||
|
||||
class SettingsList(ListAPI):
|
||||
"""Generic ListView for settings.
|
||||
|
||||
@@ -325,13 +342,23 @@ class UserSettingsDetail(RetrieveUpdateAPI):
|
||||
)
|
||||
|
||||
|
||||
class NotificationMessageMixin:
|
||||
"""Generic mixin for NotificationMessage."""
|
||||
class NotificationMessageViewSet(
|
||||
BulkDeleteViewsetMixin, RetrieveUpdateDestroyModelViewSet
|
||||
):
|
||||
"""Notifications for the current user.
|
||||
|
||||
- User can only view / delete their own notification objects
|
||||
"""
|
||||
|
||||
queryset = common.models.NotificationMessage.objects.all()
|
||||
serializer_class = common.serializers.NotificationMessageSerializer
|
||||
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):
|
||||
"""Return prefetched queryset."""
|
||||
queryset = (
|
||||
@@ -348,20 +375,6 @@ class NotificationMessageMixin:
|
||||
|
||||
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):
|
||||
"""Only list notifications which apply to the current user."""
|
||||
try:
|
||||
@@ -382,18 +395,23 @@ class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
|
||||
queryset = queryset.filter(user=request.user)
|
||||
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):
|
||||
"""Detail view for an individual notification object.
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""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
|
||||
"""
|
||||
|
||||
|
||||
class NotificationReadAll(NotificationMessageMixin, RetrieveAPI):
|
||||
"""API endpoint to mark all notifications as read."""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
# TODO @matmair this should really be a POST
|
||||
@action(
|
||||
detail=False, methods=['get'], permission_classes=[IsAuthenticatedOrReadScope]
|
||||
)
|
||||
def readall(self, request, *args, **kwargs):
|
||||
"""Set all messages for the current user as read."""
|
||||
try:
|
||||
self.queryset.filter(user=request.user, read=False).update(read=True)
|
||||
@@ -404,47 +422,50 @@ class NotificationReadAll(NotificationMessageMixin, RetrieveAPI):
|
||||
)
|
||||
|
||||
|
||||
class NewsFeedMixin:
|
||||
"""Generic mixin for NewsFeedEntry."""
|
||||
common_router.register(
|
||||
'notifications', NotificationMessageViewSet, basename='api-notifications'
|
||||
)
|
||||
|
||||
|
||||
class NewsFeedViewSet(BulkDeleteViewsetMixin, RetrieveUpdateDestroyModelViewSet):
|
||||
"""Newsfeed from the official inventree.org website."""
|
||||
|
||||
queryset = common.models.NewsFeedEntry.objects.all()
|
||||
serializer_class = common.serializers.NewsFeedEntrySerializer
|
||||
permission_classes = [IsAdminOrAdminScope]
|
||||
|
||||
|
||||
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
|
||||
"""List view for all news items."""
|
||||
|
||||
filter_backends = ORDER_FILTER
|
||||
|
||||
ordering = '-published'
|
||||
|
||||
ordering_fields = ['published', 'author', 'read']
|
||||
|
||||
filterset_fields = ['read']
|
||||
|
||||
|
||||
class NewsFeedEntryDetail(NewsFeedMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for an individual news feed object."""
|
||||
common_router.register('news', NewsFeedViewSet, basename='api-news')
|
||||
|
||||
|
||||
class ConfigList(ListAPI):
|
||||
"""List view for all accessed configurations."""
|
||||
@extend_schema_view(
|
||||
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
|
||||
serializer_class = common.serializers.ConfigSerializer
|
||||
permission_classes = [IsSuperuserOrSuperScope]
|
||||
lookup_field = 'key'
|
||||
|
||||
# Specifically disable pagination for this view
|
||||
pagination_class = None
|
||||
|
||||
|
||||
class ConfigDetail(RetrieveAPI):
|
||||
"""Detail view for an individual configuration."""
|
||||
|
||||
serializer_class = common.serializers.ConfigSerializer
|
||||
permission_classes = [IsSuperuserOrSuperScope]
|
||||
|
||||
def get_object(self):
|
||||
"""Attempt to find a config object with the provided key."""
|
||||
key = self.kwargs['key']
|
||||
@@ -454,6 +475,9 @@ class ConfigDetail(RetrieveAPI):
|
||||
return {key: value}
|
||||
|
||||
|
||||
admin_router.register('config', ConfigViewSet, basename='api-config')
|
||||
|
||||
|
||||
class NotesImageList(ListCreateAPI):
|
||||
"""List view for all notes images."""
|
||||
|
||||
@@ -493,7 +517,7 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
|
||||
permission_classes = [IsStaffOrReadOnlyScope]
|
||||
|
||||
|
||||
class CustomUnitList(DataExportViewMixin, ListCreateAPI):
|
||||
class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet):
|
||||
"""List view for custom units."""
|
||||
|
||||
queryset = common.models.CustomUnit.objects.all()
|
||||
@@ -501,22 +525,12 @@ class CustomUnitList(DataExportViewMixin, ListCreateAPI):
|
||||
permission_classes = [IsStaffOrReadOnlyScope]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
|
||||
class CustomUnitDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for a particular custom unit."""
|
||||
|
||||
queryset = common.models.CustomUnit.objects.all()
|
||||
serializer_class = common.serializers.CustomUnitSerializer
|
||||
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):
|
||||
@action(
|
||||
detail=False,
|
||||
methods=['get'],
|
||||
serializer_class=common.serializers.AllUnitListResponseSerializer,
|
||||
)
|
||||
def all(self, request, *args, **kwargs):
|
||||
"""Return a list of all available units."""
|
||||
reg = InvenTree.conversion.get_unit_registry()
|
||||
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."""
|
||||
|
||||
queryset = Error.objects.all()
|
||||
@@ -556,12 +573,7 @@ class ErrorMessageList(BulkDeleteMixin, ListAPI):
|
||||
search_fields = ['info', 'data']
|
||||
|
||||
|
||||
class ErrorMessageDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for a single error message."""
|
||||
|
||||
queryset = Error.objects.all()
|
||||
serializer_class = common.serializers.ErrorMessageSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
common_router.register('error-report', ErrorMessageViewSet, basename='api-error')
|
||||
|
||||
|
||||
class BackgroundTaskDetail(APIView):
|
||||
@@ -1179,13 +1191,17 @@ class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for a SelectionEntry object."""
|
||||
|
||||
|
||||
class DataOutputEndpointMixin:
|
||||
class DataOutputViewSet(BulkDeleteViewsetMixin, RetrieveDestroyModelViewSet):
|
||||
"""Mixin class for DataOutput endpoints."""
|
||||
|
||||
queryset = common.models.DataOutput.objects.all()
|
||||
serializer_class = common.serializers.DataOutputSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
|
||||
filterset_fields = ['user']
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the set of DataOutput objects which the user has permission to view."""
|
||||
queryset = super().get_queryset()
|
||||
@@ -1203,29 +1219,16 @@ class DataOutputEndpointMixin:
|
||||
return queryset.filter(user=user)
|
||||
|
||||
|
||||
class DataOutputList(DataOutputEndpointMixin, BulkDeleteMixin, ListAPI):
|
||||
"""List view for DataOutput objects."""
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
|
||||
filterset_fields = ['user']
|
||||
common_router.register('data-output', DataOutputViewSet, basename='api-data-output')
|
||||
|
||||
|
||||
class DataOutputDetail(DataOutputEndpointMixin, generics.DestroyAPIView, RetrieveAPI):
|
||||
"""Detail view for a DataOutput object."""
|
||||
|
||||
|
||||
class EmailMessageMixin:
|
||||
"""Mixin class for Email endpoints."""
|
||||
class EmailViewSet(BulkDeleteViewsetMixin, RetrieveDestroyModelViewSet):
|
||||
"""Backend E-Mail management for administrative purposes."""
|
||||
|
||||
queryset = common.models.EmailMessage.objects.all()
|
||||
serializer_class = common.serializers.EmailMessageSerializer
|
||||
permission_classes = [IsSuperuserOrSuperScope]
|
||||
|
||||
|
||||
class EmailMessageList(EmailMessageMixin, BulkDeleteMixin, ListAPI):
|
||||
"""List view for email objects."""
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = [
|
||||
'created',
|
||||
@@ -1245,19 +1248,17 @@ class EmailMessageList(EmailMessageMixin, BulkDeleteMixin, ListAPI):
|
||||
'thread_id_key',
|
||||
]
|
||||
|
||||
|
||||
class EmailMessageDetail(EmailMessageMixin, RetrieveDestroyAPI):
|
||||
"""Detail view for an email object."""
|
||||
|
||||
|
||||
class TestEmail(CreateAPI):
|
||||
"""Send a test email."""
|
||||
|
||||
serializer_class = common.serializers.TestEmailSerializer
|
||||
permission_classes = [IsSuperuserOrSuperScope]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@extend_schema(responses={201: common.serializers.TestEmailSerializer})
|
||||
@action(
|
||||
detail=False,
|
||||
methods=['post'],
|
||||
serializer_class=common.serializers.TestEmailSerializer,
|
||||
)
|
||||
def test(self, request):
|
||||
"""Send a test email."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
data = serializer.validated_data
|
||||
|
||||
delivered, reason = send_email(
|
||||
@@ -1269,6 +1270,11 @@ class TestEmail(CreateAPI):
|
||||
raise serializers.ValidationError(
|
||||
detail=f'Failed to send test email: "{reason}"'
|
||||
) # 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):
|
||||
@@ -1491,13 +1497,6 @@ common_api_urls = [
|
||||
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
|
||||
path(
|
||||
'metadata/',
|
||||
@@ -1530,72 +1529,6 @@ common_api_urls = [
|
||||
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
|
||||
path(
|
||||
'flags/',
|
||||
@@ -1625,16 +1558,6 @@ common_api_urls = [
|
||||
path('icons/', IconList.as_view(), name='api-icon-list'),
|
||||
# Selection lists
|
||||
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)
|
||||
path(
|
||||
'system/',
|
||||
@@ -1655,19 +1578,8 @@ common_api_urls = [
|
||||
)
|
||||
]),
|
||||
),
|
||||
# Router
|
||||
path('', include(common_router.urls)),
|
||||
]
|
||||
|
||||
admin_api_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'),
|
||||
]),
|
||||
),
|
||||
]
|
||||
admin_api_urls = admin_router.urls
|
||||
|
||||
@@ -1304,12 +1304,41 @@ class NotificationTest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
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)
|
||||
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):
|
||||
"""Tests for bulk deletion of user notifications."""
|
||||
from error_report.models import Error
|
||||
@@ -1835,7 +1864,7 @@ class CustomUnitAPITest(InvenTreeAPITestCase):
|
||||
|
||||
def test_api(self):
|
||||
"""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('available_systems', response.data)
|
||||
self.assertIn('available_units', response.data)
|
||||
|
||||
Reference in New Issue
Block a user