Merge branch 'inventree:master' into matmair/issue2279

This commit is contained in:
Matthias Mair 2021-12-15 00:20:48 +01:00 committed by GitHub
commit acbee38600
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 716 additions and 242 deletions

View File

@ -12,7 +12,7 @@ on:
- l10* - l10*
env: env:
python_version: 3.7 python_version: 3.8
node_version: 16 node_version: 16
server_start_sleep: 60 server_start_sleep: 60
@ -229,6 +229,7 @@ jobs:
cache: 'pip' cache: 'pip'
- name: Install Dependencies - name: Install Dependencies
run: | run: |
sudo apt-get update
sudo apt-get install libpq-dev sudo apt-get install libpq-dev
pip3 install invoke pip3 install invoke
pip3 install psycopg2 pip3 install psycopg2
@ -282,7 +283,8 @@ jobs:
cache: 'pip' cache: 'pip'
- name: Install Dependencies - name: Install Dependencies
run: | run: |
sudo apt-get install mysql-server libmysqlclient-dev sudo apt-get update
sudo apt-get install libmysqlclient-dev
pip3 install invoke pip3 install invoke
pip3 install mysqlclient pip3 install mysqlclient
invoke install invoke install

View File

@ -4,12 +4,15 @@ Helper forms which subclass Django forms to provide additional functionality
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from urllib.parse import urlencode
import logging import logging
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django import forms from django import forms
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.conf import settings from django.conf import settings
from django.http import HttpResponseRedirect
from django.urls import reverse
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field from crispy_forms.layout import Layout, Field
@ -18,6 +21,9 @@ from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppende
from allauth.account.forms import SignupForm, set_form_field_order from allauth.account.forms import SignupForm, set_form_field_order
from allauth.account.adapter import DefaultAccountAdapter from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.exceptions import ImmediateHttpResponse
from allauth_2fa.adapter import OTPAdapter
from allauth_2fa.utils import user_has_valid_totp_device
from part.models import PartCategory from part.models import PartCategory
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
@ -278,7 +284,7 @@ class RegistratonMixin:
return user return user
class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter): class CustomAccountAdapter(RegistratonMixin, OTPAdapter, DefaultAccountAdapter):
""" """
Override of adapter to use dynamic settings Override of adapter to use dynamic settings
""" """
@ -297,3 +303,27 @@ class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter):
if InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO', True): if InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO', True):
return super().is_auto_signup_allowed(request, sociallogin) return super().is_auto_signup_allowed(request, sociallogin)
return False return False
# from OTPAdapter
def has_2fa_enabled(self, user):
"""Returns True if the user has 2FA configured."""
return user_has_valid_totp_device(user)
def login(self, request, user):
# Require two-factor authentication if it has been configured.
if self.has_2fa_enabled(user):
# Cast to string for the case when this is not a JSON serializable
# object, e.g. a UUID.
request.session['allauth_2fa_user_id'] = str(user.id)
redirect_url = reverse('two-factor-authenticate')
# Add GET parameters to the URL if they exist.
if request.GET:
redirect_url += u'?' + urlencode(request.GET)
raise ImmediateHttpResponse(
response=HttpResponseRedirect(redirect_url)
)
# Otherwise defer to the original allauth adapter.
return super().login(request, user)

View File

@ -0,0 +1,36 @@
"""
Custom management command to remove MFA for a user
"""
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
class Command(BaseCommand):
"""
Remove MFA for a user
"""
def add_arguments(self, parser):
parser.add_argument('mail', type=str)
def handle(self, *args, **kwargs):
# general settings
mail = kwargs.get('mail')
if not mail:
raise KeyError('A mail is required')
user = get_user_model()
mfa_user = [*set(user.objects.filter(email=mail) | user.objects.filter(emailaddress__email=mail))]
if len(mfa_user) == 0:
print('No user with this mail associated')
elif len(mfa_user) > 1:
print('More than one user found with this mail')
else:
# and clean out all MFA methods
# backup codes
mfa_user[0].staticdevice_set.all().delete()
# TOTP tokens
mfa_user[0].totpdevice_set.all().delete()
print(f'Removed all MFA methods for user {str(mfa_user[0])}')

View File

@ -1,12 +1,18 @@
from django.shortcuts import HttpResponseRedirect from django.shortcuts import HttpResponseRedirect
from django.urls import reverse_lazy from django.urls import reverse_lazy, Resolver404
from django.db import connection from django.db import connection
from django.shortcuts import redirect from django.shortcuts import redirect
from django.conf.urls import include, url
import logging import logging
import time import time
import operator import operator
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware
from InvenTree.urls import frontendpatterns
from common.models import InvenTreeSetting
logger = logging.getLogger("inventree") logger = logging.getLogger("inventree")
@ -146,3 +152,28 @@ class QueryCountMiddleware(object):
print(x[0], ':', x[1]) print(x[0], ':', x[1])
return response return response
url_matcher = url('', include(frontendpatterns))
class Check2FAMiddleware(BaseRequire2FAMiddleware):
"""check if user is required to have MFA enabled"""
def require_2fa(self, request):
# Superusers are require to have 2FA.
try:
if url_matcher.resolve(request.path[1:]):
return InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA')
except Resolver404:
pass
return False
class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
"""This function ensures only frontend code triggers the MFA auth cycle"""
def process_request(self, request):
try:
if not url_matcher.resolve(request.path[1:]):
super().process_request(request)
except Resolver404:
pass

View File

