Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-06-25 12:02:30 +10:00
commit 9b9dfde158
21 changed files with 352 additions and 189 deletions

View File

@ -57,8 +57,8 @@ jobs:
python3 check_js_templates.py python3 check_js_templates.py
- name: Lint Javascript Files - name: Lint Javascript Files
run: | run: |
invoke render-js-files python InvenTree/manage.py prerender
npx eslint js_tmp/*.js npx eslint InvenTree/InvenTree/static_i18n/i18n/*.js
html: html:
name: Style [HTML] name: Style [HTML]

View File

@ -15,6 +15,7 @@ from allauth.account.forms import SignupForm, set_form_field_order
from allauth.exceptions import ImmediateHttpResponse from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter from allauth_2fa.adapter import OTPAdapter
from allauth_2fa.forms import TOTPDeviceRemoveForm
from allauth_2fa.utils import user_has_valid_totp_device from allauth_2fa.utils import user_has_valid_totp_device
from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText, from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
PrependedText) PrependedText)
@ -269,3 +270,36 @@ class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter):
# Otherwise defer to the original allauth adapter. # Otherwise defer to the original allauth adapter.
return super().login(request, user) return super().login(request, user)
# Temporary fix for django-allauth-2fa # TODO remove
# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
class CustomTOTPDeviceRemoveForm(TOTPDeviceRemoveForm):
"""Custom Form to ensure a token is provided before removing MFA"""
# User must input a valid token so 2FA can be removed
token = forms.CharField(
label=_('Token'),
)
def __init__(self, user, **kwargs):
"""Add token field."""
super().__init__(user, **kwargs)
self.fields['token'].widget.attrs.update(
{
'autofocus': 'autofocus',
'autocomplete': 'off',
}
)
def clean_token(self):
"""Ensure at least one valid token is provided."""
# Ensure that the user has provided a valid token
token = self.cleaned_data.get('token')
# Verify that the user has provided a valid token
for device in self.user.totpdevice_set.filter(confirmed=True):
if device.verify_token(token):
return token
raise forms.ValidationError(_("The entered token is not valid"))

View File

@ -4,9 +4,7 @@
""" """
import logging import logging
import os
from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
@ -27,18 +25,15 @@ class Command(BaseCommand):
return return
img = model.image img = model.image
url = img.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url)
if not os.path.exists(loc): logger.info(f"Generating thumbnail image for '{img}'")
logger.info(f"Generating thumbnail image for '{img}'")
try: try:
model.image.render_variations(replace=False) model.image.render_variations(replace=False)
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"Warning: Image file '{img}' is missing") logger.warning(f"Warning: Image file '{img}' is missing")
except UnidentifiedImageError: except UnidentifiedImageError:
logger.warning(f"Warning: Image file '{img}' is not a valid image") logger.warning(f"Warning: Image file '{img}' is not a valid image")
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
"""Rebuild all thumbnail images.""" """Rebuild all thumbnail images."""

View File

@ -218,18 +218,6 @@ logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
INSTALLED_APPS = [ INSTALLED_APPS = [
# Core django modules
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'user_sessions', # db user sessions
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
# Maintenance
'maintenance_mode',
# InvenTree apps # InvenTree apps
'build.apps.BuildConfig', 'build.apps.BuildConfig',
'common.apps.CommonConfig', 'common.apps.CommonConfig',
@ -243,6 +231,18 @@ INSTALLED_APPS = [
'plugin.apps.PluginAppConfig', 'plugin.apps.PluginAppConfig',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last 'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
# Core django modules
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'user_sessions', # db user sessions
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
# Maintenance
'maintenance_mode',
# Third part add-ons # Third part add-ons
'django_filters', # Extended filter functionality 'django_filters', # Extended filter functionality
'rest_framework', # DRF (Django Rest Framework) 'rest_framework', # DRF (Django Rest Framework)

View File

@ -16,6 +16,7 @@
sanitizeData, sanitizeData,
*/ */
function attachClipboard(selector, containerselector, textElement) { function attachClipboard(selector, containerselector, textElement) {
// set container // set container
if (containerselector) { if (containerselector) {

View File

@ -0,0 +1,128 @@
/* exported
showMessage,
showAlertOrCache,
showCachedAlerts,
*/
/*
* Display an alert message at the top of the screen.
* The message will contain a "close" button,
* and also dismiss automatically after a certain amount of time.
*
* arguments:
* - message: Text / HTML content to display
*
* options:
* - style: alert style e.g. 'success' / 'warning'
* - timeout: Time (in milliseconds) after which the message will be dismissed
*/
function showMessage(message, options={}) {
var style = options.style || 'info';
var timeout = options.timeout || 5000;
var target = options.target || $('#alerts');
var details = '';
if (options.details) {
details = `<p><small>${options.details}</p></small>`;
}
// Hacky function to get the next available ID
var id = 1;
while ($(`#alert-${id}`).exists()) {
id++;
}
var icon = '';
if (options.icon) {
icon = `<span class='${options.icon}'></span>`;
}
// Construct the alert
var html = `
<div id='alert-${id}' class='alert alert-${style} alert-dismissible fade show' role='alert'>
${icon}
<b>${message}</b>
${details}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
target.append(html);
// Remove the alert automatically after a specified period of time
$(`#alert-${id}`).delay(timeout).slideUp(200, function() {
$(this).alert(close);
});
}
/*
* Add a cached alert message to sesion storage
*/
function addCachedAlert(message, options={}) {
var alerts = sessionStorage.getItem('inventree-alerts');
if (alerts) {
alerts = JSON.parse(alerts);
} else {
alerts = [];
}
alerts.push({
message: message,
style: options.style || 'success',
icon: options.icon,
});
sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts));
}
/*
* Remove all cached alert messages
*/
function clearCachedAlerts() {
sessionStorage.removeItem('inventree-alerts');
}
/*
* Display an alert, or cache to display on reload
*/
function showAlertOrCache(message, cache, options={}) {
if (cache) {
addCachedAlert(message, options);
} else {
showMessage(message, options);
}
}
/*
* Display cached alert messages when loading a page
*/
function showCachedAlerts() {
var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || [];
alerts.forEach(function(alert) {
showMessage(
alert.message,
{
style: alert.style || 'success',
icon: alert.icon,
}
);
});
clearCachedAlerts();
}

