Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-06-08 07:55:51 +10:00
commit 4b66bf864e
32 changed files with 472 additions and 234 deletions

View File

@ -35,6 +35,7 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Version Check - name: Version Check
run: | run: |
pip install requests
python3 ci/version_check.py python3 ci/version_check.py
echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV echo "git_commit_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV echo "git_commit_date=$(git show -s --format=%ci)" >> $GITHUB_ENV

View File

@ -91,6 +91,10 @@ jobs:
cache: 'pip' cache: 'pip'
- name: Run pre-commit Checks - name: Run pre-commit Checks
uses: pre-commit/action@v2.0.3 uses: pre-commit/action@v2.0.3
- name: Check Version
run: |
pip install requests
python3 ci/version_check.py
python: python:
name: Tests - inventree-python name: Tests - inventree-python

View File

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

View File

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

View File

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

View File

@ -9,7 +9,6 @@ import traceback
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.debug import ExceptionReporter
import rest_framework.views as drfviews import rest_framework.views as drfviews
from error_report.models import Error from error_report.models import Error
@ -18,6 +17,23 @@ from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework.response import Response 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): def exception_handler(exc, context):
"""Custom exception handler for DRF framework. """Custom exception handler for DRF framework.
@ -55,16 +71,7 @@ def exception_handler(exc, context):
response = Response(response_data, status=500) response = Response(response_data, status=500)
# Log the exception to the database, too log_error(context['request'].path)
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(),
)
if response is not None: if response is not None:
# Convert errors returned under the label '__all__' to 'non_field_errors' # Convert errors returned under the label '__all__' to 'non_field_errors'

View File

@ -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()
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) url_fnc = getattr(item, 'get_absolute_url', None)
# create output # create output

View File

@ -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,33 +70,36 @@ 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
# Map the request method to a permission type # Map the request method to a permission type
rolemap = { rolemap = {
'POST': 'add', 'POST': 'add',
'PUT': 'change', 'PUT': 'change',
'PATCH': 'change', 'PATCH': 'change',
'DELETE': 'delete', 'DELETE': 'delete',
} }
# Remove any HTTP methods that the user does not have permission for # Remove any HTTP methods that the user does not have permission for
for method, permission in rolemap.items(): for method, permission in rolemap.items():
result = check(user, table, permission) result = check(user, table, permission)
if method in actions and not result: if method in actions and not result:
del actions[method] del actions[method]
# Add a 'DELETE' action if we are allowed to delete # Add a 'DELETE' action if we are allowed to delete
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'): if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
actions['DELETE'] = True actions['DELETE'] = True
# Add a 'VIEW' action if we are allowed to view # Add a 'VIEW' action if we are allowed to view
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

View File

@ -475,6 +475,7 @@ def after_error_logged(sender, instance: Error, created: bool, **kwargs):
'inventree.error_log', 'inventree.error_log',
context=context, context=context,
targets=users, targets=users,
delivery_methods=set([common.notifications.UIMessageNotification]),
) )
except Exception as exc: except Exception as exc:

View File

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

View File

@ -1,8 +1,10 @@
"""JSON API for the Build app.""" """JSON API for the Build app."""
from django.urls import include, re_path from django.urls import include, re_path
from django.utils.translation import gettext_lazy as _
from rest_framework import filters, generics from rest_framework import filters, generics
from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters from django_filters import rest_framework as rest_filters
@ -198,12 +200,24 @@ class BuildList(APIDownloadMixin, generics.ListCreateAPIView):
return self.serializer_class(*args, **kwargs) return self.serializer_class(*args, **kwargs)
class BuildDetail(generics.RetrieveUpdateAPIView): class BuildDetail(generics.RetrieveUpdateDestroyAPIView):
"""API endpoint for detail view of a Build object.""" """API endpoint for detail view of a Build object."""
queryset = Build.objects.all() queryset = Build.objects.all()
serializer_class = build.serializers.BuildSerializer 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): class BuildUnallocate(generics.CreateAPIView):
"""API endpoint for unallocating stock items from a build order. """API endpoint for unallocating stock items from a build order.

View File

