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" %} +
    + {% csrf_token %} + {% bootstrap_form_errors form %} +
    + {% trans "Base settings" %} + {% 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' %} +
    +
    + {% trans "Log-in settings" %} + {% bootstrap_field form.email layout='control' %} + {% bootstrap_field form.new_pw layout='control' %} + {% bootstrap_field form.new_pw_repeat layout='control' %} +
    +
    + +
    +
    +{% 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 }}

    +

    +

    + {% csrf_token %} + +
    +

    +
    + +
    +
    +
    +

    + {% 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" %}

    +
    +
    + {% bootstrap_field filter_form.query layout='inline' %} +
    +
    + {% bootstrap_field filter_form.status layout='inline' %} +
    +
    + {% bootstrap_field filter_form.superuser layout='inline' %} +
    +
    + +
    +
    +

    + + + {% trans "Create a new user" %} + +

    + + + + + + + + + + + + {% for u in users %} + + + + + + + + {% endfor %} + +
    + {% trans "E-mail address" %} + + + + {% trans "Full name" %} + + + {% trans "Active" %}{% trans "Administrator" %}
    + {{ u.email }} + {{ u.fullname }}{% if u.is_active %}{% endif %}{% if u.is_superuser %}{% endif %} + +
    + {% 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