View File

@ -28,10 +28,6 @@ class HTMLAPITests(InvenTreeTestCase):
response = self.client.get(url, HTTP_ACCEPT='application/json') response = self.client.get(url, HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Check HTTP response
response = self.client.get(url, HTTP_ACCEPT='text/html')
self.assertEqual(response.status_code, 200)
def test_build_api(self): def test_build_api(self):
"""Test that build list is working.""" """Test that build list is working."""
url = reverse('api-build-list') url = reverse('api-build-list')
@ -40,10 +36,6 @@ class HTMLAPITests(InvenTreeTestCase):
response = self.client.get(url, HTTP_ACCEPT='application/json') response = self.client.get(url, HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Check HTTP response
response = self.client.get(url, HTTP_ACCEPT='text/html')
self.assertEqual(response.status_code, 200)
def test_stock_api(self): def test_stock_api(self):
"""Test that stock list is working.""" """Test that stock list is working."""
url = reverse('api-stock-list') url = reverse('api-stock-list')
@ -52,10 +44,6 @@ class HTMLAPITests(InvenTreeTestCase):
response = self.client.get(url, HTTP_ACCEPT='application/json') response = self.client.get(url, HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Check HTTP response
response = self.client.get(url, HTTP_ACCEPT='text/html')
self.assertEqual(response.status_code, 200)
def test_company_list(self): def test_company_list(self):
"""Test that company list is working.""" """Test that company list is working."""
url = reverse('api-company-list') url = reverse('api-company-list')
@ -64,10 +52,6 @@ class HTMLAPITests(InvenTreeTestCase):
response = self.client.get(url, HTTP_ACCEPT='application/json') response = self.client.get(url, HTTP_ACCEPT='application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Check HTTP response
response = self.client.get(url, HTTP_ACCEPT='text/html')
self.assertEqual(response.status_code, 200)
def test_not_found(self): def test_not_found(self):
"""Test that the NotFoundView is working.""" """Test that the NotFoundView is working."""
response = self.client.get('/api/anc') response = self.client.get('/api/anc')

View File

@ -35,9 +35,9 @@ from .views import (AboutView, AppearanceSelectView, CurrencyRefreshView,
CustomConnectionsView, CustomEmailView, CustomConnectionsView, CustomEmailView,
CustomPasswordResetFromKeyView, CustomPasswordResetFromKeyView,
CustomSessionDeleteOtherView, CustomSessionDeleteView, CustomSessionDeleteOtherView, CustomSessionDeleteView,
DatabaseStatsView, DynamicJsView, EditUserView, IndexView, CustomTwoFactorRemove, DatabaseStatsView, DynamicJsView,
NotificationsView, SearchView, SetPasswordView, EditUserView, IndexView, NotificationsView, SearchView,
SettingsView, auth_request) SetPasswordView, SettingsView, auth_request)
admin.site.site_header = "InvenTree Admin" admin.site.site_header = "InvenTree Admin"
@ -164,6 +164,11 @@ frontendpatterns = [
re_path(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'), re_path(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'),
re_path(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'), re_path(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'),
re_path(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"), re_path(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"),
# Temporary fix for django-allauth-2fa # TODO remove
# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
re_path(r'^accounts/two_factor/remove/?$', CustomTwoFactorRemove.as_view(), name='two-factor-remove'),
re_path(r'^accounts/', include('allauth_2fa.urls')), # MFA support re_path(r'^accounts/', include('allauth_2fa.urls')), # MFA support
re_path(r'^accounts/', include('allauth.urls')), # included urlpatterns re_path(r'^accounts/', include('allauth.urls')), # included urlpatterns
] ]

View File

@ -28,6 +28,7 @@ from allauth.account.models import EmailAddress
from allauth.account.views import EmailView, PasswordResetFromKeyView from allauth.account.views import EmailView, PasswordResetFromKeyView
from allauth.socialaccount.forms import DisconnectForm from allauth.socialaccount.forms import DisconnectForm
from allauth.socialaccount.views import ConnectionsView from allauth.socialaccount.views import ConnectionsView
from allauth_2fa.views import TwoFactorRemove
from djmoney.contrib.exchange.models import ExchangeBackend, Rate from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from user_sessions.views import SessionDeleteOtherView, SessionDeleteView from user_sessions.views import SessionDeleteOtherView, SessionDeleteView
@ -36,7 +37,7 @@ from common.settings import currency_code_default, currency_codes
from part.models import PartCategory from part.models import PartCategory
from users.models import RuleSet, check_user_role from users.models import RuleSet, check_user_role
from .forms import EditUserForm, SetPasswordForm from .forms import CustomTOTPDeviceRemoveForm, EditUserForm, SetPasswordForm
def auth_request(request): def auth_request(request):
@ -761,3 +762,12 @@ class NotificationsView(TemplateView):
"""View for showing notifications.""" """View for showing notifications."""
template_name = "InvenTree/notifications/notifications.html" template_name = "InvenTree/notifications/notifications.html"
# Temporary fix for django-allauth-2fa # TODO remove
# See https://github.com/inventree/InvenTree/security/advisories/GHSA-8j76-mm54-52xq
class CustomTwoFactorRemove(TwoFactorRemove):
"""Use custom form."""
form_class = CustomTOTPDeviceRemoveForm
success_url = reverse_lazy("settings")

View File

@ -4,6 +4,8 @@ import logging
from django.apps import AppConfig from django.apps import AppConfig
from InvenTree.ready import isImportingData
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -26,6 +28,8 @@ class CommonConfig(AppConfig):
if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED', backup_value=False, create=False, cache=False): if common.models.InvenTreeSetting.get_setting('SERVER_RESTART_REQUIRED', backup_value=False, create=False, cache=False):
logger.info("Clearing SERVER_RESTART_REQUIRED flag") logger.info("Clearing SERVER_RESTART_REQUIRED flag")
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)
if not isImportingData():
common.models.InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', False, None)
except Exception: except Exception:
pass pass

View File

@ -37,6 +37,7 @@ from rest_framework.exceptions import PermissionDenied
import InvenTree.fields import InvenTree.fields
import InvenTree.helpers import InvenTree.helpers
import InvenTree.ready
import InvenTree.validators import InvenTree.validators
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -748,7 +749,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
""" """
super().save() super().save()
if self.requires_restart(): if self.requires_restart() and not InvenTree.ready.isImportingData():
InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None) InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None)
""" """

