2
0
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:
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
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."""

View File

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

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()
# 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')
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()

View File

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

View File

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