@ -249,9 +249,11 @@ src="{% static 'img/blank_image.png' %}"
{% endif %} {% endif %}
$("#build-delete").on('click', function() { $("#build-delete").on('click', function() {
launchModalForm( constructForm(
"{% url 'build-delete' build.id %}", '{% url "api-build-detail" build.pk %}',
{ {
method: 'DELETE',
title: '{% trans "Delete Build Order" %}',
redirect: "{% url 'build-index' %}", redirect: "{% url 'build-index' %}",
} }
); );

View File

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

View File

@ -90,7 +90,7 @@ class BuildAPITest(InvenTreeAPITestCase):
# Required roles to access Build API endpoints # Required roles to access Build API endpoints
roles = [ roles = [
'build.change', 'build.change',
'build.add' 'build.add',
] ]
@ -268,6 +268,39 @@ class BuildTest(BuildAPITest):
self.assertEqual(bo.status, BuildStatus.CANCELLED) 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): def test_create_delete_output(self):
"""Test that we can create and delete build outputs via the API.""" """Test that we can create and delete build outputs via the API."""
bo = Build.objects.get(pk=1) bo = Build.objects.get(pk=1)

View File

@ -4,15 +4,12 @@ from django.urls import include, re_path
from . import views 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 = [ build_urls = [
re_path(r'^(?P<pk>\d+)/', include(build_detail_urls)), re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^.*$', views.BuildDetail.as_view(), name='build-detail'),
])),
re_path(r'.*$', views.BuildIndex.as_view(), name='build-index'), re_path(r'.*$', views.BuildIndex.as_view(), name='build-index'),
] ]

View File

@ -1,11 +1,9 @@
"""Django views for interacting with Build objects.""" """Django views for interacting with Build objects."""
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from .models import Build from .models import Build
from InvenTree.views import AjaxDeleteView
from InvenTree.views import InvenTreeRoleMixin from InvenTree.views import InvenTreeRoleMixin
from InvenTree.status_codes import BuildStatus 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() ctx['has_untracked_bom_items'] = build.has_untracked_bom_items()
return ctx 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')

View File

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

View File

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

View File

@ -3,7 +3,6 @@
import logging import logging
import os import os
import sys import sys
import traceback
from datetime import datetime from datetime import datetime
from decimal import Decimal 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.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money from djmoney.money import Money
from error_report.models import Error
from markdownx.models import MarkdownxField from markdownx.models import MarkdownxField
from mptt.models import TreeForeignKey from mptt.models import TreeForeignKey
@ -29,6 +27,7 @@ import InvenTree.helpers
import InvenTree.ready import InvenTree.ready
from common.settings import currency_code_default from common.settings import currency_code_default
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from InvenTree.exceptions import log_error
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
from InvenTree.helpers import (decimal2string, getSetting, increment, from InvenTree.helpers import (decimal2string, getSetting, increment,
notify_responsible) notify_responsible)
@ -186,13 +185,7 @@ class Order(MetadataMixin, ReferenceIndexingMixin):
# Record the error, try to press on # Record the error, try to press on
kind, info, data = sys.exc_info() kind, info, data = sys.exc_info()
Error.objects.create( log_error('order.get_total_price')
kind=kind.__name__,
info=info,
data='\n'.join(traceback.format_exception(kind, info, data)),
path='order.get_total_price',
)
logger.error(f"Missing exchange rate for '{target_currency}'") logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid # 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) total += line.quantity * convert_money(line.price, target_currency)
except MissingRate: except MissingRate:
# Record the error, try to press on # 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}'") logger.error(f"Missing exchange rate for '{target_currency}'")
# Return None to indicate the calculated price is invalid # Return None to indicate the calculated price is invalid

View File

