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
9b9dfde158
4
.github/workflows/qc_checks.yaml
vendored
4
.github/workflows/qc_checks.yaml
vendored
@ -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]
|
||||||
|
@ -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"))
|
||||||
|
@ -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."""
|
||||||
|
@ -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)
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
sanitizeData,
|
sanitizeData,
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
function attachClipboard(selector, containerselector, textElement) {
|
function attachClipboard(selector, containerselector, textElement) {
|
||||||
// set container
|
// set container
|
||||||
if (containerselector) {
|
if (containerselector) {
|
||||||
|
128
InvenTree/InvenTree/static/script/inventree/message.js
Normal file
128
InvenTree/InvenTree/static/script/inventree/message.js
Normal 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();
|
||||||
|
}
|
@ -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')
|
||||||
|
@ -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
|
||||||
]
|
]
|
||||||
|
@ -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")
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@ -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),
|
||||||
|
@ -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',
|
||||||
|
121
InvenTree/part/templatetags/i18n.py
Normal file
121
InvenTree/part/templatetags/i18n.py
Normal 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)
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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 %}
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user