@ -313,6 +313,12 @@ INSTALLED_APPS = [
'allauth', # Base app for SSO 'allauth', # Base app for SSO
'allauth.account', # Extend user with accounts 'allauth.account', # Extend user with accounts
'allauth.socialaccount', # Use 'social' providers 'allauth.socialaccount', # Use 'social' providers
'django_otp', # OTP is needed for MFA - base package
'django_otp.plugins.otp_totp', # Time based OTP
'django_otp.plugins.otp_static', # Backup codes
'allauth_2fa', # MFA flow for allauth
] ]
MIDDLEWARE = CONFIG.get('middleware', [ MIDDLEWARE = CONFIG.get('middleware', [
@ -323,9 +329,12 @@ MIDDLEWARE = CONFIG.get('middleware', [
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'corsheaders.middleware.CorsMiddleware', 'corsheaders.middleware.CorsMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django_otp.middleware.OTPMiddleware', # MFA support
'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'InvenTree.middleware.AuthRequiredMiddleware', 'InvenTree.middleware.AuthRequiredMiddleware',
'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA
'maintenance_mode.middleware.MaintenanceModeMiddleware', 'maintenance_mode.middleware.MaintenanceModeMiddleware',
]) ])

View File

@ -131,15 +131,26 @@ translated_javascript_urls = [
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'), url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'),
] ]
urlpatterns = [ backendpatterns = [
url(r'^part/', include(part_urls)),
url(r'^manufacturer-part/', include(manufacturer_part_urls)),
url(r'^supplier-part/', include(supplier_part_urls)),
# "Dynamic" javascript files which are rendered using InvenTree templating. # "Dynamic" javascript files which are rendered using InvenTree templating.
url(r'^js/dynamic/', include(dynamic_javascript_urls)), url(r'^js/dynamic/', include(dynamic_javascript_urls)),
url(r'^js/i18n/', include(translated_javascript_urls)), url(r'^js/i18n/', include(translated_javascript_urls)),
url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')),
url(r'^auth/?', auth_request),
url(r'^api/', include(apipatterns)),
url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
# 3rd party endpoints
url(r'^markdownx/', include('markdownx.urls')),
]
frontendpatterns = [
url(r'^part/', include(part_urls)),
url(r'^manufacturer-part/', include(manufacturer_part_urls)),
url(r'^supplier-part/', include(supplier_part_urls)),
url(r'^common/', include(common_urls)), url(r'^common/', include(common_urls)),
url(r'^stock/', include(stock_urls)), url(r'^stock/', include(stock_urls)),
@ -149,8 +160,6 @@ urlpatterns = [
url(r'^build/', include(build_urls)), url(r'^build/', include(build_urls)),
url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')),
url(r'^settings/', include(settings_urls)), url(r'^settings/', include(settings_urls)),
url(r'^notifications/', include(notifications_urls)), url(r'^notifications/', include(notifications_urls)),
@ -158,23 +167,17 @@ urlpatterns = [
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'), url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'), url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
url(r'^admin/error_log/', include('error_report.urls')),
url(r'^admin/shell/', include('django_admin_shell.urls')),
url(r'^admin/', admin.site.urls, name='inventree-admin'),
url(r'^index/', IndexView.as_view(), name='index'), url(r'^index/', IndexView.as_view(), name='index'),
url(r'^search/', SearchView.as_view(), name='search'), url(r'^search/', SearchView.as_view(), name='search'),
url(r'^stats/', DatabaseStatsView.as_view(), name='stats'), url(r'^stats/', DatabaseStatsView.as_view(), name='stats'),
url(r'^auth/?', auth_request),
url(r'^api/', include(apipatterns)),
url(r'^api-doc/', include_docs_urls(title='InvenTree API')),
# plugin urls # plugin urls
get_plugin_urls(), # appends currently loaded plugin urls = None get_plugin_urls(), # appends currently loaded plugin urls = None
url(r'^markdownx/', include('markdownx.urls')), # admin sites
url(r'^admin/error_log/', include('error_report.urls')),
url(r'^admin/shell/', include('django_admin_shell.urls')),
url(r'^admin/', admin.site.urls, name='inventree-admin'),
# DB user sessions # DB user sessions
url(r'^accounts/sessions/other/delete/$', view=CustomSessionDeleteOtherView.as_view(), name='session_delete_other', ), url(r'^accounts/sessions/other/delete/$', view=CustomSessionDeleteOtherView.as_view(), name='session_delete_other', ),
@ -185,7 +188,13 @@ urlpatterns = [
url(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'), url(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'),
url(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'), url(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'),
url(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"), url(r"^accounts/password/reset/key/(?P<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"),
url(r'^accounts/', include('allauth.urls')), # included urlpatterns url(r'^accounts/', include('allauth_2fa.urls')), # MFA support
url(r'^accounts/', include('allauth.urls')), # included urlpatterns
]
urlpatterns = [
url('', include(frontendpatterns)),
url('', include(backendpatterns)),
] ]
# Server running in "DEBUG" mode? # Server running in "DEBUG" mode?

View File

@ -962,6 +962,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': '', 'default': '',
'choices': settings_group_options 'choices': settings_group_options
}, },
'LOGIN_ENFORCE_MFA': {
'name': _('Enforce MFA'),
'description': _('Users must use multifactor security.'),
'default': False,
'validator': bool,
},
'ENABLE_PLUGINS_URL': { 'ENABLE_PLUGINS_URL': {
'name': _('Enable URL integration'), 'name': _('Enable URL integration'),
'description': _('Enable plugins to add URL routes'), 'description': _('Enable plugins to add URL routes'),

View File

@ -11,7 +11,7 @@ from django.utils.translation import ugettext_lazy as _
from mptt.fields import TreeNodeChoiceField from mptt.fields import TreeNodeChoiceField
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.helpers import GetExportFormats, clean_decimal from InvenTree.helpers import clean_decimal
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
import common.models import common.models
@ -55,36 +55,6 @@ class PartImageDownloadForm(HelperForm):
] ]
class BomExportForm(forms.Form):
""" Simple form to let user set BOM export options,
before exporting a BOM (bill of materials) file.
"""
file_format = forms.ChoiceField(label=_("File Format"), help_text=_("Select output file format"))
cascading = forms.BooleanField(label=_("Cascading"), required=False, initial=True, help_text=_("Download cascading / multi-level BOM"))
levels = forms.IntegerField(label=_("Levels"), required=True, initial=0, help_text=_("Select maximum number of BOM levels to export (0 = all levels)"))
parameter_data = forms.BooleanField(label=_("Include Parameter Data"), required=False, initial=False, help_text=_("Include part parameters data in exported BOM"))
stock_data = forms.BooleanField(label=_("Include Stock Data"), required=False, initial=False, help_text=_("Include part stock data in exported BOM"))
manufacturer_data = forms.BooleanField(label=_("Include Manufacturer Data"), required=False, initial=True, help_text=_("Include part manufacturer data in exported BOM"))
supplier_data = forms.BooleanField(label=_("Include Supplier Data"), required=False, initial=True, help_text=_("Include part supplier data in exported BOM"))
def get_choices(self):
""" BOM export format choices """
return [(x, x.upper()) for x in GetExportFormats()]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['file_format'].choices = self.get_choices()
class BomDuplicateForm(HelperForm): class BomDuplicateForm(HelperForm):
""" """
Simple confirmation form for BOM duplication. Simple confirmation form for BOM duplication.

View File

@ -475,9 +475,9 @@ class BomItemSerializer(InvenTreeModelSerializer):
validated = serializers.BooleanField(read_only=True, source='is_line_valid') validated = serializers.BooleanField(read_only=True, source='is_line_valid')
purchase_price_min = MoneyField(max_digits=10, decimal_places=6, read_only=True) purchase_price_min = MoneyField(max_digits=19, decimal_places=4, read_only=True)
purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True) purchase_price_max = MoneyField(max_digits=19, decimal_places=4, read_only=True)
purchase_price_avg = serializers.SerializerMethodField() purchase_price_avg = serializers.SerializerMethodField()

View File

@ -32,7 +32,7 @@
<div class='alert alert-info alert-block'> <div class='alert alert-info alert-block'>
<strong>{% trans "Requirements for BOM upload" %}:</strong> <strong>{% trans "Requirements for BOM upload" %}:</strong>
<ul> <ul>
<li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href="/part/bom_template/">{% trans "BOM Upload Template" %}</a></strong></li> <li>{% trans "The BOM file must contain the required named columns as provided in the " %} <strong><a href='#' id='bom-template-download'>{% trans "BOM Upload Template" %}</a></strong></li>
<li>{% trans "Each part must already exist in the database" %}</li> <li>{% trans "Each part must already exist in the database" %}</li>
</ul> </ul>
</div> </div>
@ -60,4 +60,8 @@
enableSidebar('bom-upload'); enableSidebar('bom-upload');
$('#bom-template-download').click(function() {
downloadBomTemplate();
});
{% endblock js_ready %} {% endblock js_ready %}

View File

@ -620,13 +620,7 @@
}); });
$("#download-bom").click(function () { $("#download-bom").click(function () {
launchModalForm("{% url 'bom-export' part.id %}", exportBom({{ part.id }});
{
success: function(response) {
location.href = response.url;
},
}
);
}); });
{% if report_enabled %} {% if report_enabled %}

