diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py
index 5c956d383..20ac71f90 100644
--- a/src/pretix/base/models/auth.py
+++ b/src/pretix/base/models/auth.py
@@ -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
diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py
index ac0519001..f35411666 100644
--- a/src/pretix/control/forms/filter.py
+++ b/src/pretix/control/forms/filter.py
@@ -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
diff --git a/src/pretix/control/forms/users.py b/src/pretix/control/forms/users.py
new file mode 100644
index 000000000..a413e27e0
--- /dev/null
+++ b/src/pretix/control/forms/users.py
@@ -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)
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index 995b816cc..3b4bb0cb5 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -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
diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html
index 5c01fcfb9..446da9d99 100644
--- a/src/pretix/control/templates/pretixcontrol/base.html
+++ b/src/pretix/control/templates/pretixcontrol/base.html
@@ -217,6 +217,15 @@
{% trans "Order search" %}
+ {% if request.user.is_superuser %}
+
+
+
+ {% trans "Users" %}
+
+
+ {% endif %}
{% for nav in nav_global %}
{% trans "Create user" %}
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/users/form.html b/src/pretix/control/templates/pretixcontrol/users/form.html
new file mode 100644
index 000000000..484a29e6d
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/users/form.html
@@ -0,0 +1,73 @@
+{% extends "pretixcontrol/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "User" %}{% endblock %}
+{% block content %}
+ {% trans "User" %} {{ user.email }}
+
+
+
+
+
+
+
+
+
+ {% trans "User history" %}
+
+
+ {% include "pretixcontrol/includes/logs.html" with obj=user %}
+
+
+ {{ user.date_joined|date:"SHORT_DATETIME_FORMAT" }}
+
+
+ {% trans "User created." %}
+
+
+
+
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/users/index.html b/src/pretix/control/templates/pretixcontrol/users/index.html
new file mode 100644
index 000000000..85b9971d4
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/users/index.html
@@ -0,0 +1,68 @@
+{% extends "pretixcontrol/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% load urlreplace %}
+{% block title %}{% trans "Users" %}{% endblock %}
+{% block content %}
+ {% trans "Users" %}
+
+
+
+
+ {% trans "Create a new user" %}
+
+
+
+
+
+ |
+ {% trans "E-mail address" %}
+
+
+ |
+
+ {% trans "Full name" %}
+
+
+ |
+ {% trans "Active" %} |
+ {% trans "Administrator" %} |
+ |
+
+
+
+ {% for u in users %}
+
+ |
+ {{ u.email }}
+ |
+ {{ u.fullname }} |
+ {% if u.is_active %}{% endif %} |
+ {% if u.is_superuser %}{% endif %} |
+
+
+ |
+
+ {% endfor %}
+
+
+ {% include "pretixcontrol/pagination.html" %}
+{% endblock %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index 97d0c28ce..fad11adec 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -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\d+)/$', users.UserEditView.as_view(), name='users.edit'),
+ url(r'^users/(?P\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'),
diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py
index 51a02bef7..27593bcf7 100644
--- a/src/pretix/control/views/auth.py
+++ b/src/pretix/control/views/auth.py
@@ -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)
diff --git a/src/pretix/control/views/users.py b/src/pretix/control/views/users.py
new file mode 100644
index 000000000..9cca4cc12
--- /dev/null
+++ b/src/pretix/control/views/users.py
@@ -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)
diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py
index d1c06059f..087b30a15 100644
--- a/src/tests/control/test_permissions.py
+++ b/src/tests/control/test_permissions.py
@@ -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