User management UI for system administrators

This commit is contained in:
Raphael Michel
2018-01-29 10:57:59 +01:00
parent c7a547a875
commit 3a713541a2
12 changed files with 477 additions and 14 deletions

View File

@@ -4,6 +4,7 @@ from django.conf import settings
from django.contrib.auth.models import (
AbstractBaseUser, BaseUserManager, PermissionsMixin,
)
from django.contrib.auth.tokens import default_token_generator
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
@@ -85,7 +86,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
timezone = models.CharField(max_length=100,
default=settings.TIME_ZONE,
verbose_name=_('Timezone'))
require_2fa = models.BooleanField(default=False)
require_2fa = models.BooleanField(
default=False,
verbose_name=_('Two-factor authentification is required to log in')
)
notifications_send = models.BooleanField(
default=True,
verbose_name=_('Receive notifications according to my settings below'),
@@ -158,6 +162,19 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
except SendMailException:
pass # Already logged
def send_password_reset(self):
from pretix.base.services.mail import mail
mail(
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
{
'user': self,
'url': (build_absolute_uri('control:auth.forgot.recover')
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
},
None, locale=self.locale
)
@property
def all_logentries(self):
from pretix.base.models import LogEntry

View File

@@ -539,3 +539,60 @@ class CheckInFilterForm(FilterForm):
qs = qs.filter(item=fdata.get('item'))
return qs
class UserFilterForm(FilterForm):
orders = {
'fullname': 'fullname',
'email': 'email',
}
status = forms.ChoiceField(
label=_('Status'),
choices=(
('', _('All')),
('active', _('Active')),
('inactive', _('Inactive')),
),
required=False
)
superuser = forms.ChoiceField(
label=_('Administrator'),
choices=(
('', _('All')),
('yes', _('Administrator')),
('no', _('No administrator')),
),
required=False
)
query = forms.CharField(
label=_('Search query'),
widget=forms.TextInput(attrs={
'placeholder': _('Search query'),
'autofocus': 'autofocus'
}),
required=False
)
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('status') == 'active':
qs = qs.filter(is_active=True)
elif fdata.get('status') == 'inactive':
qs = qs.filter(is_active=False)
if fdata.get('superuser') == 'yes':
qs = qs.filter(is_superuser=True)
elif fdata.get('superuser') == 'no':
qs = qs.filter(is_superuser=False)
if fdata.get('query'):
qs = qs.filter(
Q(email__icontains=fdata.get('query'))
| Q(fullname__icontains=fdata.get('query'))
)
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
return qs

View File

@@ -0,0 +1,88 @@
from django import forms
from django.contrib import messages
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
)
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from pytz import common_timezones
from pretix.base.models import User
class UserEditForm(forms.ModelForm):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this e-mail address. "
"Please choose a different one."),
'pw_mismatch': _("Please enter the same password twice"),
}
new_pw = forms.CharField(max_length=255,
required=False,
label=_("New password"),
widget=forms.PasswordInput())
new_pw_repeat = forms.CharField(max_length=255,
required=False,
label=_("Repeat new password"),
widget=forms.PasswordInput())
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
help_text=_('Only used for views that are not bound to an event. For all '
'event views, the event timezone is used instead.')
)
class Meta:
model = User
fields = [
'fullname',
'locale',
'timezone',
'email',
'require_2fa',
'is_active',
'is_superuser'
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['email'].required = True
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
return email
def clean_new_pw(self):
password1 = self.cleaned_data.get('new_pw', '')
if password1 and validate_password(password1, user=self.instance) is not None:
raise forms.ValidationError(
_(password_validators_help_texts()),
code='pw_invalid'
)
return password1
def clean_new_pw_repeat(self):
password1 = self.cleaned_data.get('new_pw')
password2 = self.cleaned_data.get('new_pw_repeat')
if password1 and password1 != password2:
raise forms.ValidationError(
self.error_messages['pw_mismatch'],
code='pw_mismatch'
)
def clean(self):
password1 = self.cleaned_data.get('new_pw')
if password1:
self.instance.set_password(password1)
return self.cleaned_data
def form_invalid(self, form):
messages.error(self.request, _('Your changes could not be saved. See below for details.'))
return super().form_invalid(form)

View File

@@ -282,4 +282,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
text = text + ' ' + str(_('Your email address has been changed to {email}.').format(email=data['email']))
if 'new_pw' in data:
text = text + ' ' + str(_('Your password has been changed.'))
if data.get('is_active') is True:
text = text + ' ' + str(_('Your account has been enabled.'))
elif data.get('is_active') is False:
text = text + ' ' + str(_('Your account has been disabled.'))
return text

View File

@@ -217,6 +217,15 @@
{% trans "Order search" %}
</a>
</li>
{% if request.user.is_superuser %}
<li>
<a href="{% url 'control:users' %}"
{% if "users" in url_name %}class="active"{% endif %}>
<i class="fa fa-user fa-fw"></i>
{% trans "Users" %}
</a>
</li>
{% endif %}
{% for nav in nav_global %}
<li>
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}