View File

@ -1318,6 +1318,11 @@ class Part(MetadataMixin, MPTTModel):
def allocation_count(self, **kwargs): def allocation_count(self, **kwargs):
"""Return the total quantity of stock allocated for this part, against both build orders and sales orders.""" """Return the total quantity of stock allocated for this part, against both build orders and sales orders."""
if self.id is None:
# If this instance has not been saved, foreign-key lookups will fail
return 0
return sum( return sum(
[ [
self.build_order_allocation_count(**kwargs), self.build_order_allocation_count(**kwargs),

View File

@ -909,7 +909,7 @@
}, },
{% if 'price_diff' in price_history.0 %} {% if 'price_diff' in price_history.0 %}
{ {
label: '{% blocktrans %}Unit Price-Cost Difference - {{currency}}{% endblocktrans %}', label: '{% blocktrans %}Unit Price-Cost Difference - {{ currency }}{% endblocktrans %}',
backgroundColor: 'rgba(68, 157, 68, 0.2)', backgroundColor: 'rgba(68, 157, 68, 0.2)',
borderColor: 'rgb(68, 157, 68)', borderColor: 'rgb(68, 157, 68)',
yAxisID: 'y2', yAxisID: 'y2',
@ -950,7 +950,7 @@
{% if bom_parts %} {% if bom_parts %}
var bom_colors = randomColor({hue: 'green', count: {{ bom_parts|length }} }) var bom_colors = randomColor({hue: 'green', count: {{ bom_parts|length }} })
var bomdata = { var bomdata = {
labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}], labels: [{% for line in bom_parts %}'{{ line.name|escapejs }}',{% endfor %}],
datasets: [ datasets: [
{ {
label: 'Price', label: 'Price',

View File

@ -0,0 +1,121 @@
"""This module provides custom translation tags specifically for use with javascript code.
Translated strings are escaped, such that they can be used as string literals in a javascript file.
"""
import django.templatetags.i18n
from django import template
from django.template import TemplateSyntaxError
from django.templatetags.i18n import TranslateNode
import bleach
register = template.Library()
class CustomTranslateNode(TranslateNode):
"""Custom translation node class, which sanitizes the translated strings for javascript use"""
def render(self, context):
"""Custom render function overrides / extends default behaviour"""
result = super().render(context)
result = bleach.clean(result)
# Remove any escape sequences
for seq in ['\a', '\b', '\f', '\n', '\r', '\t', '\v']:
result = result.replace(seq, '')
# Remove other disallowed characters
for c in ['\\', '`', ';', '|', '&']:
result = result.replace(c, '')
# Escape any quotes contained in the string
result = result.replace("'", r"\'")
result = result.replace('"', r'\"')
# Return the 'clean' resulting string
return result
@register.tag("translate")
@register.tag("trans")
def do_translate(parser, token):
"""Custom translation function, lifted from https://github.com/django/django/blob/main/django/templatetags/i18n.py
The only difference is that we pass this to our custom rendering node class
"""
bits = token.split_contents()
if len(bits) < 2:
raise TemplateSyntaxError("'%s' takes at least one argument" % bits[0])
message_string = parser.compile_filter(bits[1])
remaining = bits[2:]
noop = False
asvar = None
message_context = None
seen = set()
invalid_context = {"as", "noop"}
while remaining:
option = remaining.pop(0)
if option in seen:
raise TemplateSyntaxError(
"The '%s' option was specified more than once." % option,
)
elif option == "noop":
noop = True
elif option == "context":
try:
value = remaining.pop(0)
except IndexError:
raise TemplateSyntaxError(
"No argument provided to the '%s' tag for the context option."
% bits[0]
)
if value in invalid_context:
raise TemplateSyntaxError(
"Invalid argument '%s' provided to the '%s' tag for the context "
"option" % (value, bits[0]),
)
message_context = parser.compile_filter(value)
elif option == "as":
try:
value = remaining.pop(0)
except IndexError:
raise TemplateSyntaxError(
"No argument provided to the '%s' tag for the as option." % bits[0]
)
asvar = value
else:
raise TemplateSyntaxError(
"Unknown argument for '%s' tag: '%s'. The only options "
"available are 'noop', 'context' \"xxx\", and 'as VAR'."
% (
bits[0],
option,
)
)
seen.add(option)
return CustomTranslateNode(message_string, noop, asvar, message_context)
# Re-register tags which we have not explicitly overridden
register.tag("blocktrans", django.templatetags.i18n.do_block_translate)
register.tag("blocktranslate", django.templatetags.i18n.do_block_translate)
register.tag("language", django.templatetags.i18n.language)
register.tag("get_available_languages", django.templatetags.i18n.do_get_available_languages)
register.tag("get_language_info", django.templatetags.i18n.do_get_language_info)
register.tag("get_language_info_list", django.templatetags.i18n.do_get_language_info_list)
register.tag("get_current_language", django.templatetags.i18n.do_get_current_language)
register.tag("get_current_language_bidi", django.templatetags.i18n.do_get_current_language_bidi)
register.filter("language_name", django.templatetags.i18n.language_name)
register.filter("language_name_translated", django.templatetags.i18n.language_name_translated)
register.filter("language_name_local", django.templatetags.i18n.language_name_local)
register.filter("language_bidi", django.templatetags.i18n.language_bidi)

View File

@ -89,27 +89,20 @@
<!-- general JS --> <!-- general JS -->
{% include "third_party_js.html" %} {% include "third_party_js.html" %}
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script> <script type='text/javascript' src='{% static "script/inventree/message.js" %}'></script>
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
<script type='text/javascript'> <script type='text/javascript'>
$(document).ready(function () { $(document).ready(function () {
// notifications
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
showAlertOrCache( showMessage(messsage);
'{{ message }}',
true,
{
style: 'info',
}
);
{% endfor %} {% endfor %}
{% endif %} {% endif %}
inventreeDocReady(); showCachedAlerts();
}); });
</script> </script>

View File

@ -17,7 +17,10 @@
<form method="post" action="{% url 'account_confirm_email' confirmation.key %}"> <form method="post" action="{% url 'account_confirm_email' confirmation.key %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-primary btn-block">{% trans 'Confirm' %}</button> <hr>
<button type="submit" class="btn btn-success float-right">
<span class='fas fa-check-circle'></span> {% trans 'Confirm' %}
</button>
</form> </form>
{% else %} {% else %}

View File

@ -8,7 +8,8 @@
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
<button type="submit" class="btn btn-primary"> <hr>
<button type="submit" class="btn btn-primary float-right">
<span class='fas fa-check-circle'></span> {% trans 'Authenticate' %} <span class='fas fa-check-circle'></span> {% trans 'Authenticate' %}
</button> </button>
</form> </form>

View File

@ -141,6 +141,7 @@
<!-- general JS --> <!-- general JS -->
<script defer type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script> <script defer type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script defer type='text/javascript' src="{% static 'script/inventree/message.js' %}"></script>
<!-- dynamic javascript templates --> <!-- dynamic javascript templates -->
<script defer type='text/javascript' src="{% url 'calendar.js' %}"></script> <script defer type='text/javascript' src="{% url 'calendar.js' %}"></script>

View File

@ -2,8 +2,6 @@
/* exported /* exported
loadNotificationTable, loadNotificationTable,
showAlertOrCache,
showCachedAlerts,
startNotificationWatcher, startNotificationWatcher,
stopNotificationWatcher, stopNotificationWatcher,
openNotificationPanel, openNotificationPanel,
@ -100,128 +98,6 @@ function loadNotificationTable(table, options={}, enableDelete=false) {
} }
/*
* Add a cached alert message to sesion storage
*/
function addCachedAlert(message, options={}) {
var alerts = sessionStorage.getItem('inventree-alerts');
if (alerts) {
alerts = JSON.parse(alerts);
} else {
alerts = [];
}
alerts.push({
message: message,
style: options.style || 'success',
icon: options.icon,
});
sessionStorage.setItem('inventree-alerts', JSON.stringify(alerts));
}
/*
* Remove all cached alert messages
*/
function clearCachedAlerts() {
sessionStorage.removeItem('inventree-alerts');
}
/*
* Display an alert, or cache to display on reload
*/
function showAlertOrCache(message, cache, options={}) {
if (cache) {
addCachedAlert(message, options);
} else {
showMessage(message, options);
}
}
/*
* Display cached alert messages when loading a page
*/
function showCachedAlerts() {
var alerts = JSON.parse(sessionStorage.getItem('inventree-alerts')) || [];
alerts.forEach(function(alert) {
showMessage(
alert.message,
{
style: alert.style || 'success',
icon: alert.icon,
}
);
});
clearCachedAlerts();
}
/*
* Display an alert message at the top of the screen.
* The message will contain a "close" button,
* and also dismiss automatically after a certain amount of time.
*
* arguments:
* - message: Text / HTML content to display
*
* options:
* - style: alert style e.g. 'success' / 'warning'
* - timeout: Time (in milliseconds) after which the message will be dismissed
*/
function showMessage(message, options={}) {
var style = options.style || 'info';
var timeout = options.timeout || 5000;
var target = options.target || $('#alerts');
var details = '';
if (options.details) {
details = `<p><small>${options.details}</p></small>`;
}
// Hacky function to get the next available ID
var id = 1;
while ($(`#alert-${id}`).exists()) {
id++;
}
var icon = '';
if (options.icon) {
icon = `<span class='${options.icon}'></span>`;
}
// Construct the alert
var html = `
<div id='alert-${id}' class='alert alert-${style} alert-dismissible fade show' role='alert'>
${icon}
<b>${message}</b>
${details}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
target.append(html);
// Remove the alert automatically after a specified period of time
$(`#alert-${id}`).delay(timeout).slideUp(200, function() {
$(this).alert(close);
});
}
var notificationWatcher = null; // reference for the notificationWatcher var notificationWatcher = null; // reference for the notificationWatcher
/** /**
* start the regular notification checks * start the regular notification checks

View File

@ -70,6 +70,7 @@
<!-- general JS --> <!-- general JS -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/message.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script> <script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
{% block body_scripts_inventree %} {% block body_scripts_inventree %}
{% endblock %} {% endblock %}

View File

@ -8,7 +8,7 @@ coveralls==2.1.2 # Coveralls linking (for Travis)
cryptography==3.4.8 # Cryptography support cryptography==3.4.8 # Cryptography support
django-admin-shell==0.1.2 # Python shell for the admin interface django-admin-shell==0.1.2 # Python shell for the admin interface
django-allauth==0.48.0 # SSO for external providers via OpenID django-allauth==0.48.0 # SSO for external providers via OpenID
django-allauth-2fa==0.9 # MFA / 2FA django-allauth-2fa==0.9 # MFA / 2FA # IMPORTANT: Do only change after reviewing GHSA-8j76-mm54-52xq
django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files
django-cors-headers==3.2.0 # CORS headers extension for DRF django-cors-headers==3.2.0 # CORS headers extension for DRF
django-crispy-forms==1.11.2 # Form helpers django-crispy-forms==1.11.2 # Form helpers