View File

@ -1192,14 +1192,10 @@ class BomExport(AjaxView):
""" """
model = Part model = Part
form_class = part_forms.BomExportForm
ajax_form_title = _("Export Bill of Materials") ajax_form_title = _("Export Bill of Materials")
role_required = 'part.view' role_required = 'part.view'
def get(self, request, *args, **kwargs):
return self.renderJsonResponse(request, self.form_class())
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
# Extract POSTed form data # Extract POSTed form data

View File

@ -11,7 +11,7 @@ from importlib import reload
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from django.conf.urls import url from django.conf.urls import url, include
from django.urls import clear_url_caches from django.urls import clear_url_caches
from django.contrib import admin from django.contrib import admin
from django.utils.text import slugify from django.utils.text import slugify
@ -412,7 +412,7 @@ class Plugins:
self.plugins_inactive = {} self.plugins_inactive = {}
def _update_urls(self): def _update_urls(self):
from InvenTree.urls import urlpatterns from InvenTree.urls import urlpatterns as global_pattern, frontendpatterns as urlpatterns
from plugin.urls import get_plugin_urls from plugin.urls import get_plugin_urls
for index, a in enumerate(urlpatterns): for index, a in enumerate(urlpatterns):
@ -421,6 +421,9 @@ class Plugins:
urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin') urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin')
elif a.app_name == 'plugin': elif a.app_name == 'plugin':
urlpatterns[index] = get_plugin_urls() urlpatterns[index] = get_plugin_urls()
# replace frontendpatterns
global_pattern[0] = url('', include(urlpatterns))
clear_url_caches() clear_url_caches()
def _reload_apps(self, force_reload: bool = False): def _reload_apps(self, force_reload: bool = False):

View File

@ -16,6 +16,7 @@
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %}
{% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %}
<tr> <tr>
<th><h5>{% trans 'Signup' %}</h5></th> <th><h5>{% trans 'Signup' %}</h5></th>
<td colspan='4'></td> <td colspan='4'></td>