@ -616,6 +616,8 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase):
# reload notification methods # reload notification methods
storage.collect(run_class) storage.collect(run_class)
NotificationEntry.objects.all().delete()
# There should be no notification runs # There should be no notification runs
self.assertEqual(NotificationEntry.objects.all().count(), 0) self.assertEqual(NotificationEntry.objects.all().count(), 0)
@ -630,8 +632,8 @@ class BaseNotificationIntegrationTest(InvenTreeTestCase):
self.part.set_starred(self.user, True) self.part.set_starred(self.user, True)
self.part.save() self.part.save()
# There should be 1 notification # There should be 1 (or 2) notifications - in some cases an error is generated, which creates a subsequent notification
self.assertEqual(NotificationEntry.objects.all().count(), 1) self.assertIn(NotificationEntry.objects.all().count(), [1, 2])
class PartNotificationTest(BaseNotificationIntegrationTest): class PartNotificationTest(BaseNotificationIntegrationTest):

View File

@ -1,17 +1,14 @@
"""Functions to print a label to a mixin printer.""" """Functions to print a label to a mixin printer."""
import logging import logging
import sys
import traceback
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.debug import ExceptionReporter
import pdf2image import pdf2image
from error_report.models import Error
import common.notifications import common.notifications
from InvenTree.exceptions import log_error
from plugin.registry import registry from plugin.registry import registry
logger = logging.getLogger('inventree') 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 # Log an error message to the database
kind, info, data = sys.exc_info() log_error('plugin.print_label')
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(),
)
logger.error(f"Label printing failed: Sending notification to user '{user}'") # pragma: no cover logger.error(f"Label printing failed: Sending notification to user '{user}'") # pragma: no cover
# Throw an error against the plugin instance # Throw an error against the plugin instance

View File

@ -1,14 +1,10 @@
"""Views for plugin app.""" """Views for plugin app."""
import logging import logging
import sys
import traceback
from django.conf import settings 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 from plugin.registry import registry
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -29,18 +25,8 @@ class InvenTreePluginViewMixin:
try: try:
panels += plug.render_panels(self, self.request, ctx) panels += plug.render_panels(self, self.request, ctx)
except Exception: except Exception:
# Prevent any plugin error from crashing the page render
kind, info, data = sys.exc_info()
# Log the error to the database # Log the error to the database
Error.objects.create( log_error(self.request.path)
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(),
)
logger.error(f"Plugin '{plug.slug}' could not render custom panels at '{self.request.path}'") logger.error(f"Plugin '{plug.slug}' could not render custom panels at '{self.request.path}'")
return panels return panels

View File

@ -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" %}',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -114,7 +114,7 @@ if __name__ == '__main__':
docker_tags = None docker_tags = None
if GITHUB_REF_TYPE == 'tag': if GITHUB_REF_TYPE == 'tag':
# GITHUB_REF should be of th eform /refs/heads/<tag> # GITHUB_REF should be of the form /refs/heads/<tag>
version_tag = GITHUB_REF.split('/')[-1] version_tag = GITHUB_REF.split('/')[-1]
print(f"Checking requirements for tagged release - '{version_tag}':") print(f"Checking requirements for tagged release - '{version_tag}':")
@ -122,8 +122,6 @@ if __name__ == '__main__':
print(f"Version number '{version}' does not match tag '{version_tag}'") print(f"Version number '{version}' does not match tag '{version_tag}'")
sys.exit sys.exit
# TODO: Check if there is already a release with this tag!
if highest_release: if highest_release:
docker_tags = [version_tag, 'stable'] docker_tags = [version_tag, 'stable']
else: else:
@ -131,17 +129,6 @@ if __name__ == '__main__':
elif GITHUB_REF_TYPE == 'branch': elif GITHUB_REF_TYPE == 'branch':
# Otherwise we know we are targetting the 'master' branch # Otherwise we know we are targetting the 'master' branch
print("Checking requirements for 'master' development branch:")
pattern = r"^\d+(\.\d+)+ dev$"
result = re.match(pattern, version)
if result is None:
print(f"Version number '{version}' does not match required pattern for development branch")
sys.exit(1)
else:
print(f"Version number '{version}' matches development branch")
docker_tags = ['latest'] docker_tags = ['latest']
else: else:
@ -153,7 +140,7 @@ if __name__ == '__main__':
sys.exit(1) sys.exit(1)
if docker_tags is None: if docker_tags is None:
print("Docker tag could not be determined") print("Docker tags could not be determined")
sys.exit(1) sys.exit(1)
print(f"Version check passed for '{version}'!") print(f"Version check passed for '{version}'!")