diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index d47d3335b3..d0fa06b5ff 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -35,6 +35,7 @@ jobs: uses: actions/checkout@v2 - name: Version Check run: | + pip install requests python3 ci/version_check.py echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index b3c9e18a78..9aa655545f 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -91,6 +91,10 @@ jobs: cache: 'pip' - name: Run pre-commit Checks uses: pre-commit/action@v2.0.3 + - name: Check Version + run: | + pip install requests + python3 ci/version_check.py python: name: Tests - inventree-python diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index a9f55b23a6..ca4d37dc7c 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -64,6 +64,10 @@ class BulkDeleteMixin: - Speed (single API call and DB query) """ + def filter_delete_queryset(self, queryset, request): + """Provide custom filtering for the queryset *before* it is deleted""" + return queryset + def delete(self, request, *args, **kwargs): """Perform a DELETE operation against this list endpoint. @@ -81,18 +85,46 @@ class BulkDeleteMixin: except AttributeError: items = request.data.get('items', None) - if items is None or type(items) is not list or not items: + # Extract the filters from the request body + try: + filters = request.data.getlist('filters', None) + except AttributeError: + filters = request.data.get('filters', None) + + if not items and not filters: raise ValidationError({ - "non_field_errors": ["List of items must be provided for bulk deletion"] + "non_field_errors": ["List of items or filters must be provided for bulk deletion"], + }) + + if items and type(items) is not list: + raise ValidationError({ + "items": ["'items' must be supplied as a list object"] + }) + + if filters and type(filters) is not dict: + raise ValidationError({ + "filters": ["'filters' must be supplied as a dict object"] }) # Keep track of how many items we deleted n_deleted = 0 with transaction.atomic(): - objects = model.objects.filter(id__in=items) - n_deleted = objects.count() - objects.delete() + + # Start with *all* models and perform basic filtering + queryset = model.objects.all() + queryset = self.filter_delete_queryset(queryset, request) + + # Filter by provided item ID values + if items: + queryset = queryset.filter(id__in=items) + + # Filter by provided filters + if filters: + queryset = queryset.filter(**filters) + + n_deleted = queryset.count() + queryset.delete() return Response( { diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index ef1007c66b..a95a39d040 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -146,7 +146,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): if data is None: data = {} - response = self.client.delete(url, data=data, foramt=format) + response = self.client.delete(url, data=data, format=format) if expected_code is not None: self.assertEqual(response.status_code, expected_code) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 7bb41bd8c5..e90f60d1b7 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,16 @@ # InvenTree API version -INVENTREE_API_VERSION = 58 +INVENTREE_API_VERSION = 59 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v59 -> 2022-06-07 : https://github.com/inventree/InvenTree/pull/3154 + - Adds further improvements to BulkDelete mixin class + - Fixes multiple bugs in custom OPTIONS metadata implementation + - Adds 'bulk delete' for Notifications + v58 -> 2022-06-06 : https://github.com/inventree/InvenTree/pull/3146 - Adds a BulkDelete API mixin class for fast, safe deletion of multiple objects with a single API request diff --git a/InvenTree/InvenTree/exceptions.py b/InvenTree/InvenTree/exceptions.py index 2b64fd8b64..ce79449c53 100644 --- a/InvenTree/InvenTree/exceptions.py +++ b/InvenTree/InvenTree/exceptions.py @@ -9,7 +9,6 @@ import traceback from django.conf import settings from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import gettext_lazy as _ -from django.views.debug import ExceptionReporter import rest_framework.views as drfviews from error_report.models import Error @@ -18,6 +17,23 @@ from rest_framework.exceptions import ValidationError as DRFValidationError from rest_framework.response import Response +def log_error(path): + """Log an error to the database. + + - Uses python exception handling to extract error details + + Arguments: + path: The 'path' (most likely a URL) associated with this error (optional) + """ + kind, info, data = sys.exc_info() + Error.objects.create( + kind=kind.__name__, + info=info, + data='\n'.join(traceback.format_exception(kind, info, data)), + path=path, + ) + + def exception_handler(exc, context): """Custom exception handler for DRF framework. @@ -55,16 +71,7 @@ def exception_handler(exc, context): response = Response(response_data, status=500) - # Log the exception to the database, too - kind, info, data = sys.exc_info() - - Error.objects.create( - kind=kind.__name__, - info=info, - data='\n'.join(traceback.format_exception(kind, info, data)), - path=context['request'].path, - html=ExceptionReporter(context['request'], kind, info, data).get_traceback_html(), - ) + log_error(context['request'].path) if response is not None: # Convert errors returned under the label '__all__' to 'non_field_errors' diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 34f296c2f8..fdc6380138 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -682,6 +682,7 @@ def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = ' The method name must always be the name of the field prefixed by 'get_' """ + model_cls = getattr(obj, type_ref) obj_id = getattr(obj, object_ref) @@ -691,7 +692,12 @@ def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = ' # resolve referenced data into objects model_cls = model_cls.model_class() - item = model_cls.objects.get(id=obj_id) + + try: + item = model_cls.objects.get(id=obj_id) + except model_cls.DoesNotExist: + return None + url_fnc = getattr(item, 'get_absolute_url', None) # create output diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index 924d528fc0..8e4fa13452 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -44,7 +44,7 @@ class InvenTreeMetadata(SimpleMetadata): if str2bool(request.query_params.get('context', False)): - if hasattr(self.serializer, 'get_context_data'): + if hasattr(self, 'serializer') and hasattr(self.serializer, 'get_context_data'): context = self.serializer.get_context_data() metadata['context'] = context @@ -70,33 +70,36 @@ class InvenTreeMetadata(SimpleMetadata): actions = metadata.get('actions', None) - if actions is not None: + if actions is None: + actions = {} - check = users.models.RuleSet.check_table_permission + check = users.models.RuleSet.check_table_permission - # Map the request method to a permission type - rolemap = { - 'POST': 'add', - 'PUT': 'change', - 'PATCH': 'change', - 'DELETE': 'delete', - } + # Map the request method to a permission type + rolemap = { + 'POST': 'add', + 'PUT': 'change', + 'PATCH': 'change', + 'DELETE': 'delete', + } - # Remove any HTTP methods that the user does not have permission for - for method, permission in rolemap.items(): + # Remove any HTTP methods that the user does not have permission for + for method, permission in rolemap.items(): - result = check(user, table, permission) + result = check(user, table, permission) - if method in actions and not result: - del actions[method] + if method in actions and not result: + del actions[method] - # Add a 'DELETE' action if we are allowed to delete - if 'DELETE' in view.allowed_methods and check(user, table, 'delete'): - actions['DELETE'] = True + # Add a 'DELETE' action if we are allowed to delete + if 'DELETE' in view.allowed_methods and check(user, table, 'delete'): + actions['DELETE'] = True - # Add a 'VIEW' action if we are allowed to view - if 'GET' in view.allowed_methods and check(user, table, 'view'): - actions['GET'] = True + # Add a 'VIEW' action if we are allowed to view + if 'GET' in view.allowed_methods and check(user, table, 'view'): + actions['GET'] = True + + metadata['actions'] = actions except AttributeError: # We will assume that if the serializer class does *not* have a Meta diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 90d8a0314b..eaa8ef3b0d 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -475,6 +475,7 @@ def after_error_logged(sender, instance: Error, created: bool, **kwargs): 'inventree.error_log', context=context, targets=users, + delivery_methods=set([common.notifications.UIMessageNotification]), ) except Exception as exc: diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index 46029d1540..0e55f7a559 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -215,15 +215,15 @@ class APITests(InvenTreeAPITestCase): actions = self.getActions(url) - # No actions, as there are no permissions! - self.assertEqual(len(actions), 0) + # Even without permissions, GET action is available + self.assertEqual(len(actions), 1) # Assign a new role self.assignRole('part.view') actions = self.getActions(url) - # As we don't have "add" permission, there should be no available API actions - self.assertEqual(len(actions), 0) + # As we don't have "add" permission, there should be only the GET API action + self.assertEqual(len(actions), 1) # But let's make things interesting... # Why don't we treat ourselves to some "add" permissions @@ -244,7 +244,8 @@ class APITests(InvenTreeAPITestCase): actions = self.getActions(url) # No actions, as we do not have any permissions! - self.assertEqual(len(actions), 0) + self.assertEqual(len(actions), 1) + self.assertIn('GET', actions.keys()) # Add a 'add' permission # Note: 'add' permission automatically implies 'change' also @@ -266,3 +267,45 @@ class APITests(InvenTreeAPITestCase): self.assertIn('GET', actions.keys()) self.assertIn('PUT', actions.keys()) self.assertIn('DELETE', actions.keys()) + + +class BulkDeleteTests(InvenTreeAPITestCase): + """Unit tests for the BulkDelete endpoints""" + + superuser = True + + def test_errors(self): + """Test that the correct errors are thrown""" + + url = reverse('api-stock-test-result-list') + + # DELETE without any of the required fields + response = self.delete( + url, + {}, + expected_code=400 + ) + + self.assertIn('List of items or filters must be provided for bulk deletion', str(response.data)) + + # DELETE with invalid 'items' + response = self.delete( + url, + { + 'items': {"hello": "world"}, + }, + expected_code=400, + ) + + self.assertIn("'items' must be supplied as a list object", str(response.data)) + + # DELETE with invalid 'filters' + response = self.delete( + url, + { + 'filters': [1, 2, 3], + }, + expected_code=400, + ) + + self.assertIn("'filters' must be supplied as a dict object", str(response.data)) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 89e929256c..295035bd99 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -1,8 +1,10 @@ """JSON API for the Build app.""" from django.urls import include, re_path +from django.utils.translation import gettext_lazy as _ from rest_framework import filters, generics +from rest_framework.exceptions import ValidationError from django_filters.rest_framework import DjangoFilterBackend from django_filters import rest_framework as rest_filters @@ -198,12 +200,24 @@ class BuildList(APIDownloadMixin, generics.ListCreateAPIView): return self.serializer_class(*args, **kwargs) -class BuildDetail(generics.RetrieveUpdateAPIView): +class BuildDetail(generics.RetrieveUpdateDestroyAPIView): """API endpoint for detail view of a Build object.""" queryset = Build.objects.all() serializer_class = build.serializers.BuildSerializer + def destroy(self, request, *args, **kwargs): + """Only allow deletion of a BuildOrder if the build status is CANCELLED""" + + build = self.get_object() + + if build.status != BuildStatus.CANCELLED: + raise ValidationError({ + "non_field_errors": [_("Build must be cancelled before it can be deleted")] + }) + + return super().destroy(request, *args, **kwargs) + class BuildUnallocate(generics.CreateAPIView): """API endpoint for unallocating stock items from a build order. diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html index 5da87bea7c..d006d0b6ab 100644 --- a/InvenTree/build/templates/build/build_base.html +++ b/InvenTree/build/templates/build/build_base.html @@ -249,9 +249,11 @@ src="{% static 'img/blank_image.png' %}" {% endif %} $("#build-delete").on('click', function() { - launchModalForm( - "{% url 'build-delete' build.id %}", + constructForm( + '{% url "api-build-detail" build.pk %}', { + method: 'DELETE', + title: '{% trans "Delete Build Order" %}', redirect: "{% url 'build-index' %}", } ); diff --git a/InvenTree/build/templates/build/delete_build.html b/InvenTree/build/templates/build/delete_build.html deleted file mode 100644 index 62dab01da0..0000000000 --- a/InvenTree/build/templates/build/delete_build.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "modal_delete_form.html" %} -{% load i18n %} -{% block pre_form_content %} - -{% trans "Are you sure you want to delete this build?" %} - -{% endblock %} diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index c565506eae..abd25e7d42 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -90,7 +90,7 @@ class BuildAPITest(InvenTreeAPITestCase): # Required roles to access Build API endpoints roles = [ 'build.change', - 'build.add' + 'build.add', ] @@ -268,6 +268,39 @@ class BuildTest(BuildAPITest): self.assertEqual(bo.status, BuildStatus.CANCELLED) + def test_delete(self): + """Test that we can delete a BuildOrder via the API""" + + bo = Build.objects.get(pk=1) + + url = reverse('api-build-detail', kwargs={'pk': bo.pk}) + + # At first we do not have the required permissions + self.delete( + url, + expected_code=403, + ) + + self.assignRole('build.delete') + + # As build is currently not 'cancelled', it cannot be deleted + self.delete( + url, + expected_code=400, + ) + + bo.status = BuildStatus.CANCELLED + bo.save() + + # Now, we should be able to delete + self.delete( + url, + expected_code=204, + ) + + with self.assertRaises(Build.DoesNotExist): + Build.objects.get(pk=1) + def test_create_delete_output(self): """Test that we can create and delete build outputs via the API.""" bo = Build.objects.get(pk=1) diff --git a/InvenTree/build/urls.py b/InvenTree/build/urls.py index b524df5627..0b33c7d78e 100644 --- a/InvenTree/build/urls.py +++ b/InvenTree/build/urls.py @@ -4,15 +4,12 @@ from django.urls import include, re_path from . import views -build_detail_urls = [ - re_path(r'^delete/', views.BuildDelete.as_view(), name='build-delete'), - - re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), -] build_urls = [ - re_path(r'^(?P\d+)/', include(build_detail_urls)), + re_path(r'^(?P\d+)/', include([ + re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'), + ])), re_path(r'.*$', views.BuildIndex.as_view(), name='build-index'), ] diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py index 9d01ddc3d6..bccd1b31e6 100644 --- a/InvenTree/build/views.py +++ b/InvenTree/build/views.py @@ -1,11 +1,9 @@ """Django views for interacting with Build objects.""" -from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView from .models import Build -from InvenTree.views import AjaxDeleteView from InvenTree.views import InvenTreeRoleMixin from InvenTree.status_codes import BuildStatus @@ -49,11 +47,3 @@ class BuildDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView): ctx['has_untracked_bom_items'] = build.has_untracked_bom_items() return ctx - - -class BuildDelete(AjaxDeleteView): - """View to delete a build.""" - - model = Build - ajax_template_name = 'build/delete_build.html' - ajax_form_title = _('Delete Build Order') diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index afc0b1272b..672f341e8f 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -16,6 +16,7 @@ from rest_framework.views import APIView import common.models import common.serializers +from InvenTree.api import BulkDeleteMixin from InvenTree.helpers import inheritors from plugin.models import NotificationUserSetting from plugin.serializers import NotificationUserSettingSerializer @@ -258,12 +259,16 @@ class NotificationUserSettingsDetail(generics.RetrieveUpdateAPIView): ] -class NotificationList(generics.ListAPIView): +class NotificationList(BulkDeleteMixin, generics.ListAPIView): """List view for all notifications of the current user.""" queryset = common.models.NotificationMessage.objects.all() serializer_class = common.serializers.NotificationMessageSerializer + permission_classes = [ + permissions.IsAuthenticated, + ] + filter_backends = [ DjangoFilterBackend, filters.SearchFilter, @@ -298,6 +303,12 @@ class NotificationList(generics.ListAPIView): queryset = queryset.filter(user=user) return queryset + def filter_delete_queryset(self, queryset, request): + """Ensure that the user can only delete their *own* notifications""" + + queryset = queryset.filter(user=request.user) + return queryset + class NotificationDetail(generics.RetrieveUpdateDestroyAPIView): """Detail view for an individual notification object. diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index f0f06a3e7c..8a087930c0 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -14,7 +14,8 @@ from plugin.models import NotificationUserSetting, PluginConfig from .api import WebhookView from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting, - NotificationEntry, WebhookEndpoint, WebhookMessage) + NotificationEntry, NotificationMessage, WebhookEndpoint, + WebhookMessage) CONTENT_TYPE_JSON = 'application/json' @@ -665,6 +666,10 @@ class WebhookMessageTests(TestCase): class NotificationTest(InvenTreeAPITestCase): """Tests for NotificationEntriy.""" + fixtures = [ + 'users', + ] + def test_check_notification_entries(self): """Test that notification entries can be created.""" # Create some notification entries @@ -684,9 +689,84 @@ class NotificationTest(InvenTreeAPITestCase): def test_api_list(self): """Test list URL.""" + url = reverse('api-notifications-list') + self.get(url, expected_code=200) + # Test the OPTIONS endpoint for the 'api-notification-list' + # Ref: https://github.com/inventree/InvenTree/pull/3154 + response = self.options(url) + + self.assertIn('DELETE', response.data['actions']) + self.assertIn('GET', response.data['actions']) + self.assertNotIn('POST', response.data['actions']) + + self.assertEqual(response.data['description'], 'List view for all notifications of the current user.') + + # POST action should fail (not allowed) + response = self.post(url, {}, expected_code=405) + + def test_bulk_delete(self): + """Tests for bulk deletion of user notifications""" + + from error_report.models import Error + + # Create some notification messages by throwing errors + for _ii in range(10): + Error.objects.create() + + # Check that messsages have been created + messages = NotificationMessage.objects.all() + + # As there are three staff users (including the 'test' user) we expect 30 notifications + self.assertEqual(messages.count(), 30) + + # Only 10 messages related to *this* user + my_notifications = messages.filter(user=self.user) + self.assertEqual(my_notifications.count(), 10) + + # Get notification via the API + url = reverse('api-notifications-list') + response = self.get(url, {}, expected_code=200) + self.assertEqual(len(response.data), 10) + + # Mark some as read + for ntf in my_notifications[0:3]: + ntf.read = True + ntf.save() + + # Read out via API again + response = self.get( + url, + { + 'read': True, + }, + expected_code=200 + ) + + # Check validity of returned data + self.assertEqual(len(response.data), 3) + for ntf in response.data: + self.assertTrue(ntf['read']) + + # Now, let's bulk delete all 'unread' notifications via the API, + # but only associated with the logged in user + response = self.delete( + url, + { + 'filters': { + 'read': False, + } + }, + expected_code=204, + ) + + # Only 7 notifications should have been deleted, + # as the notifications associated with other users must remain untouched + self.assertEqual(NotificationMessage.objects.count(), 23) + self.assertEqual(NotificationMessage.objects.filter(user=self.user).count(), 3) + class LoadingTest(TestCase): """Tests for the common config.""" diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index be96a8fa2b..bdced458c0 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -3,7 +3,6 @@ import logging import os import sys -import traceback from datetime import datetime from decimal import Decimal @@ -21,7 +20,6 @@ from django.utils.translation import gettext_lazy as _ from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.models import convert_money from djmoney.money import Money -from error_report.models import Error from markdownx.models import MarkdownxField from mptt.models import TreeForeignKey @@ -29,6 +27,7 @@ import InvenTree.helpers import InvenTree.ready from common.settings import currency_code_default from company.models import Company, SupplierPart +from InvenTree.exceptions import log_error from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField from InvenTree.helpers import (decimal2string, getSetting, increment, notify_responsible) @@ -186,13 +185,7 @@ class Order(MetadataMixin, ReferenceIndexingMixin): # Record the error, try to press on kind, info, data = sys.exc_info() - Error.objects.create( - kind=kind.__name__, - info=info, - data='\n'.join(traceback.format_exception(kind, info, data)), - path='order.get_total_price', - ) - + log_error('order.get_total_price') logger.error(f"Missing exchange rate for '{target_currency}'") # Return None to indicate the calculated price is invalid @@ -208,15 +201,8 @@ class Order(MetadataMixin, ReferenceIndexingMixin): total += line.quantity * convert_money(line.price, target_currency) except MissingRate: # Record the error, try to press on - kind, info, data = sys.exc_info() - - Error.objects.create( - kind=kind.__name__, - info=info, - data='\n'.join(traceback.format_exception(kind, info, data)), - path='order.get_total_price', - ) + log_error('order.get_total_price') logger.error(f"Missing exchange rate for '{target_currency}'") # Return None to indicate the calculated price is invalid diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index d11cc5d142..ac03baf0a5 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -616,6 +616,8 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase): # reload notification methods storage.collect(run_class) + NotificationEntry.objects.all().delete() + # There should be no notification runs self.assertEqual(NotificationEntry.objects.all().count(), 0) @@ -630,8 +632,8 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase): self.part.set_starred(self.user, True) self.part.save() - # There should be 1 notification - self.assertEqual(NotificationEntry.objects.all().count(), 1) + # There should be 1 (or 2) notifications - in some cases an error is generated, which creates a subsequent notification + self.assertIn(NotificationEntry.objects.all().count(), [1, 2]) class PartNotificationTest(BaseNotificationIntegrationTest): diff --git a/InvenTree/plugin/base/label/label.py b/InvenTree/plugin/base/label/label.py index 6880a8b3e6..5baf0ec872 100644 --- a/InvenTree/plugin/base/label/label.py +++ b/InvenTree/plugin/base/label/label.py @@ -1,17 +1,14 @@ """Functions to print a label to a mixin printer.""" import logging -import sys -import traceback from django.conf import settings from django.utils.translation import gettext_lazy as _ -from django.views.debug import ExceptionReporter import pdf2image -from error_report.models import Error import common.notifications +from InvenTree.exceptions import log_error from plugin.registry import registry logger = logging.getLogger('inventree') @@ -63,16 +60,7 @@ def print_label(plugin_slug: str, pdf_data, filename=None, label_instance=None, } # Log an error message to the database - kind, info, data = sys.exc_info() - - Error.objects.create( - kind=kind.__name__, - info=info, - data='\n'.join(traceback.format_exception(kind, info, data)), - path='print_label', - html=ExceptionReporter(None, kind, info, data).get_traceback_html(), - ) - + log_error('plugin.print_label') logger.error(f"Label printing failed: Sending notification to user '{user}'") # pragma: no cover # Throw an error against the plugin instance diff --git a/InvenTree/plugin/views.py b/InvenTree/plugin/views.py index aa43b9bd09..2073f25422 100644 --- a/InvenTree/plugin/views.py +++ b/InvenTree/plugin/views.py @@ -1,14 +1,10 @@ """Views for plugin app.""" import logging -import sys -import traceback from django.conf import settings -from django.views.debug import ExceptionReporter - -from error_report.models import Error +from InvenTree.exceptions import log_error from plugin.registry import registry logger = logging.getLogger('inventree') @@ -29,18 +25,8 @@ class InvenTreePluginViewMixin: try: panels += plug.render_panels(self, self.request, ctx) except Exception: - # Prevent any plugin error from crashing the page render - kind, info, data = sys.exc_info() - # Log the error to the database - Error.objects.create( - kind=kind.__name__, - info=info, - data='\n'.join(traceback.format_exception(kind, info, data)), - path=self.request.path, - html=ExceptionReporter(self.request, kind, info, data).get_traceback_html(), - ) - + log_error(self.request.path) logger.error(f"Plugin '{plug.slug}' could not render custom panels at '{self.request.path}'") return panels diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index c7c6019ddb..d5ccc21d35 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -280,9 +280,7 @@ // Ensure that we are only deleting the correct test results response.forEach(function(result) { - if (result.stock_item == {{ item.pk }}) { - items.push(result.pk); - } + items.push(result.pk); }); var html = ` @@ -293,6 +291,9 @@ constructForm(url, { form_data: { items: items, + filters: { + stock_item: {{ item.pk }}, + } }, method: 'DELETE', title: '{% trans "Delete Test Data" %}', diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index b0213e5a0b..93f35b78e1 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -968,10 +968,28 @@ class StockTestResultTest(StockAPITestCase): ) # Now, let's delete all the newly created items with a single API request + # However, we will provide incorrect filters response = self.delete( url, { 'items': tests, + 'filters': { + 'stock_item': 10, + } + }, + expected_code=204 + ) + + self.assertEqual(StockItemTestResult.objects.count(), n + 50) + + # Try again, but with the correct filters this time + response = self.delete( + url, + { + 'items': tests, + 'filters': { + 'stock_item': 1, + } }, expected_code=204 ) diff --git a/InvenTree/templates/InvenTree/notifications/history.html b/InvenTree/templates/InvenTree/notifications/history.html index 2f6a34d0d7..4623f7fc4f 100644 --- a/InvenTree/templates/InvenTree/notifications/history.html +++ b/InvenTree/templates/InvenTree/notifications/history.html @@ -10,15 +10,22 @@ {% endblock %} {% block actions %} -
- {% trans "Refresh Notification History" %} +
+ {% trans "Delete Notifications" %}
{% endblock %} {% block content %} +
+
+ {% include "filter_list.html" with id="notifications-history" %} +
+
+
- + +
diff --git a/InvenTree/templates/InvenTree/notifications/inbox.html b/InvenTree/templates/InvenTree/notifications/inbox.html index 40ea8f2042..be994727b1 100644 --- a/InvenTree/templates/InvenTree/notifications/inbox.html +++ b/InvenTree/templates/InvenTree/notifications/inbox.html @@ -13,15 +13,18 @@
{% trans "Mark all as read" %}
-
- {% trans "Refresh Pending Notifications" %} -
{% endblock %} {% block content %} +
+
+ {% include "filter_list.html" with id="notifications-inbox" %} +
+
+
- +
diff --git a/InvenTree/templates/InvenTree/notifications/notifications.html b/InvenTree/templates/InvenTree/notifications/notifications.html index fedf8a1448..b225002279 100644 --- a/InvenTree/templates/InvenTree/notifications/notifications.html +++ b/InvenTree/templates/InvenTree/notifications/notifications.html @@ -29,83 +29,6 @@ function updateNotificationTables() { // this allows the global notification panel to update the tables window.updateNotifications = updateNotificationTables -function loadNotificationTable(table, options={}, enableDelete=false) { - - var params = options.params || {}; - var read = typeof(params.read) === 'undefined' ? true : params.read; - - $(table).inventreeTable({ - url: options.url, - name: options.name, - groupBy: false, - search: true, - queryParams: { - ordering: 'age', - read: read, - }, - paginationVAlign: 'bottom', - formatNoMatches: options.no_matches, - columns: [ - { - field: 'pk', - title: '{% trans "ID" %}', - visible: false, - switchable: false, - }, - { - field: 'age', - title: '{% trans "Age" %}', - sortable: 'true', - formatter: function(value, row) { - return row.age_human - } - }, - { - field: 'category', - title: '{% trans "Category" %}', - sortable: 'true', - }, - { - field: 'target', - title: '{% trans "Item" %}', - sortable: 'true', - formatter: function(value, row, index, field) { - if (value == null) { - return ''; - } - - var html = `${value.model}: ${value.name}`; - if (value.link ) {html = `${html}`;} - return html; - } - }, - { - field: 'name', - title: '{% trans "Name" %}', - }, - { - field: 'message', - title: '{% trans "Message" %}', - }, - { - formatter: function(value, row, index, field) { - var bRead = getReadEditButton(row.pk, row.read) - if (enableDelete) { - var bDel = ""; - } else { - var bDel = ''; - } - var html = "
" + bRead + bDel + "
"; - return html; - } - } - ] - }); - - $(table).on('click', '.notification-read', function() { - updateNotificationReadState($(this)); - }); -} loadNotificationTable("#inbox-table", { name: 'inbox', @@ -116,10 +39,6 @@ loadNotificationTable("#inbox-table", { no_matches: function() { return '{% trans "No unread notifications found" %}'; }, }); -$("#inbox-refresh").on('click', function() { - $("#inbox-table").bootstrapTable('refresh'); -}); - $("#mark-all").on('click', function() { inventreeGet( '{% url "api-notifications-readall" %}', @@ -140,8 +59,31 @@ loadNotificationTable("#history-table", { no_matches: function() { return '{% trans "No notification history found" %}'; }, }, true); -$("#history-refresh").on('click', function() { - $("#history-table").bootstrapTable('refresh'); + +$('#history-delete').click(function() { + + var html = ` +
+ {% trans "Delete all read notifications" %} +
`; + + // Perform a bulk delete of all 'read' notifications for this user + constructForm( + '{% url "api-notifications-list" %}', + { + method: 'DELETE', + preFormContent: html, + title: '{% trans "Delete Notifications" %}', + onSuccess: function() { + $('#history-table').bootstrapTable('refresh'); + }, + form_data: { + filters: { + read: true, + } + } + } + ); }); $("#history-table").on('click', '.notification-delete', function() { diff --git a/InvenTree/templates/attachment_table.html b/InvenTree/templates/attachment_table.html index 2db4c6345f..f47e1f5173 100644 --- a/InvenTree/templates/attachment_table.html +++ b/InvenTree/templates/attachment_table.html @@ -3,7 +3,7 @@
-