diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml
index 9aa655545f..604e7d0369 100644
--- a/.github/workflows/qc_checks.yaml
+++ b/.github/workflows/qc_checks.yaml
@@ -57,8 +57,8 @@ jobs:
python3 check_js_templates.py
- name: Lint Javascript Files
run: |
- invoke render-js-files
- npx eslint js_tmp/*.js
+ python InvenTree/manage.py prerender
+ npx eslint InvenTree/InvenTree/static_i18n/i18n/*.js
html:
name: Style [HTML]
diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py
index d0d725c2f8..f4157fdad9 100644
--- a/InvenTree/InvenTree/forms.py
+++ b/InvenTree/InvenTree/forms.py
@@ -15,6 +15,7 @@ from allauth.account.forms import SignupForm, set_form_field_order
from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter
+from allauth_2fa.forms import TOTPDeviceRemoveForm
from allauth_2fa.utils import user_has_valid_totp_device
from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
PrependedText)
@@ -269,3 +270,36 @@ class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter):
# Otherwise defer to the original allauth adapter.
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"))
diff --git a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py
index a37be73b9c..28f17228e2 100644
--- a/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py
+++ b/InvenTree/InvenTree/management/commands/rebuild_thumbnails.py
@@ -4,9 +4,7 @@
"""
import logging
-import os
-from django.conf import settings
from django.core.management.base import BaseCommand
from django.db.utils import OperationalError, ProgrammingError
@@ -27,18 +25,15 @@ class Command(BaseCommand):
return
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:
- model.image.render_variations(replace=False)
- except FileNotFoundError:
- logger.warning(f"Warning: Image file '{img}' is missing")
- except UnidentifiedImageError:
- logger.warning(f"Warning: Image file '{img}' is not a valid image")
+ try:
+ model.image.render_variations(replace=False)
+ except FileNotFoundError:
+ logger.warning(f"Warning: Image file '{img}' is missing")
+ except UnidentifiedImageError:
+ logger.warning(f"Warning: Image file '{img}' is not a valid image")
def handle(self, *args, **kwargs):
"""Rebuild all thumbnail images."""
diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py
index e50c8fceae..d11f0920a0 100644
--- a/InvenTree/InvenTree/settings.py
+++ b/InvenTree/InvenTree/settings.py
@@ -218,18 +218,6 @@ logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'")
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
'build.apps.BuildConfig',
'common.apps.CommonConfig',
@@ -243,6 +231,18 @@ INSTALLED_APPS = [
'plugin.apps.PluginAppConfig',
'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
'django_filters', # Extended filter functionality
'rest_framework', # DRF (Django Rest Framework)
diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js
index df19ca06d2..df49d3d670 100644
--- a/InvenTree/InvenTree/static/script/inventree/inventree.js
+++ b/InvenTree/InvenTree/static/script/inventree/inventree.js
@@ -16,6 +16,7 @@
sanitizeData,
*/
+
function attachClipboard(selector, containerselector, textElement) {
// set container
if (containerselector) {
diff --git a/InvenTree/InvenTree/static/script/inventree/message.js b/InvenTree/InvenTree/static/script/inventree/message.js
new file mode 100644
index 0000000000..24bb8c7700
--- /dev/null
+++ b/InvenTree/InvenTree/static/script/inventree/message.js
@@ -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 = `
${options.details}
`;
+ }
+
+ // Hacky function to get the next available ID
+ var id = 1;
+
+ while ($(`#alert-${id}`).exists()) {
+ id++;
+ }
+
+ var icon = '';
+
+ if (options.icon) {
+ icon = ``;
+ }
+
+ // Construct the alert
+ var html = `
+
+ ${icon}
+ ${message}
+ ${details}
+
+
+ `;
+
+ 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();
+}
\ No newline at end of file
diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py
index 0e55f7a559..49e98815a5 100644
--- a/InvenTree/InvenTree/test_api.py
+++ b/InvenTree/InvenTree/test_api.py
@@ -28,10 +28,6 @@ class HTMLAPITests(InvenTreeTestCase):
response = self.client.get(url, HTTP_ACCEPT='application/json')
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):
"""Test that build list is working."""
url = reverse('api-build-list')
@@ -40,10 +36,6 @@ class HTMLAPITests(InvenTreeTestCase):
response = self.client.get(url, HTTP_ACCEPT='application/json')
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):
"""Test that stock list is working."""
url = reverse('api-stock-list')
@@ -52,10 +44,6 @@ class HTMLAPITests(InvenTreeTestCase):
response = self.client.get(url, HTTP_ACCEPT='application/json')
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):
"""Test that company list is working."""
url = reverse('api-company-list')
@@ -64,10 +52,6 @@ class HTMLAPITests(InvenTreeTestCase):
response = self.client.get(url, HTTP_ACCEPT='application/json')
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):
"""Test that the NotFoundView is working."""
response = self.client.get('/api/anc')
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 2ccbe6d259..3f32152a5f 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -35,9 +35,9 @@ from .views import (AboutView, AppearanceSelectView, CurrencyRefreshView,
CustomConnectionsView, CustomEmailView,
CustomPasswordResetFromKeyView,
CustomSessionDeleteOtherView, CustomSessionDeleteView,
- DatabaseStatsView, DynamicJsView, EditUserView, IndexView,
- NotificationsView, SearchView, SetPasswordView,
- SettingsView, auth_request)
+ CustomTwoFactorRemove, DatabaseStatsView, DynamicJsView,
+ EditUserView, IndexView, NotificationsView, SearchView,
+ SetPasswordView, SettingsView, auth_request)
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/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'),
re_path(r"^accounts/password/reset/key/(?P[0-9A-Za-z]+)-(?P.+)/$", 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.urls')), # included urlpatterns
]
diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index f2821813af..f18bf73106 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -28,6 +28,7 @@ from allauth.account.models import EmailAddress
from allauth.account.views import EmailView, PasswordResetFromKeyView
from allauth.socialaccount.forms import DisconnectForm
from allauth.socialaccount.views import ConnectionsView
+from allauth_2fa.views import TwoFactorRemove
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
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 users.models import RuleSet, check_user_role
-from .forms import EditUserForm, SetPasswordForm
+from .forms import CustomTOTPDeviceRemoveForm, EditUserForm, SetPasswordForm
def auth_request(request):
@@ -761,3 +762,12 @@ class NotificationsView(TemplateView):
"""View for showing notifications."""
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")
diff --git a/InvenTree/common/apps.py b/InvenTree/common/apps.py
index 331ea5bf7d..084b101bf6 100644
--- a/InvenTree/common/apps.py
+++ b/InvenTree/common/apps.py
@@ -4,6 +4,8 @@ import logging
from django.apps import AppConfig
+from InvenTree.ready import isImportingData
+
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):
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:
pass
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 22015676e9..038972e21a 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -37,6 +37,7 @@ from rest_framework.exceptions import PermissionDenied
import InvenTree.fields
import InvenTree.helpers
+import InvenTree.ready
import InvenTree.validators
logger = logging.getLogger('inventree')
@@ -748,7 +749,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
"""
super().save()
- if self.requires_restart():
+ if self.requires_restart() and not InvenTree.ready.isImportingData():
InvenTreeSetting.set_setting('SERVER_RESTART_REQUIRED', True, None)
"""
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 4c02ee34f6..38319a2e91 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -1318,6 +1318,11 @@ class Part(MetadataMixin, MPTTModel):
def allocation_count(self, **kwargs):
"""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(
[
self.build_order_allocation_count(**kwargs),
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 10ec44489a..4119b6fb92 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -909,7 +909,7 @@
},
{% 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)',
borderColor: 'rgb(68, 157, 68)',
yAxisID: 'y2',
@@ -950,7 +950,7 @@
{% if bom_parts %}
var bom_colors = randomColor({hue: 'green', count: {{ bom_parts|length }} })
var bomdata = {
- labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}],
+ labels: [{% for line in bom_parts %}'{{ line.name|escapejs }}',{% endfor %}],
datasets: [
{
label: 'Price',
diff --git a/InvenTree/part/templatetags/i18n.py b/InvenTree/part/templatetags/i18n.py
new file mode 100644
index 0000000000..fcd9df4628
--- /dev/null
+++ b/InvenTree/part/templatetags/i18n.py
@@ -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)
diff --git a/InvenTree/templates/account/base.html b/InvenTree/templates/account/base.html
index ef2398f327..4ac646b717 100644
--- a/InvenTree/templates/account/base.html
+++ b/InvenTree/templates/account/base.html
@@ -89,27 +89,20 @@
{% include "third_party_js.html" %}
-
-
-
+
diff --git a/InvenTree/templates/account/email_confirm.html b/InvenTree/templates/account/email_confirm.html
index 1bdd051fdc..b602126366 100644
--- a/InvenTree/templates/account/email_confirm.html
+++ b/InvenTree/templates/account/email_confirm.html
@@ -17,7 +17,10 @@
{% else %}
diff --git a/InvenTree/templates/allauth_2fa/authenticate.html b/InvenTree/templates/allauth_2fa/authenticate.html
index b27b5df81c..d28ef77f51 100644
--- a/InvenTree/templates/allauth_2fa/authenticate.html
+++ b/InvenTree/templates/allauth_2fa/authenticate.html
@@ -8,7 +8,8 @@
{% csrf_token %}
{{ form|crispy }}
-