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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@
sanitizeData,
*/
function attachClipboard(selector, containerselector, textElement) {
// set container
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')
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')

View File

@ -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<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.urls')), # included urlpatterns
]

View File

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

View File

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

View File

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

View File

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

View File

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

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 -->
{% include "third_party_js.html" %}
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'notification.js' %}"></script>
<script type='text/javascript' src='{% static "script/inventree/message.js" %}'></script>
<script type='text/javascript'>
$(document).ready(function () {
// notifications
{% if messages %}
{% for message in messages %}
showAlertOrCache(
'{{ message }}',
true,
{
style: 'info',
}
);
showMessage(messsage);
{% endfor %}
{% endif %}
inventreeDocReady();
showCachedAlerts();
});
</script>

View File

@ -17,7 +17,10 @@
<form method="post" action="{% url 'account_confirm_email' confirmation.key %}">
{% 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>
{% else %}

View File

@ -8,7 +8,8 @@
{% csrf_token %}
{{ 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' %}
</button>
</form>

View File

@ -141,6 +141,7 @@
<!-- general JS -->
<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 -->
<script defer type='text/javascript' src="{% url 'calendar.js' %}"></script>

View File

@ -2,8 +2,6 @@
/* exported
loadNotificationTable,
showAlertOrCache,
showCachedAlerts,
startNotificationWatcher,
stopNotificationWatcher,
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
/**
* start the regular notification checks

View File

@ -70,6 +70,7 @@
<!-- general JS -->
<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>
{% block body_scripts_inventree %}
{% endblock %}

View File

@ -8,7 +8,7 @@ coveralls==2.1.2 # Coveralls linking (for Travis)
cryptography==3.4.8 # Cryptography support
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-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-cors-headers==3.2.0 # CORS headers extension for DRF
django-crispy-forms==1.11.2 # Form helpers