View File

@ -42,193 +42,317 @@
</tr> </tr>
</table> </table>
<div class='panel-heading'> <div class="row">
<div class='d-flex flex-span'> <div class='panel-heading'>
<div class='d-flex flex-span'>
<h4>{% trans "Email" %}</h4> <h4>{% trans "Email" %}</h4>
{% include "spacer.html" %} </div>
</div> </div>
</div>
<div class='row'> <div class="col-sm-6">
<div class='col-sm-6'> {% if user.emailaddress_set.all %}
{% if user.emailaddress_set.all %} <p>{% trans 'The following email addresses are associated with your account:' %}</p>
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
<form action="{% url 'account_email' %}" class="email_list" method="post"> <form action="{% url 'account_email' %}" class="email_list" method="post">
{% csrf_token %} {% csrf_token %}
<fieldset class="blockLabels"> <fieldset class="blockLabels">
{% for emailaddress in user.emailaddress_set.all %} {% for emailaddress in user.emailaddress_set.all %}
<div> <div>
<div class="ctrlHolder"> <div class="ctrlHolder">
<label for="email_radio_{{forloop.counter}}" class="{% if emailaddress.primary %}primary_email{%endif%}"> <label for="email_radio_{{forloop.counter}}" class="{% if emailaddress.primary %}primary_email{%endif%}">
<input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked"{%endif %} value="{{emailaddress.email}}"/> <input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked" {%endif %} value="{{emailaddress.email}}" />
{% if emailaddress.primary %} {% if emailaddress.primary %}
<b>{{ emailaddress.email }}</b> <b>{{ emailaddress.email }}</b>
{% else %}
{{ emailaddress.email }}
{% endif %}
</label>
{% if emailaddress.verified %}
<span class='badge badge-right rounded-pill bg-success'>{% trans "Verified" %}</span>
{% else %} {% else %}
<span class='badge badge-right rounded-pill bg-warning'>{% trans "Unverified" %}</span> {{ emailaddress.email }}
{% endif %} {% endif %}
{% if emailaddress.primary %}<span class='badge badge-right rounded-pill bg-primary'>{% trans "Primary" %}</span>{% endif %} </label>
</div>
{% if emailaddress.verified %}
<span class='badge badge-right rounded-pill bg-success'>{% trans "Verified" %}</span>
{% else %}
<span class='badge badge-right rounded-pill bg-warning'>{% trans "Unverified" %}</span>
{% endif %}
{% if emailaddress.primary %}<span class='badge badge-right rounded-pill bg-primary'>{% trans "Primary" %}</span>{% endif %}
</div>
</div> </div>
{% endfor %} {% endfor %}
<div class="buttonHolder"> <div class="buttonHolder">
<button class="btn btn-primary secondaryAction" type="submit" name="action_primary" >{% trans 'Make Primary' %}</button> <button class="btn btn-primary secondaryAction" type="submit" name="action_primary">{% trans 'Make Primary' %}</button>
<button class="btn btn-primary secondaryAction" type="submit" name="action_send" {% if not mail_conf %}disabled{% endif %}>{% trans 'Re-send Verification' %}</button> <button class="btn btn-primary secondaryAction" type="submit" name="action_send" {% if not mail_conf %}disabled{% endif %}>{% trans 'Re-send Verification' %}</button>
<button class="btn btn-primary primaryAction" type="submit" name="action_remove" >{% trans 'Remove' %}</button> <button class="btn btn-primary primaryAction" type="submit" name="action_remove">{% trans 'Remove' %}</button>
</div> </div>
</fieldset> </fieldset>
</form>
{% else %}
<p><strong>{% trans 'Warning:'%}</strong>
{% trans "You currently do not have any email address set up. You should really add an email address so you can receive notifications, reset your password, etc." %}
</p>
{% endif %}
</div>
<div class='col-sm-6'>
{% if can_add_email %}
<h5>{% trans "Add Email Address" %}</h5>
<form method="post" action="{% url 'account_email' %}" class="add_email">
{% csrf_token %}
<label for="id_email" class=" requiredField">
E-mail<span class="asteriskField">*</span>
</label>
<div id="div_id_email" class="form-group input-group mb-3">
<div class='input-group-prepend'><span class='input-group-text'>@</span></div>
<input type="email" name="email" placeholder='{% trans "Enter e-mail address" %}' class="textinput textInput form-control" required="" id="id_email">
<div class='input-group-append'>
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add Email" %}</button>
</div>
</div>
</form>
{% endif %}
</div>
</div>
<div class='panel-heading'>
<h4>{% trans "Social Accounts" %}</h4>
</div>
<div>
{% if social_form.accounts %}
<p>{% blocktrans %}You can sign in to your account using any of the following third party accounts:{% endblocktrans %}</p>
<form method="post" action="{% url 'socialaccount_connections' %}">
{% csrf_token %}
<fieldset>
{% if social_form.non_field_errors %}
<div id="errorMsg">{{ social_form.non_field_errors }}</div>
{% endif %}
{% for base_account in social_form.accounts %}
{% with base_account.get_provider_account as account %}
<div>
<label for="id_account_{{ base_account.id }}">
<input id="id_account_{{ base_account.id }}" type="radio" name="account" value="{{ base_account.id }}"/>
<span class="socialaccount_provider {{ base_account.provider }} {{ account.get_brand.id }}">
<span class='brand-icon' brand_name='{{account.get_brand.id}}'></span>{{account.get_brand.name}}</span>
{{ account }}
</label>
</div>
{% endwith %}
{% endfor %}
<div>
<button class="btn btn-primary" type="submit">{% trans 'Remove' %}</button>
</div>
</fieldset>
</form> </form>
{% else %} {% else %}
<div class='alert alert-block alert-warning'> <div class='alert alert-block alert-danger'>
{% trans "There are no social network accounts connected to your InvenTree account" %} <strong>{% trans 'Warning:'%}</strong>
{% trans "You currently do not have any email address set up. You should really add an email address so you can receive notifications, reset your password, etc." %}
</div>
{% endif %}
</div>
{% if can_add_email %}
<div class="col-sm-6">
<h5>{% trans "Add Email Address" %}</h5>
<form method="post" action="{% url 'account_email' %}" class="add_email">
{% csrf_token %}
{{ add_email_form|crispy }}
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add Email" %}</button>
</form>
</div> </div>
{% endif %} {% endif %}
<br>
<h4>{% trans 'Add a 3rd Party Account' %}</h4>
<div>
{% include "socialaccount/snippets/provider_list.html" with process="connect" %}
</div>
{% include "socialaccount/snippets/login_extra.html" %}
<br>
</div> </div>
<div class='panel-heading'> <div class="row">
<div class='d-flex flex-wrap'> <div class='panel-heading'>
<h4>{% trans "Active Sessions" %}</h4> <h4>{% trans "Social Accounts" %}</h4>
{% include "spacer.html" %} </div>
<div class='btn-group' role='group'>
{% if session_list.count > 1 %} <div class="col-md-6">
<form method="post" action="{% url 'session_delete_other' %}"> {% if social_form.accounts %}
{% csrf_token %} <p>{% blocktrans %}You can sign in to your account using any of the following third party accounts:{% endblocktrans %}</p>
<button type="submit" class="btn btn-sm btn-default btn-danger" title='{% trans "Log out active sessions (except this one)" %}'>
{% trans "Log Out Active Sessions" %}
</button> <form method="post" action="{% url 'socialaccount_connections' %}">
</form> {% csrf_token %}
{% endif %}
<fieldset>
{% if social_form.non_field_errors %}
<div id="errorMsg">{{ social_form.non_field_errors }}</div>
{% endif %}
{% for base_account in social_form.accounts %}
{% with base_account.get_provider_account as account %}
<div>
<label for="id_account_{{ base_account.id }}">
<input id="id_account_{{ base_account.id }}" type="radio" name="account"
value="{{ base_account.id }}" />
<span class="socialaccount_provider {{ base_account.provider }} {{ account.get_brand.id }}">
<span class='brand-icon'
brand_name='{{account.get_brand.id}}'></span>{{account.get_brand.name}}</span>
{{ account }}
</label>
</div>
{% endwith %}
{% endfor %}
<div>
<button class="btn btn-primary" type="submit">{% trans 'Remove' %}</button>
</div>
</fieldset>
</form>
{% else %}
<p>{% trans 'You currently have no social network accounts connected to this account.' %}</p>
{% endif %}
</div>
<div class="col-md-6">
<h5>{% trans 'Add a 3rd Party Account' %}</h5>
<div>
{% include "socialaccount/snippets/provider_list.html" with process="connect" %}
</div>
{% include "socialaccount/snippets/login_extra.html" %}
</div>
</div>
<div class="row">
<div class='panel-heading'>
<h4>{% trans "Multifactor" %}</h4>
</div>
<div class="col-md-6">
{% if user.staticdevice_set.all or user.totpdevice_set.all %}
<p>{% trans 'You have these factors available:' %}</p>
<table class="table table-striped">
<thead>
<th>Type</th>
<th>Name</th>
</thead>
<tbody>
{% for token in user.totpdevice_set.all %}
<tr>
<td>{% trans 'TOTP' %}</td>
<td>{{ token.name }}</td>
</tr>
{% endfor %}
{% for token in user.staticdevice_set.all %}
<tr>
<td>{% trans 'Static' %}</td>
<td>{{ token.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p><strong>{% trans 'Warning:'%}</strong>
{% trans "You currently do not have any factors set up." %}
</p>
{% endif %}
</div>
<div class="col-md-6">
<h5>{% trans "Change factors" %}</h5>
<a href="{% url 'two-factor-setup' %}" class="btn btn-primary {% if user.staticdevice_set.all and user.totpdevice_set.all %}disabled{% endif %}" role="button">{% trans "Setup multifactor" %}</a>
{% if user.staticdevice_set.all or user.totpdevice_set.all %}
<a href="{% url 'two-factor-remove' %}" class="btn btn-primary" role="button">{% trans "Remove multifactor" %}</a>
{% endif %}
</div>
</div>
<div class='row'>
<div class='panel-heading'>
<h4>{% trans "Theme Settings" %}</h4>
</div>
<div class='col-sm-6'>
<form action='{% url "settings-appearance" %}' method='post'>
{% csrf_token %}
<input name='next' type='hidden' value='{% url "settings" %}'>
<label for='theme' class=' requiredField'>
{% trans "Select theme" %}
</label>
<div class='form-group input-group mb-3'>
<select id='theme' name='theme' class='select form-control'>
{% get_available_themes as themes %}
{% for theme in themes %}
<option value='{{ theme.key }}'>{{ theme.name }}</option>
{% endfor %}
</select>
<div class='input-group-append'>
<input type="submit" value="{% trans 'Set Theme' %}" class="btn btn-primary">
</div>
</div>
</form>
</div>
</div>
<div class="row">
<div class='panel-heading'>
<h4>{% trans "Language Settings" %}</h4>
</div>
<div class="col">
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<input name="next" type="hidden" value="{% url 'settings' %}">
<label for='language' class=' requiredField'>
{% trans "Select language" %}
</label>
<div class='form-group input-group mb-3'>
<select name="language" class="select form-control w-25">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% if 'alllang' in request.GET %}{% define True as ALL_LANG %}{% endif %}
{% for language in languages %}
{% define language.code as lang_code %}
{% define locale_stats|keyvalue:lang_code as lang_translated %}
{% if lang_translated > 10 or lang_code == 'en' or lang_code == LANGUAGE_CODE %}{% define True as use_lang %}{% else %}{% define False as use_lang %}{% endif %}
{% if ALL_LANG or use_lang %}
<option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ lang_code }})
{% if lang_translated %}
{% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %}
{% else %}
{% if lang_code == 'en' %}-{% else %}{% trans 'No translations available' %}{% endif %}
{% endif %}
</option>
{% endif %}
{% endfor %}
</select>
<div class='input-group-append'>
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
</div>
<p>{% trans "Some languages are not complete" %}
{% if ALL_LANG %}
. <a href="{% url 'settings' %}">{% trans "Show only sufficent" %}</a>
{% else %}
and hidden. <a href="?alllang">{% trans "Show them too" %}</a>
{% endif %}
</p>
</div>
</form>
</div>
<div class="col-sm-6">
<h4>{% trans "Help the translation efforts!" %}</h4>
<p>{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the
InvenTree web application is <a href="{{link}}">community contributed via crowdin</a>. Contributions are
welcomed and encouraged.{% endblocktrans %}</p>
</div>
</div>
<div class="row">
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Active Sessions" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if session_list.count > 1 %}
<form method="post" action="{% url 'session_delete_other' %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-default btn-danger" title='{% trans "Log out active sessions (except this one)" %}'>
{% trans "Log Out Active Sessions" %}
</button>
</form>
{% endif %}
</div>
</div> </div>
</div> </div>
</div>
<div> <div>
{% trans "<em>unknown on unknown</em>" as unknown_on_unknown %} {% trans "<em>unknown on unknown</em>" as unknown_on_unknown %}
{% trans "<em>unknown</em>" as unknown %} {% trans "<em>unknown</em>" as unknown %}
<table class="table table-striped table-condensed"> <table class="table table-striped table-condensed">
<thead> <thead>
<tr> <tr>
<th>{% trans "IP Address" %}</th> <th>{% trans "IP Address" %}</th>
<th>{% trans "Device" %}</th> <th>{% trans "Device" %}</th>
<th>{% trans "Last Activity" %}</th> <th>{% trans "Last Activity" %}</th>
</tr> </tr>
</thead> </thead>
{% for object in session_list %} {% for object in session_list %}
<tr {% if object.session_key == session_key %}class="active"{% endif %}> <tr {% if object.session_key == session_key %}class="active"{% endif %}>
<td>{{ object.ip }}</td> <td>{{ object.ip }}</td>
<td>{{ object.user_agent|device|default_if_none:unknown_on_unknown|safe }}</td> <td>{{ object.user_agent|device|default_if_none:unknown_on_unknown|safe }}</td>
<td> <td>
{% if object.session_key == session_key %} {% if object.session_key == session_key %}
{% blocktrans with time=object.last_activity|timesince %}{{ time }} ago (this session){% endblocktrans %} {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago (this session){% endblocktrans %}
{% else %} {% else %}
{% blocktrans with time=object.last_activity|timesince %}{{ time }} ago{% endblocktrans %} {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago{% endblocktrans %}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
(function() { (function() {
var message = "{% trans 'Do you really want to remove the selected email address?' %}"; var message = "{% trans 'Do you really want to remove the selected email address?' %}";
var actions = document.getElementsByName('action_remove'); var actions = document.getElementsByName('action_remove');
if (actions.length) { if (actions.length) {
actions[0].addEventListener("click", function(e) { actions[0].addEventListener("click", function(e) {
if (! confirm(message)) { if (! confirm(message)) {
e.preventDefault(); e.preventDefault();
} }
}); });
} }
})(); })();
{% endblock %} {% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "account/base.html" %}
{% load i18n crispy_forms_tags %}
{% block content %}
<h1>{% trans "Two-Factor Authentication" %}</h1>
<form method="post" class="login">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary">
{% trans 'Authenticate' %}
</button>
</form>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "account/base.html" %}
{% load i18n %}
{% block content %}
<h3>
{% trans "Two-Factor Authentication Backup Tokens" %}
</h3>
{% if backup_tokens %}
{% if reveal_tokens %}
<ul>
{% for token in backup_tokens %}
<li>{{ token.token }}</li>
{% endfor %}
</ul>
{% else %}
{% trans 'Backup tokens have been generated, but are not revealed here for security reasons. Press the button below to generate new ones.' %}
{% endif %}
{% else %}
{% trans 'No tokens. Press the button below to generate some.' %}
{% endif %}
<br>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-primary w-100">
{% trans 'Generate backup tokens' %}
</button>
</form>
<br>
<a href="{% url 'settings' %}" class="btn btn-secondary w-100 btn-sm">{% trans "back to settings" %}</a>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "account/base.html" %}
{% load i18n %}
{% block content %}
<h3>
{% trans "Disable Two-Factor Authentication" %}
</h3>
<p>{% trans "Are you sure?" %}</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger w-100">
{% trans 'Disable Two-Factor' %}
</button>
</form>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "account/base.html" %}
{% load i18n crispy_forms_tags %}
{% block content %}
<h3>
{% trans "Setup Two-Factor Authentication" %}
</h3>
<h4>
{% trans 'Step 1' %}:
</h4>
<p>
{% trans 'Scan the QR code below with a token generator of your choice (for instance Google Authenticator).' %}
</p>
<div class="bg-light rounded">
<img src="{{ qr_code_url }}" class="mx-auto d-block"/>
</div>
<br>
<h4>
{% trans 'Step 2' %}:
</h4>
<p>
{% trans 'Input a token generated by the app:' %}
</p>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary btn-block w-100">
{% trans 'Verify' %}
</button>
</form>
<div>
<a href="{% url 'settings' %}" class="btn btn-secondary w-100 btn-sm mt-3">{% trans "back to settings" %}</a>
</div>
{% endblock %}

