2
0
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:
Matthias Mair
2026-04-11 09:37:16 +02:00
committed by GitHub
parent fffc55c764
commit 366d4c398c
6 changed files with 262 additions and 230 deletions

View File

@@ -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."""

View File

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

View 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

View File

@@ -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 == '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') (self.method == 'PUT' or self.method == 'PATCH')
and self.is_bulk_action('BulkUpdateMixin') 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()

View File

@@ -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): )
def test(self, request):
"""Send a test email.""" """Send a test email."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer_class = common.serializers.TestEmailSerializer
permission_classes = [IsSuperuserOrSuperScope]
def perform_create(self, serializer):
"""Send a test email."""
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'),
]),
),
]

View File

@@ -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)