diff --git a/src/pretix/base/migrations/0055_organizerpermission_can_change_permissions.py b/src/pretix/base/migrations/0055_organizerpermission_can_change_permissions.py new file mode 100644 index 000000000..3a674b10a --- /dev/null +++ b/src/pretix/base/migrations/0055_organizerpermission_can_change_permissions.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-01-07 12:37 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0054_auto_20170107_1058'), + ] + + operations = [ + migrations.AddField( + model_name='organizerpermission', + name='can_change_permissions', + field=models.BooleanField(default=True, verbose_name='Can change permissions'), + ), + ] diff --git a/src/pretix/base/migrations/0056_auto_20170107_1251.py b/src/pretix/base/migrations/0056_auto_20170107_1251.py new file mode 100644 index 000000000..a36fd87e6 --- /dev/null +++ b/src/pretix/base/migrations/0056_auto_20170107_1251.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-01-07 12:51 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import pretix.base.models.organizer + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0055_organizerpermission_can_change_permissions'), + ] + + operations = [ + migrations.AddField( + model_name='organizerpermission', + name='invite_email', + field=models.EmailField(blank=True, max_length=254, null=True), + ), + migrations.AddField( + model_name='organizerpermission', + name='invite_token', + field=models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True), + ), + migrations.AlterField( + model_name='organizerpermission', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index baf6fb915..ce7e51995 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -1,5 +1,8 @@ +import string + from django.core.validators import RegexValidator from django.db import models +from django.utils.crypto import get_random_string from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ @@ -76,6 +79,10 @@ class Organizer(LoggedModel): return ObjectRelatedCache(self) +def generate_invite_token(): + return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) + + class OrganizerPermission(models.Model): """ The relation between an Organizer and a User who has permissions to @@ -91,11 +98,17 @@ class OrganizerPermission(models.Model): """ organizer = models.ForeignKey(Organizer, related_name="user_perms", on_delete=models.CASCADE) - user = models.ForeignKey(User, related_name="organizer_perms") + user = models.ForeignKey(User, related_name="organizer_perms", on_delete=models.CASCADE, null=True, blank=True) + invite_email = models.EmailField(null=True, blank=True) + invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True) can_create_events = models.BooleanField( default=True, verbose_name=_("Can create events"), ) + can_change_permissions = models.BooleanField( + default=True, + verbose_name=_("Can change permissions"), + ) class Meta: verbose_name = _("Organizer permission") diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index fb76dc9ce..adc0e253b 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -9,7 +9,9 @@ from django.utils.deprecation import MiddlewareMixin from django.utils.encoding import force_str from django.utils.translation import ugettext as _ -from pretix.base.models import Event, EventPermission, Organizer +from pretix.base.models import ( + Event, EventPermission, Organizer, OrganizerPermission, +) class PermissionMiddleware(MiddlewareMixin): @@ -82,6 +84,10 @@ class PermissionMiddleware(MiddlewareMixin): slug=url.kwargs['organizer'], permitted__id__exact=request.user.id, )[0] + request.orgaperm = OrganizerPermission.objects.get( + organizer=request.organizer, + user=request.user + ) except IndexError: raise Http404(_("The selected organizer was not found or you " "have no permission to administrate it.")) diff --git a/src/pretix/control/templates/pretixcontrol/email/invitation_organizer.txt b/src/pretix/control/templates/pretixcontrol/email/invitation_organizer.txt new file mode 100644 index 000000000..77f284740 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/email/invitation_organizer.txt @@ -0,0 +1,16 @@ +{% load i18n %}{% blocktrans with url=url|safe %}Hello, + +you have been invited to the team of an event organizer that uses pretix +for their ticket sales. + +Organizer: {{ organizer }} + +If you want to join that team, just click on the following link: +{{ url }} + +If you do not want to join, you can safely ignore or delete this email. + +Best regards, + +Your pretix team +{% endblocktrans %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/detail.html b/src/pretix/control/templates/pretixcontrol/organizers/detail.html index b2fbf12d3..9b8a7a524 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/detail.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/detail.html @@ -3,19 +3,131 @@ {% load bootstrap3 %} {% block title %}{% trans "Organizer" %}{% endblock %} {% block content %} -

{% trans "Organizer" %}

-
- {% csrf_token %} - {% bootstrap_form_errors form %} -
- {% trans "General information" %} - {% bootstrap_field form.name layout="horizontal" %} - {% bootstrap_field form.slug layout="horizontal" %} -
-
- +

+ {% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %} + + + {% trans "Edit" %} + +

+
+
+
+ {% trans "Events" %} + {% if events|length == 0 %} +

+ {% trans "You currently do not have access to any events." %} +

+ {% else %} + + + + + + + + + {% for e in events %} + + + + + {% endfor %} + +
{% trans "Event name" %}{% trans "Start date" %}
+ {{ e.name }} + {{ e.get_date_from_display }}
+ {% endif %} + + + {% trans "Create a new event" %} + +
- + + {% if request.orgaperm.can_change_permissions %} +
+
+ {% csrf_token %} +
+ {% trans "Team" %} +

+ {% blocktrans trimmed %} + You can use the following list to control who can create new events in the name of this + organizer and who can add more people to this list. This does not + control who has access to a particular event. You can control the access to an + event in the "Permissions" section of the event's settings. A user does not need to be + on the list here to get access to an event. + {% endblocktrans %} +

+ + {% bootstrap_formset_errors formset %} + {{ formset.management_form }} +
+ + + + + + + + + + + {% for form in formset %} + + + + + + + {% endfor %} + + + + + + + + + + + + +
{% trans "User" %}{% trans "Create events" %}{% trans "Change permissions" %}{% trans "Delete" %}
+ {{ form.id }} + {% if form.instance.user %} + {{ form.instance.user }} + {% else %} + {{ form.instance.invite_email }} + + {% endif %} + {{ form.can_create_events }}{{ form.can_change_permissions }}{{ form.DELETE }}
+ {% trans "Adding a new user" %}
+ {% blocktrans trimmed %} + To add a new user, you can enter their email address here. If they already have a + pretix account, they will immediately be added to the team. Otherwise, they will + be sent an email with an invitation. + {% endblocktrans %} +
+
+
+ {% bootstrap_field add_form.user layout='inline' %} +
+
+
{{ add_form.can_create_events }}{{ add_form.can_change_permissions }}
+
+
+ +
+ +
+
+
+ {% endif %} +
{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html new file mode 100644 index 000000000..b2fbf12d3 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -0,0 +1,21 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Organizer" %}{% endblock %} +{% block content %} +

{% trans "Organizer" %}

+
+ {% csrf_token %} + {% bootstrap_form_errors form %} +
+ {% trans "General information" %} + {% bootstrap_field form.name layout="horizontal" %} + {% bootstrap_field form.slug layout="horizontal" %} +
+
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/index.html b/src/pretix/control/templates/pretixcontrol/organizers/index.html index 4c27ef3fe..61e0eb166 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/index.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/index.html @@ -20,7 +20,7 @@ {% for o in organizers %} - {{ o.name }} + {{ o.name }} {% endfor %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 907b503a1..81ffff052 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -32,6 +32,7 @@ urlpatterns = [ name='user.settings.2fa.delete'), url(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'), url(r'^organizers/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'), + url(r'^organizer/(?P[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'), url(r'^organizer/(?P[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'), url(r'^events/$', main.EventList.as_view(), name='events'), url(r'^events/add$', main.EventWizard.as_view(), name='events.add'), diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index a08b13cd5..8e4c02699 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -23,7 +23,9 @@ from u2flib_server.utils import rand_bytes from pretix.base.forms.auth import ( LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm, ) -from pretix.base.models import EventPermission, U2FDevice, User +from pretix.base.models import ( + EventPermission, OrganizerPermission, U2FDevice, User, +) from pretix.base.services.mail import SendMailException, mail from pretix.helpers.urls import build_absolute_uri @@ -107,25 +109,33 @@ def invite(request, token): try: perm = EventPermission.objects.get(invite_token=token) + desc = perm.event.name except EventPermission.DoesNotExist: - messages.error(request, _('You used an invalid link. Please copy the link from your email to the address bar ' - 'and make sure it is correct and that the link has not been used before.')) - return redirect('control:auth.login') + try: + perm = OrganizerPermission.objects.get(invite_token=token) + desc = perm.organizer.name + except OrganizerPermission.DoesNotExist: + messages.error(request, _('You used an invalid link. Please copy the link from your email to the address bar ' + 'and make sure it is correct and that the link has not been used before.')) + return redirect('control:auth.login') if request.user.is_authenticated: try: - EventPermission.objects.get(event=perm.event, user=request.user) + if isinstance(perm, EventPermission): + EventPermission.objects.get(event=perm.event, user=request.user) + else: + OrganizerPermission.objects.get(organizer=perm.organizer, user=request.user) messages.error(request, _('You cannot accept the invitation for "{}" as you already are part of ' - 'that event\'s team.').format(perm.event.name)) + 'this team.').format(desc)) return redirect('control:index') - except EventPermission.DoesNotExist: + except (EventPermission.DoesNotExist, OrganizerPermission.DoesNotExist): pass perm.invite_token = None perm.invite_email = None perm.user = request.user perm.save() - messages.success(request, _('You have now access to "{}".').format(perm.event.name)) + messages.success(request, _('You have now access to "{}".').format(desc)) return redirect('control:index') if request.method == 'POST': @@ -145,7 +155,7 @@ def invite(request, token): perm.invite_email = None perm.user = user perm.save() - messages.success(request, _('Welcome to pretix! You have now access to "{}".').format(perm.event.name)) + messages.success(request, _('Welcome to pretix! You have now access to "{}".').format(desc)) return redirect('control:index') else: form = RegistrationForm(initial={'email': perm.invite_email}) diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 247492a64..a15cd024f 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -1,12 +1,20 @@ +from django import forms from django.contrib import messages from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse +from django.db import transaction +from django.forms import modelformset_factory +from django.shortcuts import redirect +from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ -from django.views.generic import CreateView, ListView, UpdateView +from django.views.generic import CreateView, DetailView, ListView, UpdateView -from pretix.base.models import Organizer, OrganizerPermission +from pretix.base.forms import I18nModelForm +from pretix.base.models import Organizer, OrganizerPermission, User +from pretix.base.services.mail import SendMailException, mail from pretix.control.forms.organizer import OrganizerForm, OrganizerUpdateForm from pretix.control.permissions import OrganizerPermissionRequiredMixin +from pretix.helpers.urls import build_absolute_uri class OrganizerList(ListView): @@ -24,10 +32,147 @@ class OrganizerList(ListView): ) +class OrganizerPermissionForm(I18nModelForm): + class Meta: + model = OrganizerPermission + fields = ( + 'can_create_events', 'can_change_permissions' + ) + + +class OrganizerPermissionCreateForm(OrganizerPermissionForm): + user = forms.EmailField(required=False, label=_('User')) + + +class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView): + model = Organizer + template_name = 'pretixcontrol/organizers/detail.html' + permission = None + context_object_name = 'organizer' + + def get_object(self, queryset=None) -> Organizer: + return self.request.organizer + + @cached_property + def formset(self): + fs = modelformset_factory( + OrganizerPermission, + form=OrganizerPermissionForm, + can_delete=True, can_order=False, extra=0 + ) + return fs(data=self.request.POST if self.request.method == "POST" else None, + prefix="formset", + queryset=OrganizerPermission.objects.filter(organizer=self.request.organizer)) + + @cached_property + def add_form(self): + return OrganizerPermissionCreateForm(data=self.request.POST if self.request.method == "POST" else None, + prefix="add") + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['formset'] = self.formset + ctx['add_form'] = self.add_form + ctx['events'] = self.request.organizer.events.all() + return ctx + + def _send_invite(self, instance): + try: + mail( + instance.invite_email, + _('Account information changed'), + 'pretixcontrol/email/invitation_organizer.txt', + { + 'user': self, + 'organizer': self.request.organizer.name, + 'url': build_absolute_uri('control:auth.invite', kwargs={ + 'token': instance.invite_token + }) + }, + event=None, + locale=self.request.LANGUAGE_CODE + ) + except SendMailException: + pass # Already logged + + @transaction.atomic + def post(self, *args, **kwargs): + if not self.request.orgaperm.can_change_permissions: + raise PermissionDenied(_("You have no permission to do this.")) + + if self.formset.is_valid() and self.add_form.is_valid(): + if self.add_form.has_changed(): + logdata = { + k: v for k, v in self.add_form.cleaned_data.items() + } + + try: + self.add_form.instance.organizer = self.request.organizer + self.add_form.instance.organizer_id = self.request.organizer.id + self.add_form.instance.user = User.objects.get(email=self.add_form.cleaned_data['user']) + self.add_form.instance.user_id = self.add_form.instance.user.id + except User.DoesNotExist: + self.add_form.instance.invite_email = self.add_form.cleaned_data['user'] + if OrganizerPermission.objects.filter(invite_email=self.add_form.instance.invite_email, + organizer=self.request.organizer).exists(): + messages.error(self.request, _('This user already has been invited for this team.')) + return self.get(*args, **kwargs) + + self.add_form.save() + self._send_invite(self.add_form.instance) + + self.request.organizer.log_action( + 'pretix.organizer.permissions.invited', user=self.request.user, data=logdata + ) + else: + if OrganizerPermission.objects.filter(user=self.add_form.instance.user, + organizer=self.request.organizer).exists(): + messages.error(self.request, _('This user already has permissions for this team.')) + return self.get(*args, **kwargs) + self.add_form.save() + logdata['user'] = self.add_form.instance.user_id + self.request.organizer.log_action( + 'pretix.organizer.permissions.added', user=self.request.user, data=logdata + ) + for form in self.formset.forms: + if form.has_changed(): + changedata = { + k: form.cleaned_data.get(k) for k in form.changed_data + } + changedata['user'] = form.instance.user_id + self.request.organizer.log_action( + 'pretix.organizer.permissions.changed', user=self.request.user, data=changedata + ) + if form.instance.user_id == self.request.user.pk: + if not form.cleaned_data['can_change_permissions'] or form in self.formset.deleted_forms: + messages.error(self.request, _('You cannot remove your own permission to view this page.')) + return self.get(*args, **kwargs) + + for form in self.formset.deleted_forms: + logdata = { + k: v for k, v in form.cleaned_data.items() + } + self.request.organizer.log_action( + 'pretix.organizer.permissions.deleted', user=self.request.user, data=logdata + ) + + self.formset.save() + messages.success(self.request, _('Your changes have been saved.')) + return redirect(self.get_success_url()) + else: + messages.error(self.request, _('Your changes could not be saved.')) + return self.get(*args, **kwargs) + + def get_success_url(self) -> str: + return reverse('control:organizer', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView): model = Organizer form_class = OrganizerUpdateForm - template_name = 'pretixcontrol/organizers/detail.html' + template_name = 'pretixcontrol/organizers/edit.html' permission = None context_object_name = 'organizer'