View File

@@ -0,0 +1,29 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Create user" %}{% endblock %}
{% block content %}
<h1>{% trans "Create user" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "Base settings" %}</legend>
{% bootstrap_field form.is_active layout='control' %}
{% bootstrap_field form.fullname layout='control' %}
{% bootstrap_field form.locale layout='control' %}
{% bootstrap_field form.timezone layout='control' %}
</fieldset>
<fieldset>
<legend>{% trans "Log-in settings" %}</legend>
{% bootstrap_field form.email layout='control' %}
{% bootstrap_field form.new_pw layout='control' %}
{% bootstrap_field form.new_pw_repeat layout='control' %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "User" %}{% endblock %}
{% block content %}
<h1>{% trans "User" %} {{ user.email }}</h1>
<p>
<form action="{% url "control:users.reset" id=user.pk %}" method="post" class="form-inline helper-display-inline">
{% csrf_token %}
<button class="btn btn-default">{% trans "Send password reset email" %}</button>
</form>
</p>
<div class="row">
<div class="col-md-10 col-xs-12">
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "Base settings" %}</legend>
{% bootstrap_field form.is_active layout='control' %}
{% bootstrap_field form.fullname layout='control' %}
{% bootstrap_field form.locale layout='control' %}
{% bootstrap_field form.timezone layout='control' %}
{% bootstrap_field form.is_superuser layout='control' %}
</fieldset>
<fieldset>
<legend>{% trans "Log-in settings" %}</legend>
{% bootstrap_field form.email layout='control' %}
{% bootstrap_field form.new_pw layout='control' %}
{% bootstrap_field form.new_pw_repeat layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}
</fieldset>
<fieldset>
<legend>{% trans "Team memberships" %}</legend>
<ul>
{% for t in teams %}
<li>
<a href="{% url "control:organizer.team" organizer=t.organizer.slug team=t.pk %}">
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
Team "{{ team }}" of organizer "{{ organizer }}"
{% endblocktrans %}
</a>
</li>
{% endfor %}
</ul>
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
</div>
<div class="col-xs-12 col-lg-2">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "User history" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=user %}
<li class="list-group-item logentry">
<p class="meta">
<span class="fa fa-clock-o"></span> {{ user.date_joined|date:"SHORT_DATETIME_FORMAT" }}
</p>
<p>
{% trans "User created." %}
</p>
</li>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load urlreplace %}
{% block title %}{% trans "Users" %}{% endblock %}
{% block content %}
<h1>{% trans "Users" %}</h1>
<form class="row filter-form" action="" method="get">
<div class="col-md-4 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query layout='inline' %}
</div>
<div class="col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.status layout='inline' %}
</div>
<div class="col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.superuser layout='inline' %}
</div>
<div class="col-md-2 col-sm-6 col-xs-12">
<button class="btn btn-primary btn-block" type="submit">
<span class="fa fa-filter"></span>
<span class="hidden-md">
{% trans "Filter" %}
</span>
</button>
</div>
</form>
<p>
<a href="{% url "control:users.add" %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Create a new user" %}
</a>
</p>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>
{% trans "E-mail address" %}
<a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>
{% trans "Full name" %}
<a href="?{% url_replace request 'ordering' '-fullname' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'fullname' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Active" %}</th>
<th>{% trans "Administrator" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td><strong>
<a href="{% url "control:users.edit" id=u.pk %}">{{ u.email }}</a>
</strong></td>
<td>{{ u.fullname }}</td>
<td>{% if u.is_active %}<span class="fa fa-check-circle"></span>{% endif %}</td>
<td>{% if u.is_superuser %}<span class="fa fa-check-circle"></span>{% endif %}</td>
<td class="text-right">
<a href="{% url "control:users.edit" id=u.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -2,7 +2,8 @@ from django.conf.urls import include, url
from pretix.control.views import (
auth, checkin, dashboards, event, global_settings, item, main, orders,
organizer, search, subevents, typeahead, user, vouchers, waitinglist,
organizer, search, subevents, typeahead, user, users, vouchers,
waitinglist,
)
urlpatterns = [
@@ -17,7 +18,11 @@ urlpatterns = [
url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='global.settings'),
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'),
url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'),
url(r'^users/$', users.UserListView.as_view(), name='users'),
url(r'^users/select2$', typeahead.users_select2, name='users.select2'),
url(r'^users/add$', users.UserCreateView.as_view(), name='users.add'),
url(r'^users/(?P<id>\d+)/$', users.UserEditView.as_view(), name='users.edit'),
url(r'^users/(?P<id>\d+)/reset$', users.UserResetView.as_view(), name='users.reset'),
url(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
url(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
url(r'^settings/notifications/$', user.UserNotificationsEditView.as_view(), name='user.settings.notifications'),

View File

@@ -25,8 +25,7 @@ from pretix.base.forms.auth import (
LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm,
)
from pretix.base.models import TeamInvite, U2FDevice, User
from pretix.base.services.mail import SendMailException, mail
from pretix.helpers.urls import build_absolute_uri
from pretix.base.services.mail import SendMailException
logger = logging.getLogger(__name__)
@@ -200,15 +199,7 @@ class Forgot(TemplateView):
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
try:
mail(
user.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
{
'user': user,
'url': (build_absolute_uri('control:auth.forgot.recover')
+ '?id=%d&token=%s' % (user.id, default_token_generator.make_token(user)))
},
None, locale=user.locale
)
user.send_password_reset()
except SendMailException:
messages.error(request, _('There was an error sending the mail. Please try again later.'))
return self.get(request, *args, **kwargs)

View File

@@ -0,0 +1,117 @@
from django.conf import settings
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.views import View
from django.views.generic import ListView
from pretix.base.models import User
from pretix.base.services.mail import SendMailException
from pretix.control.forms.filter import UserFilterForm
from pretix.control.forms.users import UserEditForm
from pretix.control.permissions import AdministratorPermissionRequiredMixin
from pretix.control.views import CreateView, UpdateView
from pretix.control.views.user import RecentAuthenticationRequiredMixin
class UserListView(AdministratorPermissionRequiredMixin, ListView):
template_name = 'pretixcontrol/users/index.html'
context_object_name = 'users'
def get_queryset(self):
qs = User.objects.all()
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
return ctx
@cached_property
def filter_form(self):
return UserFilterForm(data=self.request.GET)
class UserEditView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, UpdateView):
template_name = 'pretixcontrol/users/form.html'
context_object_name = 'user'
form_class = UserEditForm
def get_object(self, queryset=None):
return get_object_or_404(User, pk=self.kwargs.get("id"))
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['teams'] = self.object.teams.select_related('organizer')
return ctx
def get_success_url(self):
return reverse('control:users.edit', kwargs=self.kwargs)
def form_valid(self, form):
messages.success(self.request, _('Your changes have been saved.'))
data = {}
for k in form.changed_data:
if k != 'new_pw_repeat':
if 'new_pw' == k:
data['new_pw'] = True
else:
data[k] = form.cleaned_data[k]
sup = super().form_valid(form)
if 'require_2fa' in form.changed_data and form.cleaned_data['require_2fa']:
self.object.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
elif 'require_2fa' in form.changed_data and not form.cleaned_data['require_2fa']:
self.object.log_action('pretix.user.settings.2fa.disabled', user=self.request.user)
self.object.log_action('pretix.user.settings.changed', user=self.request.user, data=data)
return sup
class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
def post(self, request, *args, **kwargs):
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
try:
self.object.send_password_reset()
except SendMailException:
messages.error(request, _('There was an error sending the mail. Please try again later.'))
return redirect(self.get_success_url())
self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent',
user=request.user)
messages.success(request, _('We sent out an e-mail containing further instructions.'))
return redirect(self.get_success_url())
def get_success_url(self):
return reverse('control:users.edit', kwargs=self.kwargs)
class UserCreateView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, CreateView):
template_name = 'pretixcontrol/users/create.html'
context_object_name = 'user'
form_class = UserEditForm
def get_form(self, form_class=None):
f = super().get_form(form_class)
f.fields['new_pw'].required = True
f.fields['new_pw_repeat'].required = True
return f
def get_initial(self):
i = super().get_initial()
i['timezone'] = settings.TIME_ZONE
return i
def get_success_url(self):
return reverse('control:users')
def form_valid(self, form):
messages.success(self.request, _('The new user has been created.'))
return super().form_valid(form)

View File

@@ -28,6 +28,11 @@ superuser_urls = [
"global/settings/",
"global/update/",
"users/select2",
"users/",
"users/add",
"users/1/",
"users/1/impersonate",
"users/1/reset",
]
event_urls = [
@@ -145,7 +150,7 @@ def test_superuser_required(perf_patch, client, env, url):
env[1].is_superuser = True
env[1].save()
response = client.get('/control/' + url)
assert response.status_code == 200
assert response.status_code in (200, 302, 404)
@pytest.mark.django_db