mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Adding bulk deletion endpoint for notifications (#3154)
* Catch DoesNotExist error * Move notificationtable function to js file * Fix for custom metadata class - Previously only worked if a POST or PUT action was available on the endpoint - So, a ListAPIView endpoint would not actually work! - Adding in a BulkDelete mixin to a ListAPIView caused failure * Add unit test to ensure new OPTIONS metadata updates are checked * Expand functionality of the existing BulkDelete mixin - Allow deletion by custom filters - Allow each implementing class to implement custom filters - Adds more unit testing for BulkDelete mixin class * Add bulk delete operation for Notification API - Ensure users can only delete their *own* notifications * Improve notification tables / buttons / etc * Adds unit testing for bulk delete of notifications - Fixed API permissions for notifications list endpoint * Update BulkDelete operations for the StockItemTestResult table * Use filters parameter in attachments table to ensure that only correct attachments are deleted * JS linting * Fixes for unit tests
This commit is contained in:
		| @@ -64,6 +64,10 @@ class BulkDeleteMixin: | |||||||
|     - Speed (single API call and DB query) |     - 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): |     def delete(self, request, *args, **kwargs): | ||||||
|         """Perform a DELETE operation against this list endpoint. |         """Perform a DELETE operation against this list endpoint. | ||||||
|  |  | ||||||
| @@ -81,18 +85,46 @@ class BulkDeleteMixin: | |||||||
|         except AttributeError: |         except AttributeError: | ||||||
|             items = request.data.get('items', None) |             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({ |             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 |         # Keep track of how many items we deleted | ||||||
|         n_deleted = 0 |         n_deleted = 0 | ||||||
|  |  | ||||||
|         with transaction.atomic(): |         with transaction.atomic(): | ||||||
|             objects = model.objects.filter(id__in=items) |  | ||||||
|             n_deleted = objects.count() |             # Start with *all* models and perform basic filtering | ||||||
|             objects.delete() |             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( |         return Response( | ||||||
|             { |             { | ||||||
|   | |||||||
| @@ -146,7 +146,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): | |||||||
|         if data is None: |         if data is None: | ||||||
|             data = {} |             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: |         if expected_code is not None: | ||||||
|             self.assertEqual(response.status_code, expected_code) |             self.assertEqual(response.status_code, expected_code) | ||||||
|   | |||||||
| @@ -2,11 +2,16 @@ | |||||||
|  |  | ||||||
|  |  | ||||||
| # InvenTree API version | # 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 | 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 | 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 |     - Adds a BulkDelete API mixin class for fast, safe deletion of multiple objects with a single API request | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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_' |     The method name must always be the name of the field prefixed by 'get_' | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model_cls = getattr(obj, type_ref) |     model_cls = getattr(obj, type_ref) | ||||||
|     obj_id = getattr(obj, object_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 |     # resolve referenced data into objects | ||||||
|     model_cls = model_cls.model_class() |     model_cls = model_cls.model_class() | ||||||
|  |  | ||||||
|  |     try: | ||||||
|         item = model_cls.objects.get(id=obj_id) |         item = model_cls.objects.get(id=obj_id) | ||||||
|  |     except model_cls.DoesNotExist: | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     url_fnc = getattr(item, 'get_absolute_url', None) |     url_fnc = getattr(item, 'get_absolute_url', None) | ||||||
|  |  | ||||||
|     # create output |     # create output | ||||||
|   | |||||||
| @@ -44,7 +44,7 @@ class InvenTreeMetadata(SimpleMetadata): | |||||||
|  |  | ||||||
|         if str2bool(request.query_params.get('context', False)): |         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() |                 context = self.serializer.get_context_data() | ||||||
|  |  | ||||||
|             metadata['context'] = context |             metadata['context'] = context | ||||||
| @@ -70,7 +70,8 @@ class InvenTreeMetadata(SimpleMetadata): | |||||||
|  |  | ||||||
|             actions = metadata.get('actions', None) |             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 | ||||||
|  |  | ||||||
| @@ -98,6 +99,8 @@ class InvenTreeMetadata(SimpleMetadata): | |||||||
|             if 'GET' in view.allowed_methods and check(user, table, 'view'): |             if 'GET' in view.allowed_methods and check(user, table, 'view'): | ||||||
|                 actions['GET'] = True |                 actions['GET'] = True | ||||||
|  |  | ||||||
|  |             metadata['actions'] = actions | ||||||
|  |  | ||||||
|         except AttributeError: |         except AttributeError: | ||||||
|             # We will assume that if the serializer class does *not* have a Meta |             # We will assume that if the serializer class does *not* have a Meta | ||||||
|             # then we don't need a permission |             # then we don't need a permission | ||||||
|   | |||||||
| @@ -215,15 +215,15 @@ class APITests(InvenTreeAPITestCase): | |||||||
|  |  | ||||||
|         actions = self.getActions(url) |         actions = self.getActions(url) | ||||||
|  |  | ||||||
|         # No actions, as there are no permissions! |         # Even without permissions, GET action is available | ||||||
|         self.assertEqual(len(actions), 0) |         self.assertEqual(len(actions), 1) | ||||||
|  |  | ||||||
|         # Assign a new role |         # Assign a new role | ||||||
|         self.assignRole('part.view') |         self.assignRole('part.view') | ||||||
|         actions = self.getActions(url) |         actions = self.getActions(url) | ||||||
|  |  | ||||||
|         # As we don't have "add" permission, there should be no available API actions |         # As we don't have "add" permission, there should be only the GET API action | ||||||
|         self.assertEqual(len(actions), 0) |         self.assertEqual(len(actions), 1) | ||||||
|  |  | ||||||
|         # But let's make things interesting... |         # But let's make things interesting... | ||||||
|         # Why don't we treat ourselves to some "add" permissions |         # Why don't we treat ourselves to some "add" permissions | ||||||
| @@ -244,7 +244,8 @@ class APITests(InvenTreeAPITestCase): | |||||||
|         actions = self.getActions(url) |         actions = self.getActions(url) | ||||||
|  |  | ||||||
|         # No actions, as we do not have any permissions! |         # 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 |         # Add a 'add' permission | ||||||
|         # Note: 'add' permission automatically implies 'change' also |         # Note: 'add' permission automatically implies 'change' also | ||||||
| @@ -266,3 +267,45 @@ class APITests(InvenTreeAPITestCase): | |||||||
|         self.assertIn('GET', actions.keys()) |         self.assertIn('GET', actions.keys()) | ||||||
|         self.assertIn('PUT', actions.keys()) |         self.assertIn('PUT', actions.keys()) | ||||||
|         self.assertIn('DELETE', 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)) | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ from rest_framework.views import APIView | |||||||
|  |  | ||||||
| import common.models | import common.models | ||||||
| import common.serializers | import common.serializers | ||||||
|  | from InvenTree.api import BulkDeleteMixin | ||||||
| from InvenTree.helpers import inheritors | from InvenTree.helpers import inheritors | ||||||
| from plugin.models import NotificationUserSetting | from plugin.models import NotificationUserSetting | ||||||
| from plugin.serializers import NotificationUserSettingSerializer | 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.""" |     """List view for all notifications of the current user.""" | ||||||
|  |  | ||||||
|     queryset = common.models.NotificationMessage.objects.all() |     queryset = common.models.NotificationMessage.objects.all() | ||||||
|     serializer_class = common.serializers.NotificationMessageSerializer |     serializer_class = common.serializers.NotificationMessageSerializer | ||||||
|  |  | ||||||
|  |     permission_classes = [ | ||||||
|  |         permissions.IsAuthenticated, | ||||||
|  |     ] | ||||||
|  |  | ||||||
|     filter_backends = [ |     filter_backends = [ | ||||||
|         DjangoFilterBackend, |         DjangoFilterBackend, | ||||||
|         filters.SearchFilter, |         filters.SearchFilter, | ||||||
| @@ -298,6 +303,12 @@ class NotificationList(generics.ListAPIView): | |||||||
|         queryset = queryset.filter(user=user) |         queryset = queryset.filter(user=user) | ||||||
|         return queryset |         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): | class NotificationDetail(generics.RetrieveUpdateDestroyAPIView): | ||||||
|     """Detail view for an individual notification object. |     """Detail view for an individual notification object. | ||||||
|   | |||||||
| @@ -14,7 +14,8 @@ from plugin.models import NotificationUserSetting, PluginConfig | |||||||
|  |  | ||||||
| from .api import WebhookView | from .api import WebhookView | ||||||
| from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting, | from .models import (ColorTheme, InvenTreeSetting, InvenTreeUserSetting, | ||||||
|                      NotificationEntry, WebhookEndpoint, WebhookMessage) |                      NotificationEntry, NotificationMessage, WebhookEndpoint, | ||||||
|  |                      WebhookMessage) | ||||||
|  |  | ||||||
| CONTENT_TYPE_JSON = 'application/json' | CONTENT_TYPE_JSON = 'application/json' | ||||||
|  |  | ||||||
| @@ -665,6 +666,10 @@ class WebhookMessageTests(TestCase): | |||||||
| class NotificationTest(InvenTreeAPITestCase): | class NotificationTest(InvenTreeAPITestCase): | ||||||
|     """Tests for NotificationEntriy.""" |     """Tests for NotificationEntriy.""" | ||||||
|  |  | ||||||
|  |     fixtures = [ | ||||||
|  |         'users', | ||||||
|  |     ] | ||||||
|  |  | ||||||
|     def test_check_notification_entries(self): |     def test_check_notification_entries(self): | ||||||
|         """Test that notification entries can be created.""" |         """Test that notification entries can be created.""" | ||||||
|         # Create some notification entries |         # Create some notification entries | ||||||
| @@ -684,9 +689,84 @@ class NotificationTest(InvenTreeAPITestCase): | |||||||
|  |  | ||||||
|     def test_api_list(self): |     def test_api_list(self): | ||||||
|         """Test list URL.""" |         """Test list URL.""" | ||||||
|  |  | ||||||
|         url = reverse('api-notifications-list') |         url = reverse('api-notifications-list') | ||||||
|  |  | ||||||
|         self.get(url, expected_code=200) |         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): | class LoadingTest(TestCase): | ||||||
|     """Tests for the common config.""" |     """Tests for the common config.""" | ||||||
|   | |||||||
| @@ -280,9 +280,7 @@ | |||||||
|  |  | ||||||
|                     // Ensure that we are only deleting the correct test results |                     // Ensure that we are only deleting the correct test results | ||||||
|                     response.forEach(function(result) { |                     response.forEach(function(result) { | ||||||
|                         if (result.stock_item == {{ item.pk }}) { |  | ||||||
|                         items.push(result.pk); |                         items.push(result.pk); | ||||||
|                         } |  | ||||||
|                     }); |                     }); | ||||||
|  |  | ||||||
|                     var html = ` |                     var html = ` | ||||||
| @@ -293,6 +291,9 @@ | |||||||
|                     constructForm(url, { |                     constructForm(url, { | ||||||
|                         form_data: { |                         form_data: { | ||||||
|                             items: items, |                             items: items, | ||||||
|  |                             filters: { | ||||||
|  |                                 stock_item: {{ item.pk }}, | ||||||
|  |                             } | ||||||
|                         }, |                         }, | ||||||
|                         method: 'DELETE', |                         method: 'DELETE', | ||||||
|                         title: '{% trans "Delete Test Data" %}', |                         title: '{% trans "Delete Test Data" %}', | ||||||
|   | |||||||
| @@ -968,10 +968,28 @@ class StockTestResultTest(StockAPITestCase): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Now, let's delete all the newly created items with a single API request |         # Now, let's delete all the newly created items with a single API request | ||||||
|  |         # However, we will provide incorrect filters | ||||||
|         response = self.delete( |         response = self.delete( | ||||||
|             url, |             url, | ||||||
|             { |             { | ||||||
|                 'items': tests, |                 '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 |             expected_code=204 | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -10,15 +10,22 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block actions %} | {% block actions %} | ||||||
| <div class='btn btn-secondary' type='button' id='history-refresh' title='{% trans "Refresh Notification History" %}'> | <div class='btn btn-danger' type='button' id='history-delete' title='{% trans "Delete Notifications" %}'> | ||||||
|     <span class='fa fa-sync'></span> {% trans "Refresh Notification History" %} |     <span class='fas fa-trash-alt'></span> {% trans "Delete Notifications" %} | ||||||
| </div> | </div> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
|  | <div id='history-buttons'> | ||||||
|  |     <div class='btn-group' role='group'> | ||||||
|  |         {% include "filter_list.html" with id="notifications-history" %} | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
| <div class='row'> | <div class='row'> | ||||||
|     <table class='table table-striped table-condensed' id='history-table'> |  | ||||||
|  |     <table class='table table-striped table-condensed' id='history-table' data-toolbar='#history-buttons'> | ||||||
|     </table> |     </table> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,15 +13,18 @@ | |||||||
| <div class='btn btn-outline-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'> | <div class='btn btn-outline-secondary' type='button' id='mark-all' title='{% trans "Mark all as read" %}'> | ||||||
|     <span class='fa fa-bookmark'></span> {% trans "Mark all as read" %} |     <span class='fa fa-bookmark'></span> {% trans "Mark all as read" %} | ||||||
| </div> | </div> | ||||||
| <div class='btn btn-secondary' type='button' id='inbox-refresh' title='{% trans "Refresh Pending Notifications" %}'> |  | ||||||
|     <span class='fa fa-sync'></span> {% trans "Refresh Pending Notifications" %} |  | ||||||
| </div> |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
|  | <div id='inbox-buttons'> | ||||||
|  |     <div class='btn-group' role='group'> | ||||||
|  |         {% include "filter_list.html" with id="notifications-inbox" %} | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
| <div class='row'> | <div class='row'> | ||||||
|     <table class='table table-striped table-condensed' id='inbox-table'> |     <table class='table table-striped table-condensed' id='inbox-table' data-toolbar='#inbox-buttons'> | ||||||
|     </table> |     </table> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -29,83 +29,6 @@ function updateNotificationTables() { | |||||||
| // this allows the global notification panel to update the tables | // this allows the global notification panel to update the tables | ||||||
| window.updateNotifications = updateNotificationTables | 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 = `<a href='${value.link}'>${html}</a>`;} |  | ||||||
|                     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 = "<button title='{% trans "Delete Notification" %}' class='notification-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>"; |  | ||||||
|                     } else { |  | ||||||
|                         var bDel = ''; |  | ||||||
|                     } |  | ||||||
|                     var html = "<div class='btn-group float-right' role='group'>" + bRead + bDel + "</div>"; |  | ||||||
|                     return html; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         ] |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     $(table).on('click', '.notification-read', function() { |  | ||||||
|         updateNotificationReadState($(this)); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| loadNotificationTable("#inbox-table", { | loadNotificationTable("#inbox-table", { | ||||||
|     name: 'inbox', |     name: 'inbox', | ||||||
| @@ -116,10 +39,6 @@ loadNotificationTable("#inbox-table", { | |||||||
|     no_matches: function() { return '{% trans "No unread notifications found" %}'; }, |     no_matches: function() { return '{% trans "No unread notifications found" %}'; }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| $("#inbox-refresh").on('click', function() { |  | ||||||
|     $("#inbox-table").bootstrapTable('refresh'); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| $("#mark-all").on('click', function() { | $("#mark-all").on('click', function() { | ||||||
|     inventreeGet( |     inventreeGet( | ||||||
|         '{% url "api-notifications-readall" %}', |         '{% url "api-notifications-readall" %}', | ||||||
| @@ -140,8 +59,31 @@ loadNotificationTable("#history-table", { | |||||||
|     no_matches: function() { return '{% trans "No notification history found" %}'; }, |     no_matches: function() { return '{% trans "No notification history found" %}'; }, | ||||||
| }, true); | }, true); | ||||||
|  |  | ||||||
| $("#history-refresh").on('click', function() { |  | ||||||
|     $("#history-table").bootstrapTable('refresh'); | $('#history-delete').click(function() { | ||||||
|  |  | ||||||
|  |     var html = ` | ||||||
|  |     <div class='alert alert-block alert-danger'> | ||||||
|  |         {% trans "Delete all read notifications" %} | ||||||
|  |     </div>`; | ||||||
|  |  | ||||||
|  |     // 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() { | $("#history-table").on('click', '.notification-delete', function() { | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ | |||||||
| <div id='attachment-buttons'> | <div id='attachment-buttons'> | ||||||
|     <div class='btn-group' role='group'> |     <div class='btn-group' role='group'> | ||||||
|         <div class='btn-group'> |         <div class='btn-group'> | ||||||
|             <button class='btn btn-primary dropdown-toggle' type='buton' data-bs-toggle='dropdown' title='{% trans "Actions" %}'> |             <button class='btn btn-primary dropdown-toggle' type='button' data-bs-toggle='dropdown' title='{% trans "Actions" %}'> | ||||||
|                 <span class='fas fa-tools'></span> <span class='caret'></span> |                 <span class='fas fa-tools'></span> <span class='caret'></span> | ||||||
|             </button> |             </button> | ||||||
|             <ul class='dropdown-menu'> |             <ul class='dropdown-menu'> | ||||||
|   | |||||||
| @@ -113,6 +113,7 @@ function deleteAttachments(attachments, url, options={}) { | |||||||
|         preFormContent: html, |         preFormContent: html, | ||||||
|         form_data: { |         form_data: { | ||||||
|             items: ids, |             items: ids, | ||||||
|  |             filters: options.filters, | ||||||
|         }, |         }, | ||||||
|         onSuccess: function() { |         onSuccess: function() { | ||||||
|             // Refresh the table once all attachments are deleted |             // Refresh the table once all attachments are deleted | ||||||
| @@ -128,6 +129,9 @@ function reloadAttachmentTable() { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* Load a table of attachments against a specific model. | ||||||
|  |  * Note that this is a 'generic' table which is used for multiple attachment model classes | ||||||
|  |  */ | ||||||
| function loadAttachmentTable(url, options) { | function loadAttachmentTable(url, options) { | ||||||
|  |  | ||||||
|     var table = options.table || '#attachment-table'; |     var table = options.table || '#attachment-table'; | ||||||
| @@ -141,7 +145,7 @@ function loadAttachmentTable(url, options) { | |||||||
|         var attachments = getTableData(table); |         var attachments = getTableData(table); | ||||||
|  |  | ||||||
|         if (attachments.length > 0) { |         if (attachments.length > 0) { | ||||||
|             deleteAttachments(attachments, url); |             deleteAttachments(attachments, url, options); | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
| @@ -182,7 +186,7 @@ function loadAttachmentTable(url, options) { | |||||||
|                 var pk = $(this).attr('pk'); |                 var pk = $(this).attr('pk'); | ||||||
|  |  | ||||||
|                 var attachment = $(table).bootstrapTable('getRowByUniqueId', pk); |                 var attachment = $(table).bootstrapTable('getRowByUniqueId', pk); | ||||||
|                 deleteAttachments([attachment], url); |                 deleteAttachments([attachment], url, options); | ||||||
|             }); |             }); | ||||||
|         }, |         }, | ||||||
|         columns: [ |         columns: [ | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| /* exported | /* exported | ||||||
|  |     loadNotificationTable, | ||||||
|     showAlertOrCache, |     showAlertOrCache, | ||||||
|     showCachedAlerts, |     showCachedAlerts, | ||||||
|     startNotificationWatcher, |     startNotificationWatcher, | ||||||
| @@ -9,6 +10,96 @@ | |||||||
|     closeNotificationPanel, |     closeNotificationPanel, | ||||||
| */ | */ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* | ||||||
|  |  * Load notification table | ||||||
|  |  */ | ||||||
|  | function loadNotificationTable(table, options={}, enableDelete=false) { | ||||||
|  |  | ||||||
|  |     var params = options.params || {}; | ||||||
|  |     var read = typeof(params.read) === 'undefined' ? true : params.read; | ||||||
|  |  | ||||||
|  |     setupFilterList(`notifications-${options.name}`, table); | ||||||
|  |  | ||||||
|  |     $(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 = `<a href='${value.link}'>${html}</a>`; | ||||||
|  |                     } | ||||||
|  |                     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 = `<button title='{% trans "Delete Notification" %}' class='notification-delete btn btn-outline-secondary' type='button' pk='${row.pk}'><span class='fas fa-trash-alt icon-red'></span></button>`; | ||||||
|  |                     } else { | ||||||
|  |                         var bDel = ''; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     var html = `<div class='btn-group float-right' role='group'>${bRead}${bDel}</div>`; | ||||||
|  |  | ||||||
|  |                     return html; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         ] | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     $(table).on('click', '.notification-read', function() { | ||||||
|  |         updateNotificationReadState($(this)); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
| /* | /* | ||||||
|  * Add a cached alert message to sesion storage |  * Add a cached alert message to sesion storage | ||||||
|  */ |  */ | ||||||
|   | |||||||
| @@ -36,6 +36,7 @@ | |||||||
|     first_name: "Alan" |     first_name: "Alan" | ||||||
|     last_name: "Allgroup" |     last_name: "Allgroup" | ||||||
|     is_active: false |     is_active: false | ||||||
|  |     is_staff: true | ||||||
|     groups: |     groups: | ||||||
|       - 1 |       - 1 | ||||||
|       - 2 |       - 2 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user