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 = ` + + `; + + 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 @@
{% csrf_token %} - +
+
{% 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 }} - diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 851b6ca323..7b6f0fb187 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -141,6 +141,7 @@ + diff --git a/InvenTree/templates/js/translated/notification.js b/InvenTree/templates/js/translated/notification.js index fcd626fbea..c497755f40 100644 --- a/InvenTree/templates/js/translated/notification.js +++ b/InvenTree/templates/js/translated/notification.js @@ -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 = `

${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 = ` - - `; - - 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 diff --git a/InvenTree/templates/skeleton.html b/InvenTree/templates/skeleton.html index 8d66785b37..3609a44874 100644 --- a/InvenTree/templates/skeleton.html +++ b/InvenTree/templates/skeleton.html @@ -70,6 +70,7 @@ + {% block body_scripts_inventree %} {% endblock %} diff --git a/requirements.txt b/requirements.txt index 9684c63b35..9a9a93cfd5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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