View File

@ -175,7 +175,6 @@ function enableBreadcrumbTree(options) {
for (var i = 0; i < data.length; i++) { for (var i = 0; i < data.length; i++) {
node = data[i]; node = data[i];
node.nodes = [];
nodes[node.pk] = node; nodes[node.pk] = node;
node.selectable = false; node.selectable = false;
@ -193,10 +192,17 @@ function enableBreadcrumbTree(options) {
node = data[i]; node = data[i];
if (node.parent != null) { if (node.parent != null) {
nodes[node.parent].nodes.push(node); if (nodes[node.parent].nodes) {
nodes[node.parent].nodes.push(node);
} else {
nodes[node.parent].nodes = [node];
}
if (node.state.expanded) { if (node.state.expanded) {
nodes[node.parent].state.expanded = true; while (node.parent != null) {
nodes[node.parent].state.expanded = true;
node = nodes[node.parent];
}
} }
} else { } else {
@ -212,7 +218,6 @@ function enableBreadcrumbTree(options) {
collapseIcon: 'fa fa-chevron-down', collapseIcon: 'fa fa-chevron-down',
}); });
setBreadcrumbTreeState(label, state);
} }
} }
); );
@ -220,26 +225,11 @@ function enableBreadcrumbTree(options) {
$('#breadcrumb-tree-toggle').click(function() { $('#breadcrumb-tree-toggle').click(function() {
// Add callback to "collapse" and "expand" the sidebar // Add callback to "collapse" and "expand" the sidebar
// By default, the menu is "expanded" // Toggle treeview visibilty
var state = localStorage.getItem(`inventree-tree-state-${label}`) || 'expanded'; $('#breadcrumb-tree-collapse').toggle();
// We wish to "toggle" the state!
setBreadcrumbTreeState(label, state == 'expanded' ? 'collapsed' : 'expanded');
}); });
// Set the initial state (default = expanded)
var state = localStorage.getItem(`inventree-tree-state-${label}`) || 'expanded';
function setBreadcrumbTreeState(label, state) {
if (state == 'collapsed') {
$('#breadcrumb-tree-collapse').hide(100);
} else {
$('#breadcrumb-tree-collapse').show(100);
}
localStorage.setItem(`inventree-tree-state-${label}`, state);
}
} }
/* /*

View File

@ -2,6 +2,7 @@
/* globals /* globals
constructForm, constructForm,
exportFormatOptions,
imageHoverIcon, imageHoverIcon,
inventreeGet, inventreeGet,
inventreePut, inventreePut,
@ -14,6 +15,8 @@
*/ */
/* exported /* exported
downloadBomTemplate,
exportBom,
newPartFromBomWizard, newPartFromBomWizard,
loadBomTable, loadBomTable,
loadUsedInTable, loadUsedInTable,
@ -21,12 +24,121 @@
removeColFromBomWizard, removeColFromBomWizard,
*/ */
/* BOM management functions. function downloadBomTemplate(options={}) {
* Requires follwing files to be loaded first:
* - api.js var format = options.format;
* - part.js
* - modals.js if (!format) {
format = inventreeLoad('bom-export-format', 'csv');
}
constructFormBody({}, {
title: '{% trans "Download BOM Template" %}',
fields: {
format: {
label: '{% trans "Format" %}',
help_text: '{% trans "Select file format" %}',
required: true,
type: 'choice',
value: format,
choices: exportFormatOptions(),
}
},
onSubmit: function(fields, opts) {
var format = getFormFieldValue('format', fields['format'], opts);
// Save the format for next time
inventreeSave('bom-export-format', format);
// Hide the modal
$(opts.modal).modal('hide');
// Download the file
location.href = `{% url "bom-upload-template" %}?format=${format}`;
}
});
}
/**
* Export BOM (Bill of Materials) for the specified Part instance
*/ */
function exportBom(part_id, options={}) {
constructFormBody({}, {
title: '{% trans "Export BOM" %}',
fields: {
format: {
label: '{% trans "Format" %}',
help_text: '{% trans "Select file format" %}',
required: true,
type: 'choice',
value: inventreeLoad('bom-export-format', 'csv'),
choices: exportFormatOptions(),
},
cascading: {
label: '{% trans "Cascading" %}',
help_text: '{% trans "Download cascading / multi-level BOM" %}',
type: 'boolean',
value: inventreeLoad('bom-export-cascading', true),
},
levels: {
label: '{% trans "Levels" %}',
help_text: '{% trans "Select maximum number of BOM levels to export (0 = all levels)" %}',
type: 'integer',
value: 0,
min_value: 0,
},
parameter_data: {
label: '{% trans "Include Parameter Data" %}',
help_text: '{% trans "Include part parameter data in exported BOM" %}',
type: 'boolean',
value: inventreeLoad('bom-export-parameter_data', false),
},
stock_data: {
label: '{% trans "Include Stock Data" %}',
help_text: '{% trans "Include part stock data in exported BOM" %}',
type: 'boolean',
value: inventreeLoad('bom-export-stock_data', false),
},
manufacturer_data: {
label: '{% trans "Include Manufacturer Data" %}',
help_text: '{% trans "Include part manufacturer data in exported BOM" %}',
type: 'boolean',
value: inventreeLoad('bom-export-manufacturer_data', false),
},
supplier_data: {
label: '{% trans "Include Supplier Data" %}',
help_text: '{% trans "Include part supplier data in exported BOM" %}',
type: 'boolean',
value: inventreeLoad('bom-export-supplier_data', false),
}
},
onSubmit: function(fields, opts) {
// Extract values from the form
var field_names = ['format', 'cascading', 'levels', 'parameter_data', 'stock_data', 'manufacturer_data', 'supplier_data'];
var url = `/part/${part_id}/bom-download/?`;
field_names.forEach(function(fn) {
var val = getFormFieldValue(fn, fields[fn], opts);
// Update user preferences
inventreeSave(`bom-export-${fn}`, val);
url += `${fn}=${val}&`;
});
$(opts.modal).modal('hide');
// Redirect to the BOM file download
location.href = url;
}
});
}
function bomItemFields() { function bomItemFields() {

View File

@ -811,7 +811,9 @@ function updateFieldValue(name, value, field, options) {
switch (field.type) { switch (field.type) {
case 'boolean': case 'boolean':
el.prop('checked', value); if (value == true || value.toString().toLowerCase() == 'true') {
el.prop('checked');
}
break; break;
case 'related field': case 'related field':
// Clear? // Clear?
@ -2034,8 +2036,15 @@ function constructInputOptions(name, classes, type, parameters) {
} }
if (parameters.value != null) { if (parameters.value != null) {
// Existing value? if (parameters.type == 'boolean') {
opts.push(`value='${parameters.value}'`); // Special consideration of a boolean (checkbox) value
if (parameters.value == true || parameters.value.toString().toLowerCase() == 'true') {
opts.push('checked');
}
} else {
// Existing value?
opts.push(`value='${parameters.value}'`);
}
} else if (parameters.default != null) { } else if (parameters.default != null) {
// Otherwise, a defualt value? // Otherwise, a defualt value?
opts.push(`value='${parameters.default}'`); opts.push(`value='${parameters.default}'`);

View File

@ -73,6 +73,9 @@ class RuleSet(models.Model):
'socialaccount_socialaccount', 'socialaccount_socialaccount',
'socialaccount_socialapp', 'socialaccount_socialapp',
'socialaccount_socialtoken', 'socialaccount_socialtoken',
'otp_totp_totpdevice',
'otp_static_statictoken',
'otp_static_staticdevice',
'plugin_pluginconfig' 'plugin_pluginconfig'
], ],
'part_category': [ 'part_category': [
@ -237,7 +240,7 @@ class RuleSet(models.Model):
given the app_model name, and the permission type. given the app_model name, and the permission type.
""" """
app, model = model.split('_') model, app = split_model(model)
return "{app}.{perm}_{model}".format( return "{app}.{perm}_{model}".format(
app=app, app=app,
@ -280,6 +283,30 @@ class RuleSet(models.Model):
return self.RULESET_MODELS.get(self.name, []) return self.RULESET_MODELS.get(self.name, [])
def split_model(model):
"""get modelname and app from modelstring"""
*app, model = model.split('_')
# handle models that have
if len(app) > 1:
app = '_'.join(app)
else:
app = app[0]
return model, app
def split_permission(app, perm):
"""split permission string into permission and model"""
permission_name, *model = perm.split('_')
# handle models that have underscores
if len(model) > 1:
app += '_' + '_'.join(model[:-1])
perm = permission_name + '_' + model[-1:][0]
model = model[-1:][0]
return perm, model
def update_group_roles(group, debug=False): def update_group_roles(group, debug=False):
""" """
@ -383,7 +410,7 @@ def update_group_roles(group, debug=False):
(app, perm) = permission_string.split('.') (app, perm) = permission_string.split('.')
(permission_name, model) = perm.split('_') perm, model = split_permission(app, perm)
try: try:
content_type = ContentType.objects.get(app_label=app, model=model) content_type = ContentType.objects.get(app_label=app, model=model)

View File

@ -1,5 +1,5 @@
# Please keep this list sorted # Please keep this list sorted
Django==3.2.5 # Django package Django==3.2.10 # Django package
certifi # Certifi is (most likely) installed through one of the requirements above certifi # Certifi is (most likely) installed through one of the requirements above
coreapi==2.3.0 # API documentation coreapi==2.3.0 # API documentation
coverage==5.3 # Unit test coverage coverage==5.3 # Unit test coverage
@ -7,6 +7,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.45.0 # SSO for external providers via OpenID django-allauth==0.45.0 # SSO for external providers via OpenID
django-allauth-2fa==0.8 # MFA / 2FA
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

View File

@ -154,6 +154,18 @@ def clean_settings(c):
manage(c, "clean_settings") manage(c, "clean_settings")
@task(help={'mail': 'mail of the user whos MFA should be disabled'})
def remove_mfa(c, mail=''):
"""
Remove MFA for a user
"""
if not mail:
print('You must provide a users mail')
manage(c, f"remove_mfa {mail}")
@task(post=[rebuild_models, rebuild_thumbnails]) @task(post=[rebuild_models, rebuild_thumbnails])
def migrate(c): def migrate(c):
""" """