mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
4b66bf864e
1
.github/workflows/docker.yaml
vendored
1
.github/workflows/docker.yaml
vendored
@ -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
|
||||||
|
4
.github/workflows/qc_checks.yaml
vendored
4
.github/workflows/qc_checks.yaml
vendored
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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))
|
||||||
|
@ -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.
|
||||||
|
@ -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' %}",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -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 %}
|
|
@ -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)
|
||||||
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
@ -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')
|
|
||||||
|
@ -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."""
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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}'!")
|
||||||
|
Loading…
Reference in New Issue
Block a user