From f08e4b41c4b640749790d323944f96d9c50acc6d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 12 Sep 2018 12:42:24 +0200 Subject: [PATCH 01/13] Data model --- .../migrations/0099_auto_20180912_1035.py | 40 +++++++++ src/pretix/base/models/__init__.py | 1 + src/pretix/base/models/devices.py | 82 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/pretix/base/migrations/0099_auto_20180912_1035.py create mode 100644 src/pretix/base/models/devices.py diff --git a/src/pretix/base/migrations/0099_auto_20180912_1035.py b/src/pretix/base/migrations/0099_auto_20180912_1035.py new file mode 100644 index 0000000000..fea4153cea --- /dev/null +++ b/src/pretix/base/migrations/0099_auto_20180912_1035.py @@ -0,0 +1,40 @@ +# Generated by Django 2.1 on 2018-09-12 10:35 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.devices + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0098_auto_20180731_1243_squashed_0100_item_require_approval'), + ] + + operations = [ + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('device_id', models.PositiveIntegerField()), + ('unique_serial', models.CharField(default=pretix.base.models.devices.generate_serial, max_length=190, unique=True)), + ('initialization_token', models.CharField(default=pretix.base.models.devices.generate_initialization_token, max_length=190, unique=True)), + ('api_token', models.CharField(max_length=190, null=True, unique=True)), + ('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')), + ('name', models.CharField(max_length=190, verbose_name='Name')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='Setup date')), + ('initialized', models.DateTimeField(null=True, verbose_name='Initialization date')), + ('hardware_brand', models.CharField(blank=True, max_length=190, null=True)), + ('hardware_model', models.CharField(blank=True, max_length=190, null=True)), + ('software_brand', models.CharField(blank=True, max_length=190, null=True)), + ('software_version', models.CharField(blank=True, max_length=190, null=True)), + ('limit_events', models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events')), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='pretixbase.Organizer')), + ], + ), + migrations.AlterUniqueTogether( + name='device', + unique_together={('organizer', 'device_id')}, + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index d483000373..b3614d5d32 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -2,6 +2,7 @@ from ..settings import GlobalSettingsObject_SettingsStore from .auth import U2FDevice, User from .base import CachedFile, LoggedModel, cachedfile_name from .checkin import Checkin, CheckinList +from .devices import Device from .event import ( Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue, RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token, diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py new file mode 100644 index 0000000000..f92fdbe91b --- /dev/null +++ b/src/pretix/base/models/devices.py @@ -0,0 +1,82 @@ +import string + +from django.db import models +from django.db.models import Max +from django.utils.crypto import get_random_string +from django.utils.translation import ugettext_lazy as _ + + +def generate_serial(): + serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16) + while Device.objects.filter(unique_serial=serial).exists(): + serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16) + return serial + + +def generate_initialization_token(): + token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits) + while Device.objects.filter(initialization_token=token).exists(): + token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits) + return token + + +def generate_api_token(): + token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits) + while Device.objects.filter(initialization_token=token).exists(): + token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits) + return token + + +class Device(models.Model): + organizer = models.ForeignKey( + 'pretixbase.Organizer', + on_delete=models.PROTECT, + related_name='devices' + ) + device_id = models.PositiveIntegerField() + unique_serial = models.CharField(max_length=190, default=generate_serial, unique=True) + initialization_token = models.CharField(max_length=190, default=generate_initialization_token, unique=True) + api_token = models.CharField(max_length=190, unique=True, null=True) + all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)")) + limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True) + name = models.CharField( + max_length=190, + verbose_name=_('Name') + ) + created = models.DateTimeField( + auto_now_add=True, + verbose_name=_('Setup date') + ) + initialized = models.DateTimeField( + verbose_name=_('Initialization date'), + null=True, + ) + hardware_brand = models.CharField( + max_length=190, + null=True, blank=True + ) + hardware_model = models.CharField( + max_length=190, + null=True, blank=True + ) + software_brand = models.CharField( + max_length=190, + null=True, blank=True + ) + software_version = models.CharField( + max_length=190, + null=True, blank=True + ) + + class Meta: + unique_together = (('organizer', 'device_id'),) + + def __str__(self): + return '#{} ({} {})'.format( + self.device_id, self.hardware_brand, self.hardware_model + ) + + def save(self, *args, **kwargs): + if not self.device_id: + self.device_id = (self.organizer.devices.aggregate(m=Max('device_id'))['m'] or 0) + 1 + super().save(*args, **kwargs) From ddb645aeea27dc73b09014ec818b89f92ee6c2f4 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 17 Sep 2018 18:27:29 +0200 Subject: [PATCH 02/13] Creating device objects --- src/pretix/control/forms/organizer.py | 21 +++- .../pretixcontrol/organizers/base.html | 7 ++ .../organizers/device_connect.html | 7 ++ .../pretixcontrol/organizers/device_edit.html | 23 ++++ .../pretixcontrol/organizers/devices.html | 90 ++++++++++++++++ src/pretix/control/urls.py | 7 ++ src/pretix/control/views/organizer.py | 102 +++++++++++++++++- 7 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/device_connect.html create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/device_edit.html create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/devices.html diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 6834ec1414..a1be50e814 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -6,7 +6,7 @@ from django.utils.translation import ugettext_lazy as _ from i18nfield.forms import I18nFormField, I18nTextarea from pretix.base.forms import I18nModelForm, SettingsForm -from pretix.base.models import Organizer, Team +from pretix.base.models import Device, Organizer, Team from pretix.control.forms import ExtFileField, MultipleLanguagesWidget from pretix.multidomain.models import KnownDomain from pretix.presale.style import get_fonts @@ -107,7 +107,7 @@ class TeamForm(forms.ModelForm): data = super().clean() if self.instance.pk and not data['can_change_teams']: if not self.instance.organizer.teams.exclude(pk=self.instance.pk).filter( - can_change_teams=True, members__isnull=False + can_change_teams=True, members__isnull=False ).exists(): raise ValidationError(_('The changes could not be saved because there would be no remaining team with ' 'the permission to change teams and permissions.')) @@ -115,6 +115,23 @@ class TeamForm(forms.ModelForm): return data +class DeviceForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + organizer = kwargs.pop('organizer') + super().__init__(*args, **kwargs) + self.fields['limit_events'].queryset = organizer.events.all() + + class Meta: + model = Device + fields = ['name', 'all_events', 'limit_events'] + widgets = { + 'limit_events': forms.CheckboxSelectMultiple(attrs={ + 'data-inverse-dependency': '#id_all_events' + }), + } + + class OrganizerSettingsForm(SettingsForm): organizer_info_text = I18nFormField( diff --git a/src/pretix/control/templates/pretixcontrol/organizers/base.html b/src/pretix/control/templates/pretixcontrol/organizers/base.html index ab88911115..7a67d8f70b 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/base.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/base.html @@ -33,6 +33,13 @@ {% endif %} + {% if 'can_change_organizer_settings' in request.orgapermset %} +
  • + + {% trans "Devices" %} + +
  • + {% endif %} {% for nav in nav_organizer %}
  • diff --git a/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html b/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html new file mode 100644 index 0000000000..e7c9ef7744 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html @@ -0,0 +1,7 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + {% trans "Connect to device:" %} {{ device.name }} + +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html new file mode 100644 index 0000000000..ab58994560 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html @@ -0,0 +1,23 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + {% if device %} + {% trans "Device:" %} {{ device.name }} + {% else %} + {% trans "Connect a new device" %} + {% endif %} +
    + {% csrf_token %} + {% bootstrap_form_errors form %} + {% bootstrap_field form.name layout="control" %} + {% bootstrap_field form.all_events layout="control" %} + {% bootstrap_field form.limit_events layout="control" %} +
    + +
    + +
    +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/devices.html b/src/pretix/control/templates/pretixcontrol/organizers/devices.html new file mode 100644 index 0000000000..234ac3d7cd --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/devices.html @@ -0,0 +1,90 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} + + {% trans "Connected devices" %} + + {% if devices|length == 0 %} +
    + {% else %} +

    + {% trans "Connect a device" %} +

    +
    + + + + + + + + + + + + + + {% for d in devices %} + + + + + + + + + + {% endfor %} + +
    {% trans "Device ID" %}{% trans "Name" %}{% trans "Hardware model" %}{% trans "Software" %}{% trans "Setup date" %}{% trans "Events" %}
    + {{ d.device_id }} + + {{ d.name }} + + {{ d.hardware_brand|default_if_none:"" }} {{ d.hardware_model|default_if_none:"" }} + + {{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }} + + {% if d.initialized %} + {{ d.initialized|date:"SHORT_DATETIME_FORMAT" }} + {% else %} + {% trans "Not yet initialized" %} + {% endif %} + + {% if d.all_events %} + {% trans "All" %} + {% else %} +
      + {% for e in d.limit_events.all %} +
    • + + {{ e }} + +
    • + {% endfor %} +
    + {% endif %} +
    + {% if not d.initialized %} + + {% trans "Connect" %} + {% endif %} + +
    +
    + {% include "pretixcontrol/pagination.html" %} + {% endif %} +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index b6e99ee123..6ec60df4cd 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -68,6 +68,13 @@ urlpatterns = [ url(r'^organizer/(?P[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'), url(r'^organizer/(?P[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(), name='organizer.display'), + url(r'^organizer/(?P[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'), + url(r'^organizer/(?P[^/]+)/device/add$', organizer.DeviceCreateView.as_view(), + name='organizer.device.add'), + url(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/edit', organizer.DeviceUpdateView.as_view(), + name='organizer.device.edit'), + url(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/connect', organizer.DeviceConnectView.as_view(), + name='organizer.device.connect'), url(r'^organizer/(?P[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'), url(r'^organizer/(?P[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'), url(r'^organizer/(?P[^/]+)/team/(?P[^/]+)/$', organizer.TeamMemberView.as_view(), diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 8cc01ef635..d8f8be9063 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -13,14 +13,14 @@ from django.views.generic import ( CreateView, DeleteView, DetailView, FormView, ListView, UpdateView, ) -from pretix.base.models import Organizer, Team, TeamInvite, User +from pretix.base.models import Device, Organizer, Team, TeamInvite, User from pretix.base.models.event import EventMetaProperty from pretix.base.models.organizer import TeamAPIToken from pretix.base.services.mail import SendMailException, mail from pretix.control.forms.filter import OrganizerFilterForm from pretix.control.forms.organizer import ( - EventMetaPropertyForm, OrganizerDisplaySettingsForm, OrganizerForm, - OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, + DeviceForm, EventMetaPropertyForm, OrganizerDisplaySettingsForm, + OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, ) from pretix.control.permissions import OrganizerPermissionRequiredMixin from pretix.control.signals import nav_organizer @@ -576,3 +576,99 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, 'organizer': self.request.organizer.slug, 'team': self.object.pk }) + + +class DeviceListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): + model = Device + template_name = 'pretixcontrol/organizers/devices.html' + permission = 'can_change_organizer_settings' + context_object_name = 'devices' + + def get_queryset(self): + return self.request.organizer.devices.prefetch_related('limit_events') + + +class DeviceCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): + model = Device + template_name = 'pretixcontrol/organizers/device_edit.html' + permission = 'can_change_organizer_settings' + form_class = DeviceForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['organizer'] = self.request.organizer + return kwargs + + def get_success_url(self): + return reverse('control:organizer.device.connect', kwargs={ + 'organizer': self.request.organizer.slug, + 'device': self.object.pk + }) + + def form_valid(self, form): + form.instance.organizer = self.request.organizer + ret = super().form_valid(form) + form.instance.members.add(self.request.user) + form.instance.log_action('pretix.device.created', user=self.request.user, data={ + k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()] + for k in form.changed_data + }) + return ret + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class DeviceUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): + model = Device + template_name = 'pretixcontrol/organizers/device_edit.html' + permission = 'can_change_organizer_settings' + context_object_name = 'device' + form_class = DeviceForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['organizer'] = self.request.organizer + return kwargs + + def get_object(self, queryset=None): + return get_object_or_404(Device, organizer=self.request.organizer, pk=self.kwargs.get('device')) + + def get_success_url(self): + return reverse('control:organizer.devices', kwargs={ + 'organizer': self.request.organizer.slug, + }) + + def form_valid(self, form): + if form.has_changed(): + self.object.log_action('pretix.device.changed', user=self.request.user, data={ + k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()] + for k in form.changed_data + }) + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def form_invalid(self, form): + messages.error(self.request, _('Your changes could not be saved.')) + return super().form_invalid(form) + + +class DeviceConnectView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView): + model = Device + template_name = 'pretixcontrol/organizers/device_connect.html' + permission = 'can_change_organizer_settings' + context_object_name = 'device' + form_class = DeviceForm + + def get_object(self, queryset=None): + return get_object_or_404(Device, organizer=self.request.organizer, pk=self.kwargs.get('device')) + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + if self.object.initialized: + messages.error(request, _('This device already has been connected.')) + return redirect(reverse('control:organizer.devices', kwargs={ + 'organizer': self.request.organizer.slug, + })) + return super().get(request, *args, **kwargs) From 6d01c99d386821f092ce4471e2f1adfadfe65eb7 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 19 Sep 2018 12:01:16 +0200 Subject: [PATCH 03/13] Auth mechanism --- src/pretix/api/auth/device.py | 25 ++++++ src/pretix/api/auth/permission.py | 7 +- src/pretix/api/urls.py | 4 +- src/pretix/api/views/device.py | 59 ++++++++++++++ src/pretix/base/models/devices.py | 78 ++++++++++++++++++- src/pretix/base/models/event.py | 12 +++ .../organizers/device_connect.html | 18 +++++ src/pretix/control/views/organizer.py | 20 ++++- src/pretix/settings.py | 1 + .../static/pretixcontrol/js/ui/devices.js | 14 ++++ src/pretix/static/pretixcontrol/js/ui/main.js | 10 +++ 11 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 src/pretix/api/auth/device.py create mode 100644 src/pretix/api/views/device.py create mode 100644 src/pretix/static/pretixcontrol/js/ui/devices.js diff --git a/src/pretix/api/auth/device.py b/src/pretix/api/auth/device.py new file mode 100644 index 0000000000..3700383082 --- /dev/null +++ b/src/pretix/api/auth/device.py @@ -0,0 +1,25 @@ +from django.contrib.auth.models import AnonymousUser +from rest_framework import exceptions +from rest_framework.authentication import TokenAuthentication + +from pretix.base.models import Device + + +class DeviceTokenAuthentication(TokenAuthentication): + model = Device + keyword = 'Device' + + def authenticate_credentials(self, key): + model = self.get_model() + try: + device = model.objects.select_related('organizer').get(api_token=key) + except model.DoesNotExist: + raise exceptions.AuthenticationFailed('Invalid token.') + + if not device.initialized: + raise exceptions.AuthenticationFailed('Device has not been initialized.') + + if not device.api_token: + raise exceptions.AuthenticationFailed('Device access has been revoked.') + + return AnonymousUser(), device diff --git a/src/pretix/api/auth/permission.py b/src/pretix/api/auth/permission.py index 0412d2004c..c8bb2529a4 100644 --- a/src/pretix/api/auth/permission.py +++ b/src/pretix/api/auth/permission.py @@ -1,7 +1,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission from pretix.api.models import OAuthAccessToken -from pretix.base.models import Event +from pretix.base.models import Device, Event from pretix.base.models.organizer import Organizer, TeamAPIToken from pretix.helpers.security import ( SessionInvalid, SessionReauthRequired, assert_session_valid, @@ -9,10 +9,9 @@ from pretix.helpers.security import ( class EventPermission(BasePermission): - model = TeamAPIToken def has_permission(self, request, view): - if not request.user.is_authenticated and not isinstance(request.auth, TeamAPIToken): + if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)): return False if request.method not in SAFE_METHODS and hasattr(view, 'write_permission'): @@ -31,7 +30,7 @@ class EventPermission(BasePermission): except SessionReauthRequired: return False - perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) + perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user) if 'event' in request.resolver_match.kwargs and 'organizer' in request.resolver_match.kwargs: request.event = Event.objects.filter( diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index a59bf9a68f..0a8fce1e2b 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -7,7 +7,8 @@ from rest_framework import routers from pretix.api.views import cart from .views import ( - checkin, event, item, oauth, order, organizer, voucher, waitinglist, + checkin, device, event, item, oauth, order, organizer, voucher, + waitinglist, ) router = routers.DefaultRouter() @@ -66,4 +67,5 @@ urlpatterns = [ url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"), url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"), url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"), + url(r"^device/initialize", device.InitializeView.as_view(), name="device.initialize"), ] diff --git a/src/pretix/api/views/device.py b/src/pretix/api/views/device.py new file mode 100644 index 0000000000..3a51ae3e70 --- /dev/null +++ b/src/pretix/api/views/device.py @@ -0,0 +1,59 @@ +import logging + +from django.utils.timezone import now +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.views import APIView + +from pretix.base.models import Device +from pretix.base.models.devices import generate_api_token + +logger = logging.getLogger(__name__) + + +class InitializationRequestSerializer(serializers.Serializer): + token = serializers.CharField(max_length=190) + hardware_brand = serializers.CharField(max_length=190) + hardware_model = serializers.CharField(max_length=190) + software_brand = serializers.CharField(max_length=190) + software_version = serializers.CharField(max_length=190) + + +class DeviceSerializer(serializers.ModelSerializer): + organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True) + + class Meta: + model = Device + fields = [ + 'organizer', 'device_id', 'unique_serial', 'api_token', + 'name' + ] + + +class InitializeView(APIView): + authentication_classes = tuple() + permission_classes = tuple() + + def post(self, request, format=None): + serializer = InitializationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + device = Device.objects.get(initialization_token=serializer.validated_data.get('token')) + except Device.DoesNotExist: + raise ValidationError({'token': ['Unknown initialization token.']}) + + if device.initialized: + raise ValidationError({'token': ['This initialization token has already been used.']}) + + device.initialized = now() + device.hardware_brand = serializer.validated_data.get('hardware_brand') + device.hardware_model = serializer.validated_data.get('hardware_model') + device.software_brand = serializer.validated_data.get('software_brand') + device.software_version = serializer.validated_data.get('software_version') + device.api_token = generate_api_token() + device.save() + + serializer = DeviceSerializer(device) + return Response(serializer.data) diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index f92fdbe91b..7a8b8d4859 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -5,6 +5,8 @@ from django.db.models import Max from django.utils.crypto import get_random_string from django.utils.translation import ugettext_lazy as _ +from pretix.base.models import LoggedModel + def generate_serial(): serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16) @@ -22,12 +24,12 @@ def generate_initialization_token(): def generate_api_token(): token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits) - while Device.objects.filter(initialization_token=token).exists(): + while Device.objects.filter(api_token=token).exists(): token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits) return token -class Device(models.Model): +class Device(LoggedModel): organizer = models.ForeignKey( 'pretixbase.Organizer', on_delete=models.PROTECT, @@ -80,3 +82,75 @@ class Device(models.Model): if not self.device_id: self.device_id = (self.organizer.devices.aggregate(m=Max('device_id'))['m'] or 0) + 1 super().save(*args, **kwargs) + + def permission_set(self) -> set: + return { + 'can_view_orders', + 'can_change_orders', + 'can_view_products' + } + + def get_event_permission_set(self, organizer, event) -> set: + """ + Gets a set of permissions (as strings) that a token holds for a particular event + + :param organizer: The organizer of the event + :param event: The event to check + :return: set of permissions + """ + has_event_access = (self.all_events and organizer == self.organizer) or ( + event in self.limit_events.all() + ) + return self.permission_set() if has_event_access else set() + + def get_organizer_permission_set(self, organizer) -> set: + """ + Gets a set of permissions (as strings) that a token holds for a particular organizer + + :param organizer: The organizer of the event + :return: set of permissions + """ + return self.permission_set() if self.organizer == organizer else set() + + def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool: + """ + Checks if this token is part of a team that grants access of type ``perm_name`` + to the event ``event``. + + :param organizer: The organizer of the event + :param event: The event to check + :param perm_name: The permission, e.g. ``can_change_teams`` + :param request: This parameter is ignored and only defined for compatibility reasons. + :return: bool + """ + has_event_access = (self.all_events and organizer == self.organizer) or ( + event in self.limit_events.all() + ) + if isinstance(perm_name, (tuple, list)): + return has_event_access and any(p in self.permission_set() for p in perm_name) + return has_event_access and (not perm_name or perm_name in self.permission_set()) + + def has_organizer_permission(self, organizer, perm_name=None, request=None): + """ + Checks if this token is part of a team that grants access of type ``perm_name`` + to the organizer ``organizer``. + + :param organizer: The organizer to check + :param perm_name: The permission, e.g. ``can_change_teams`` + :param request: This parameter is ignored and only defined for compatibility reasons. + :return: bool + """ + if isinstance(perm_name, (tuple, list)): + return organizer == self.organizer and any(p in self.permission_set() for p in perm_name) + return organizer == self.organizer and (not perm_name or perm_name in self.permission_set()) + + def get_events_with_any_permission(self): + """ + Returns a queryset of events the token has any permissions to. + + :return: Iterable of Events + """ + if self.all_events: + return self.organizer.events.all() + else: + return self.limit_events.all() diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 54b0018cfd..3498f13f22 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -276,6 +276,18 @@ class Event(EventMixin, LoggedModel): else: return super().presale_has_ended + def delete_all_orders(self, really=False): + from .orders import OrderRefund, OrderPayment, OrderPosition, OrderFee + + if not really: + raise TypeError("Pass really=True as a parameter.") + + OrderPosition.objects.all().delete(order__event=self) + OrderFee.objects.all().delete(order__event=self) + OrderPayment.objects.all().delete(order__event=self) + OrderRefund.objects.all().delete(order__event=self) + self.orders.all().delete() + def save(self, *args, **kwargs): obj = super().save(*args, **kwargs) self.cache.clear() diff --git a/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html b/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html index e7c9ef7744..55da884e8d 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html @@ -1,7 +1,25 @@ {% extends "pretixcontrol/organizers/base.html" %} {% load i18n %} +{% load staticfiles %} {% load bootstrap3 %} {% block inner %} {% trans "Connect to device:" %} {{ device.name }} +
    +
      +
    1. {% trans "Open the app that you want to connect and optionally reset it to the original state." %}
    2. +
    3. {% trans "Scan the following configuration code:" %}

      +
      + {% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %} +
      + {% trans "System URL:" %} {{ settings.SITE_URL }}
      + {% trans "Token:" %} {{ device.initialization_token }} +
    4. +
    +
    + + {% trans "Device overview" %} + + {% endblock %} diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index d8f8be9063..0a41947d13 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -1,10 +1,14 @@ +import json + from django import forms +from django.conf import settings from django.contrib import messages from django.core.exceptions import PermissionDenied from django.core.files import File from django.db import transaction from django.db.models import Count from django.forms import inlineformset_factory +from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils.functional import cached_property @@ -608,7 +612,6 @@ class DeviceCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi def form_valid(self, form): form.instance.organizer = self.request.organizer ret = super().form_valid(form) - form.instance.members.add(self.request.user) form.instance.log_action('pretix.device.created', user=self.request.user, data={ k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()] for k in form.changed_data @@ -666,9 +669,22 @@ class DeviceConnectView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMix def get(self, request, *args, **kwargs): self.object = self.get_object() + if 'ajax' in request.GET: + return JsonResponse({ + 'initialized': bool(self.object.initialized) + }) if self.object.initialized: - messages.error(request, _('This device already has been connected.')) + messages.success(request, _('This device has been set up successfully.')) return redirect(reverse('control:organizer.devices', kwargs={ 'organizer': self.request.organizer.slug, })) return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['qrdata'] = json.dumps({ + 'handshake_version': 1, + 'url': settings.SITE_URL, + 'token': self.object.initialization_token, + }) + return ctx diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 9a0acaafe7..d62417c455 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -278,6 +278,7 @@ REST_FRAMEWORK = { 'PAGE_SIZE': 50, 'DEFAULT_AUTHENTICATION_CLASSES': ( 'pretix.api.auth.token.TeamTokenAuthentication', + 'pretix.api.auth.device.DeviceTokenAuthentication', 'rest_framework.authentication.SessionAuthentication', 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', ), diff --git a/src/pretix/static/pretixcontrol/js/ui/devices.js b/src/pretix/static/pretixcontrol/js/ui/devices.js new file mode 100644 index 0000000000..8feac11595 --- /dev/null +++ b/src/pretix/static/pretixcontrol/js/ui/devices.js @@ -0,0 +1,14 @@ +/*globals $, Morris, gettext, RRule, RRuleSet*/ + +$(function () { + var update = function () { + $.getJSON(location.href + '?ajax=true', {}, function (data) { + if (data.initialized) { + location.reload(); + } else { + window.setTimeout(update, 500); + } + }); + }; + window.setTimeout(update, 500); +}); diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index 628d9e1180..7d7e943520 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -366,6 +366,16 @@ var form_handlers = function (el) { el.find("input[name=basics-slug]").bind("keyup keydown change", function () { $(this).closest(".form-group").find(".slug-length").toggle($(this).val().length > 16); }); + + el.find("script[data-replace-with-qr]").each(function () { + var $div = $("
    "); + $div.insertBefore($(this)); + $div.qrcode( + { + text: $(this).html() + } + ); + }); }; $(function () { From 7860d690fa8e2f83eb0490f180ec6168e665b764 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 19 Sep 2018 12:18:20 +0200 Subject: [PATCH 04/13] Add endpoints to update, roll and revoke devices --- src/pretix/api/urls.py | 5 +- src/pretix/api/views/device.py | 54 +++++++++++++++++++ src/pretix/api/views/order.py | 9 ++-- .../migrations/0099_auto_20180912_1035.py | 5 ++ src/pretix/base/models/base.py | 5 +- src/pretix/base/models/log.py | 1 + src/pretix/base/services/orders.py | 8 +-- .../templates/pretixcontrol/event/index.html | 3 ++ .../templates/pretixcontrol/event/logs.html | 3 ++ .../pretixcontrol/includes/logs.html | 3 ++ .../pretixcontrol/organizers/devices.html | 5 ++ src/pretix/control/views/dashboards.py | 3 +- src/pretix/control/views/event.py | 2 +- src/pretix/plugins/banktransfer/api.py | 3 +- 14 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 0a8fce1e2b..56be1365c5 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -67,5 +67,8 @@ urlpatterns = [ url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"), url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"), url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"), - url(r"^device/initialize", device.InitializeView.as_view(), name="device.initialize"), + url(r"^device/initialize$", device.InitializeView.as_view(), name="device.initialize"), + url(r"^device/update$", device.UpdateView.as_view(), name="device.update"), + url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"), + url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"), ] diff --git a/src/pretix/api/views/device.py b/src/pretix/api/views/device.py index 3a51ae3e70..b8fefdc20d 100644 --- a/src/pretix/api/views/device.py +++ b/src/pretix/api/views/device.py @@ -6,6 +6,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.response import Response from rest_framework.views import APIView +from pretix.api.auth.device import DeviceTokenAuthentication from pretix.base.models import Device from pretix.base.models.devices import generate_api_token @@ -20,6 +21,13 @@ class InitializationRequestSerializer(serializers.Serializer): software_version = serializers.CharField(max_length=190) +class UpdateRequestSerializer(serializers.Serializer): + hardware_brand = serializers.CharField(max_length=190) + hardware_model = serializers.CharField(max_length=190) + software_brand = serializers.CharField(max_length=190) + software_version = serializers.CharField(max_length=190) + + class DeviceSerializer(serializers.ModelSerializer): organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True) @@ -55,5 +63,51 @@ class InitializeView(APIView): device.api_token = generate_api_token() device.save() + device.log_action('pretix.device.initialized', data=serializer.validated_data, auth=device) + + serializer = DeviceSerializer(device) + return Response(serializer.data) + + +class UpdateView(APIView): + authentication_classes = (DeviceTokenAuthentication,) + + def post(self, request, format=None): + serializer = UpdateRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + device = request.auth + device.hardware_brand = serializer.validated_data.get('hardware_brand') + device.hardware_model = serializer.validated_data.get('hardware_model') + device.software_brand = serializer.validated_data.get('software_brand') + device.software_version = serializer.validated_data.get('software_version') + device.save() + device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device) + + serializer = DeviceSerializer(device) + return Response(serializer.data) + + +class RollKeyView(APIView): + authentication_classes = (DeviceTokenAuthentication,) + + def post(self, request, format=None): + device = request.auth + device.api_token = generate_api_token() + device.save() + device.log_action('pretix.device.keyroll', auth=device) + + serializer = DeviceSerializer(device) + return Response(serializer.data) + + +class RevokeKeyView(APIView): + authentication_classes = (DeviceTokenAuthentication,) + + def post(self, request, format=None): + device = request.auth + device.api_token = None + device.save() + device.log_action('pretix.device.keyroll', auth=device) + serializer = DeviceSerializer(device) return Response(serializer.data) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 14ecef8528..e18eebb1ad 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -25,7 +25,7 @@ from pretix.api.serializers.order import ( OrderRefundSerializer, OrderSerializer, ) from pretix.base.models import ( - Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota, + Device, Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken, ) from pretix.base.payment import PaymentException @@ -177,6 +177,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): order, user=request.user if request.user.is_authenticated else None, api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None, + device=request.auth if isinstance(request.auth, Device) else None, oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None, send_mail=send_mail ) @@ -191,7 +192,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): approve_order( order, user=request.user if request.user.is_authenticated else None, - auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None, + auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None, send_mail=send_mail, ) except Quota.QuotaExceededException as e: @@ -210,7 +211,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): deny_order( order, user=request.user if request.user.is_authenticated else None, - auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None, + auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None, send_mail=send_mail, comment=comment, ) @@ -267,7 +268,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): mark_order_refunded( order, user=request.user if request.user.is_authenticated else None, - api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + auth=(request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken, Device)) else None), ) return self.retrieve(request, [], **kwargs) diff --git a/src/pretix/base/migrations/0099_auto_20180912_1035.py b/src/pretix/base/migrations/0099_auto_20180912_1035.py index fea4153cea..8094d39c20 100644 --- a/src/pretix/base/migrations/0099_auto_20180912_1035.py +++ b/src/pretix/base/migrations/0099_auto_20180912_1035.py @@ -37,4 +37,9 @@ class Migration(migrations.Migration): name='device', unique_together={('organizer', 'device_id')}, ), + migrations.AddField( + model_name='logentry', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Device'), + ), ] diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index 98abf70924..c4b783b08c 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -47,6 +47,7 @@ class LoggingMixin: """ from .log import LogEntry from .event import Event + from .devices import Device from pretix.api.models import OAuthAccessToken, OAuthApplication from .organizer import TeamAPIToken from ..notifications import get_all_notification_types @@ -67,6 +68,8 @@ class LoggingMixin: kwargs['oauth_application'] = auth elif isinstance(auth, TeamAPIToken): kwargs['api_token'] = auth + elif isinstance(auth, Device): + kwargs['device'] = auth elif isinstance(api_token, TeamAPIToken): kwargs['api_token'] = api_token @@ -96,4 +99,4 @@ class LoggedModel(models.Model, LoggingMixin): return LogEntry.objects.filter( content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk - ).select_related('user', 'event', 'oauth_application', 'api_token') + ).select_related('user', 'event', 'oauth_application', 'api_token', 'device') diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index fb5de7d2fd..2f86753ac2 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -41,6 +41,7 @@ class LogEntry(models.Model): datetime = models.DateTimeField(auto_now_add=True, db_index=True) user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT) api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT) + device = models.ForeignKey('Device', null=True, blank=True, on_delete=models.PROTECT) oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT) event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL) action_type = models.CharField(max_length=255) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index e67a4ea2cb..bfa15dc2ee 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -21,7 +21,7 @@ from pretix.base.i18n import ( LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language, ) from pretix.base.models import ( - CartPosition, Event, Item, ItemVariation, Order, OrderPayment, + CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment, OrderPosition, Quota, User, Voucher, ) from pretix.base.models.event import SubEvent @@ -307,7 +307,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): @transaction.atomic -def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None): +def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None): """ Mark this order as canceled :param order: The order to change @@ -319,6 +319,8 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_ user = User.objects.get(pk=user) if isinstance(api_token, int): api_token = TeamAPIToken.objects.get(pk=api_token) + if isinstance(device, int): + device = Device.objects.get(pk=device) if isinstance(oauth_application, int): oauth_application = OAuthApplication.objects.get(pk=oauth_application) with order.event.lock(): @@ -327,7 +329,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_ order.status = Order.STATUS_CANCELED order.save() - order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application) + order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device) i = order.invoices.filter(is_cancellation=False).last() if i: generate_cancellation(i) diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index 2f71270283..c77932eac3 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -167,6 +167,9 @@
    {{ log.oauth_application.name }} {% endif %} + {% elif log.device %} + + {{ log.device.name }} {% elif log.api_token %} {{ log.api_token.name }} diff --git a/src/pretix/control/templates/pretixcontrol/event/logs.html b/src/pretix/control/templates/pretixcontrol/event/logs.html index 3ad186eaaf..e95ab7dc2e 100644 --- a/src/pretix/control/templates/pretixcontrol/event/logs.html +++ b/src/pretix/control/templates/pretixcontrol/event/logs.html @@ -54,6 +54,9 @@
    {{ log.oauth_application.name }} {% endif %} + {% elif log.device %} + + {{ log.device.name }} {% elif log.api_token %} {{ log.api_token.name }} diff --git a/src/pretix/control/templates/pretixcontrol/includes/logs.html b/src/pretix/control/templates/pretixcontrol/includes/logs.html index aa2c4ad339..3bdffd840c 100644 --- a/src/pretix/control/templates/pretixcontrol/includes/logs.html +++ b/src/pretix/control/templates/pretixcontrol/includes/logs.html @@ -19,6 +19,9 @@ {{ log.oauth_application.name }} {% endif %} + {% elif log.device %} + + {{ log.device.name }} {% elif log.api_token %} {{ log.api_token.name }} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/devices.html b/src/pretix/control/templates/pretixcontrol/organizers/devices.html index 234ac3d7cd..4d4c4d0ac0 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/devices.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/devices.html @@ -41,7 +41,9 @@ {{ d.device_id }} + {% if d.initialized and not d.api_token %}{% endif %} {{ d.name }} + {% if d.initialized and not d.api_token %}{% endif %} {{ d.hardware_brand|default_if_none:"" }} {{ d.hardware_model|default_if_none:"" }} @@ -55,6 +57,9 @@ {% else %} {% trans "Not yet initialized" %} {% endif %} + {% if d.initialized and not d.api_token %} + {% trans "Revoked" %} + {% endif %} {% if d.all_events %} diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index 257817f46c..ba77dae1d2 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -254,7 +254,8 @@ def event_index(request, organizer, event): can_change_orders = request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request) - qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application').order_by('-datetime') + qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application', + 'device').order_by('-datetime') qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST) if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request): qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order)) diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 193da1d5ce..50d7636d04 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -875,7 +875,7 @@ class EventLog(EventPermissionRequiredMixin, ListView): def get_queryset(self): qs = self.request.event.logentry_set.all().select_related( - 'user', 'content_type', 'api_token', 'oauth_application' + 'user', 'content_type', 'api_token', 'oauth_application', 'device' ).order_by('-datetime') qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST) if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_orders', diff --git a/src/pretix/plugins/banktransfer/api.py b/src/pretix/plugins/banktransfer/api.py index e3e02de52d..73af4dae8d 100644 --- a/src/pretix/plugins/banktransfer/api.py +++ b/src/pretix/plugins/banktransfer/api.py @@ -5,6 +5,7 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.mixins import CreateModelMixin from rest_framework.response import Response +from pretix.base.models import Device from pretix.base.models.organizer import TeamAPIToken from .models import BankImportJob, BankTransaction @@ -68,7 +69,7 @@ class BankImportJobViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): return serializer.save() def create(self, request, *args, **kwargs): - perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user) + perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user) if not perm_holder.has_organizer_permission(request.organizer, 'can_change_orders'): raise PermissionDenied('Invalid set of permissions') serializer = self.get_serializer(data=request.data) From 1aca5fb6ff2661d7c3d821426eddeede6e51ab93 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 19 Sep 2018 12:22:50 +0200 Subject: [PATCH 05/13] Fix wrong action parameter --- src/pretix/api/views/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pretix/api/views/device.py b/src/pretix/api/views/device.py index b8fefdc20d..535300fadb 100644 --- a/src/pretix/api/views/device.py +++ b/src/pretix/api/views/device.py @@ -107,7 +107,7 @@ class RevokeKeyView(APIView): device = request.auth device.api_token = None device.save() - device.log_action('pretix.device.keyroll', auth=device) + device.log_action('pretix.device.revoked', auth=device) serializer = DeviceSerializer(device) return Response(serializer.data) From 17e09c601ef7f992fbcd592938e0cd371ee95b5c Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 19 Sep 2018 13:57:51 +0200 Subject: [PATCH 06/13] Revoke + Logging --- src/pretix/control/logdisplay.py | 6 ++++ .../pretixcontrol/organizers/device_edit.html | 18 +++++++++++ .../organizers/device_revoke.html | 21 +++++++++++++ .../pretixcontrol/organizers/devices.html | 4 +++ src/pretix/control/urls.py | 6 ++-- src/pretix/control/views/organizer.py | 30 ++++++++++++++++++- 6 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/device_revoke.html diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 0df4d9b888..3d6ad6ce6c 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -274,6 +274,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.subevent.quota.added': pgettext_lazy('subevent', 'A quota has been added to the event date.'), 'pretix.subevent.quota.changed': pgettext_lazy('subevent', 'A quota has been changed on the event date.'), 'pretix.subevent.quota.deleted': pgettext_lazy('subevent', 'A quota has been removed from the event date.'), + 'pretix.device.created': _('The device has been created.'), + 'pretix.device.changed': _('The device has been changed.'), + 'pretix.device.revoked': _('Access of the device has been revoked.'), + 'pretix.device.initialized': _('The device has been initialized.'), + 'pretix.device.keyroll': _('The access token of the device has been regenerated.'), + 'pretix.device.updated': _('The device has notified the server of an hardware or software update.'), } data = json.loads(logentry.data) diff --git a/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html index ab58994560..86c07984ed 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/device_edit.html @@ -8,6 +8,10 @@ {% trans "Connect a new device" %} {% endif %}
    + {% if device %} +
    +
    + {% endif %} {% csrf_token %} {% bootstrap_form_errors form %} {% bootstrap_field form.name layout="control" %} @@ -19,5 +23,19 @@
    + {% if device %} +
    +
    +
    +
    +

    + {% trans "Device history" %} +

    +
    + {% include "pretixcontrol/includes/logs.html" with obj=device %} +
    +
    +
    + {% endif %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/device_revoke.html b/src/pretix/control/templates/pretixcontrol/organizers/device_revoke.html new file mode 100644 index 0000000000..621c4ae306 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/device_revoke.html @@ -0,0 +1,21 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} +

    {% trans "Revoke device access:" %} {{ device.name }}

    +
    + {% csrf_token %} +

    + {% blocktrans %}Are you sure you want remove access for this device?{% endblocktrans %} + {% trans "All data of this device will stay available, but you can't use the device any more." %} +

    +
    + + {% trans "Cancel" %} + + +
    +
    +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/devices.html b/src/pretix/control/templates/pretixcontrol/organizers/devices.html index 4d4c4d0ac0..edf95fb294 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/devices.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/devices.html @@ -81,6 +81,10 @@ {% trans "Connect" %} + {% elif d.api_token %} + + {% trans "Revoke access" %} {% endif %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 6ec60df4cd..4137e8fa09 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -71,10 +71,12 @@ urlpatterns = [ url(r'^organizer/(?P[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'), url(r'^organizer/(?P[^/]+)/device/add$', organizer.DeviceCreateView.as_view(), name='organizer.device.add'), - url(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/edit', organizer.DeviceUpdateView.as_view(), + url(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/edit$', organizer.DeviceUpdateView.as_view(), name='organizer.device.edit'), - url(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/connect', organizer.DeviceConnectView.as_view(), + url(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/connect$', organizer.DeviceConnectView.as_view(), name='organizer.device.connect'), + url(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/revoke$', organizer.DeviceRevokeView.as_view(), + name='organizer.device.revoke'), url(r'^organizer/(?P[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'), url(r'^organizer/(?P[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'), url(r'^organizer/(?P[^/]+)/team/(?P[^/]+)/$', organizer.TeamMemberView.as_view(), diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 0a41947d13..c547b78729 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -662,7 +662,6 @@ class DeviceConnectView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMix template_name = 'pretixcontrol/organizers/device_connect.html' permission = 'can_change_organizer_settings' context_object_name = 'device' - form_class = DeviceForm def get_object(self, queryset=None): return get_object_or_404(Device, organizer=self.request.organizer, pk=self.kwargs.get('device')) @@ -688,3 +687,32 @@ class DeviceConnectView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMix 'token': self.object.initialization_token, }) return ctx + + +class DeviceRevokeView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView): + model = Device + template_name = 'pretixcontrol/organizers/device_revoke.html' + permission = 'can_change_organizer_settings' + context_object_name = 'device' + + def get_object(self, queryset=None): + return get_object_or_404(Device, organizer=self.request.organizer, pk=self.kwargs.get('device')) + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + if not self.object.api_token: + messages.success(request, _('This device currently does not have access.')) + return redirect(reverse('control:organizer.devices', kwargs={ + 'organizer': self.request.organizer.slug, + })) + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + self.object.api_token = None + self.object.save() + self.object.log_action('pretix.device.revoked', user=self.request.user) + messages.success(request, _('Access for this device has been revoked.')) + return redirect(reverse('control:organizer.devices', kwargs={ + 'organizer': self.request.organizer.slug, + })) From 0bab8adc41627baec2fa952920c6b4eca12f77f7 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 19 Sep 2018 14:45:06 +0200 Subject: [PATCH 07/13] Add documentation on auth --- doc/api/auth.rst | 9 +++ doc/api/deviceauth.rst | 127 ++++++++++++++++++++++++++++++++++++++ doc/api/fundamentals.rst | 52 +++++----------- doc/api/index.rst | 2 +- doc/api/oauth.rst | 6 +- doc/api/tokenauth.rst | 36 +++++++++++ doc/spelling_wordlist.txt | 1 + 7 files changed, 191 insertions(+), 42 deletions(-) create mode 100644 doc/api/auth.rst create mode 100644 doc/api/deviceauth.rst create mode 100644 doc/api/tokenauth.rst diff --git a/doc/api/auth.rst b/doc/api/auth.rst new file mode 100644 index 0000000000..4becd60d49 --- /dev/null +++ b/doc/api/auth.rst @@ -0,0 +1,9 @@ +Authentication +============== + +.. toctree:: + :maxdepth: 2 + + tokenauth + oauth + deviceauth diff --git a/doc/api/deviceauth.rst b/doc/api/deviceauth.rst new file mode 100644 index 0000000000..52f784afc1 --- /dev/null +++ b/doc/api/deviceauth.rst @@ -0,0 +1,127 @@ +.. _`rest-deviceauth`: + +Device authentication +===================== + +Initializing a new device +------------------------- + +Users can create new devices in the "Device" section of their organizer settings. When creating +a new device, users can specify a list of events the device is allowed to access. After a new +device is created, users will be presented initialization instructions, consisting of an URL +and an initialization token. They will also be shown as a QR code with the following contents:: + + {"handshake_version": 1, "url": "https://pretix.eu", "token": "kpp4jn8g2ynzonp6"} + +Your application should be able to scan a QR code of this type, or allow to enter the URL and the +initialization token manually. The handshake version is not used for manual initialization. When a +QR code is scanned with a higher handshake version than you support, you should reject the request +and prompt the user to update the client application. + +After your application received the token, you need to call the initialization endpoint to obtain +a proper API token. At this point, you need to identify the name and version of your application, +as well as the type of underlying hardware. Example: + +.. sourcecode:: http + + POST /api/v1/device/initialize HTTP/1.1 + Host: pretix.eu + Content-Type: application/json + + { + "token": "kpp4jn8g2ynzonp6", + "hardware_brand": "Samsung", + "hardware_model": "Galaxy S", + "software_brand": "pretixdroid", + "software_version": "4.0.0" + } + +Every initialization token can only be used once. On success, you will receive a response containing +information on your device as well as your API token: + +.. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "organizer": "foo", + "device_id": 5, + "unique_serial": "HHZ9LW9JWP390VFZ", + "api_token": "1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd", + "name": "Bar" + } + +Please make sure that you store this ``api_token`` value. We also recommend storing your device ID, your assigned +``unique_serial``, and the ``organizer`` you have access to, but that's up to you. + +In case of an error, the response will look like this: + +.. sourcecode:: http + + HTTP/1.1 400 Bad Request + Content-Type: application/json + + {"token":["This initialization token has already been used."]} + + +Performing API requests +----------------------- + +You need to include the API token with every request to pretix' API in the ``Authorization`` header +like the following: + +.. sourcecode:: http + :emphasize-lines: 3 + + GET /api/v1/organizers/ HTTP/1.1 + Host: pretix.eu + Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd + +Updating the software version +----------------------------- + +If your application is updated, we ask you to tell the server about the new version in use. You can do this at the +following endpoint: + +.. sourcecode:: http + + POST /api/v1/device/update HTTP/1.1 + Host: pretix.eu + Content-Type: application/json + Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd + + { + "hardware_brand": "Samsung", + "hardware_model": "Galaxy S", + "software_brand": "pretixdroid", + "software_version": "4.1.0" + } + +Creating a new API key +---------------------- + +If you think your API key might have leaked or just want to be extra cautious, the API allows you to create a new key. +The old API key will be invalid immediately. A request for a new key looks like this: + +.. sourcecode:: http + + POST /api/v1/device/roll HTTP/1.1 + Host: pretix.eu + Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd + +The response will look like the response to the initialization request. + +Removing a device +----------------- + +If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to +invalidate your API key. There is no way to reverse this operation. + +.. sourcecode:: http + + POST /api/v1/device/revoke HTTP/1.1 + Host: pretix.eu + Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd + +This can also be done by the user through the web interface. diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst index fbb86e41ab..7a297ab1fb 100644 --- a/doc/api/fundamentals.rst +++ b/doc/api/fundamentals.rst @@ -9,44 +9,20 @@ with pretix' REST API, such as authentication, pagination and similar definition Authentication -------------- -If you're building an application for end users, we strongly recommend that you use our -:ref:`OAuth-based authentication progress `. However, for simpler needs, you -can also go with static API tokens that you can create on a per-team basis (see below). +To access the API, you need to present valid authentication credentials. pretix currently +supports the following authorization schemes: -You need to include the API token with every request to pretix' API in the ``Authorization`` header -like the following: - -.. sourcecode:: http - :emphasize-lines: 3 - - GET /api/v1/organizers/ HTTP/1.1 - Host: pretix.eu - Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k - -.. note:: The API currently also supports authentication via browser sessions, i.e. the - same way that you authenticate with pretix when using the browser interface. - Using this type of authentication is *not* officially supported for use by - third-party clients and might change or be removed at any time. We plan on - adding OAuth2 support in the future for user-level authentication. If you want - to use session authentication, be sure to comply with Django's `CSRF policies`_. - -Obtaining an API token ----------------------- - -To authenticate your API requests, you need to obtain an API token. You can create a -token in the pretix web interface on the level of organizer teams. Create a new team -or choose an existing team that has the level of permissions the token should have and -create a new token using the form below the list of team members: - -.. image:: img/token_form.png - :class: screenshot - -You can enter a description for the token to distinguish from other tokens later on. -Once you click "Add", you will be provided with an API token in the success message. -Copy this token, as you won't be able to retrieve it again. - -.. image:: img/token_success.png - :class: screenshot +* :ref:`rest-tokenauth`: This is the simplest way and recommended for server-side applications + that interact with pretix without user interaction. +* :ref:`rest-oauth`: This is the recommended way to use if you write a third-party application + that users can connect with their pretix account. It provides the best user experience, but + requires user interaction and slightly more implementation effort. +* :ref:`rest-deviceauth`: This is the recommended way if you build apps or hardware devices that can + connect to pretix, e.g. for processing check-ins or to sell tickets offline. It provides a way + to uniquely identify devices and allows for a quick configuration flow inside your software. +* Authentication using browser sessions: This is used by the pretix web interface and it is *not* + officially supported for use by third-party applications. It might change or be removed at any + time without prior notice. If you use it, you need to comply with Django's `CSRF policies`_. Permissions ----------- @@ -204,4 +180,4 @@ as the string values ``true`` and ``false``. If the ``ordering`` parameter is documented for a resource, you can use it to sort the result set by one of the allowed fields. Prepend a ``-`` to the field name to reverse the sort order. -.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax \ No newline at end of file +.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax diff --git a/doc/api/index.rst b/doc/api/index.rst index 0e8ace9c44..9d5302ec07 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -14,5 +14,5 @@ in functionality over time. :maxdepth: 2 fundamentals - oauth + auth resources/index diff --git a/doc/api/oauth.rst b/doc/api/oauth.rst index 21b972cd41..770227e6c2 100644 --- a/doc/api/oauth.rst +++ b/doc/api/oauth.rst @@ -1,7 +1,7 @@ .. _`rest-oauth`: -OAuth support / "Connect with pretix" -===================================== +OAuth authentication / "Connect with pretix" +============================================ In addition to static tokens, pretix supports `OAuth2`_-based authentication starting with pretix 1.16. This allows you to put a "Connect with pretix" button into your website or tool @@ -168,4 +168,4 @@ pretix user interface. .. _OAuth2: https://en.wikipedia.org/wiki/OAuth .. _OAuth2 Simplified: https://aaronparecki.com/oauth-2-simplified/ -.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication \ No newline at end of file +.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication diff --git a/doc/api/tokenauth.rst b/doc/api/tokenauth.rst new file mode 100644 index 0000000000..7f430a1666 --- /dev/null +++ b/doc/api/tokenauth.rst @@ -0,0 +1,36 @@ +.. _`rest-tokenauth`: + +Token-based authentication +========================== + +Obtaining an API token +---------------------- + +To authenticate your API requests with Tokens, you need to obtain a team-level API token. +You can create a token in the pretix web interface on the level of organizer teams. Create +a new team or choose an existing team that has the level of permissions the token should +have and create a new token using the form below the list of team members: + +.. image:: img/token_form.png + :class: screenshot + +You can enter a description for the token to distinguish from other tokens later on. +Once you click "Add", you will be provided with an API token in the success message. +Copy this token, as you won't be able to retrieve it again. + +.. image:: img/token_success.png + :class: screenshot + +Using an API token +------------------ + +You need to include the API token with every request to pretix' API in the ``Authorization`` header +like the following: + +.. sourcecode:: http + :emphasize-lines: 3 + + GET /api/v1/organizers/ HTTP/1.1 + Host: pretix.eu + Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k + diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index b6e975c82b..ed90ba67f8 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -23,6 +23,7 @@ cronjob cryptographic debian deduplication +deprovision discoverable django dockerfile From 1ba4047b1bc563069b03de632699e5142e29f13d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 20 Sep 2018 10:07:26 +0200 Subject: [PATCH 08/13] API-level tests --- src/pretix/api/views/organizer.py | 2 + src/pretix/base/models/devices.py | 3 +- src/pretix/base/services/orders.py | 5 +- src/tests/api/conftest.py | 21 +++- src/tests/api/test_auth.py | 24 +++++ src/tests/api/test_deviceauth.py | 148 +++++++++++++++++++++++++++++ src/tests/api/test_permissions.py | 33 +++++++ 7 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 src/tests/api/test_deviceauth.py diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index b23f03f7b8..9e323386e1 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -23,5 +23,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): ) else: return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True)) + elif hasattr(self.request.auth, 'organizer_id'): + return Organizer.objects.filter(pk=self.request.auth.organizer_id) else: return Organizer.objects.filter(pk=self.request.auth.team.organizer_id) diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index 7a8b8d4859..068b484ab8 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -85,9 +85,10 @@ class Device(LoggedModel): def permission_set(self) -> set: return { + 'can_change_items', # TODO: Remove, after read operations are allowed without 'can_view_orders', 'can_change_orders', - 'can_view_products' + 'can_view_vouchers', # TODO: Really required } def get_event_permission_set(self, organizer, event) -> set: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index bfa15dc2ee..effa207267 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1301,10 +1301,11 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str], @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) -def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None): +def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None, + device=None): try: try: - return _cancel_order(order, user, send_mail, api_token, oauth_application) + return _cancel_order(order, user, send_mail, api_token, device, oauth_application) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 8d043d4e86..50077fb466 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -1,10 +1,12 @@ from datetime import datetime import pytest +from django.utils.timezone import now from pytz import UTC from rest_framework.test import APIClient -from pretix.base.models import Event, Organizer, Team, User +from pretix.base.models import Device, Event, Organizer, Team, User +from pretix.base.models.devices import generate_api_token @pytest.fixture @@ -69,6 +71,17 @@ def team(organizer): ) +@pytest.fixture +def device(organizer): + return Device.objects.create( + organizer=organizer, + all_events=True, + name='Foo', + initialized=now(), + api_token=generate_api_token() + ) + + @pytest.fixture def user(): return User.objects.create_user('dummy@dummy.dummy', 'dummy') @@ -96,6 +109,12 @@ def token_client(client, team): return client +@pytest.fixture +def device_client(client, device): + client.credentials(HTTP_AUTHORIZATION='Device ' + device.api_token) + return client + + @pytest.fixture def subevent(event, meta_prop): event.has_subevents = True diff --git a/src/tests/api/test_auth.py b/src/tests/api/test_auth.py index 19e796250b..c3b8f1daf3 100644 --- a/src/tests/api/test_auth.py +++ b/src/tests/api/test_auth.py @@ -52,3 +52,27 @@ def test_token_auth_inactive(client, team): client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) resp = client.get('/api/v1/organizers/') assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_device_invalid(client): + client.credentials(HTTP_AUTHORIZATION='Device ABCDE') + resp = client.get('/api/v1/organizers/') + assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_device_auth_valid(client, device): + client.credentials(HTTP_AUTHORIZATION='Device ' + device.api_token) + resp = client.get('/api/v1/organizers/') + assert resp.status_code == 200 + assert len(resp.data['results']) == 1 + + +@pytest.mark.django_db +def test_device_auth_revoked(client, device): + client.credentials(HTTP_AUTHORIZATION='Device ' + device.api_token) + device.api_token = None + device.save() + resp = client.get('/api/v1/organizers/') + assert resp.status_code == 401 diff --git a/src/tests/api/test_deviceauth.py b/src/tests/api/test_deviceauth.py new file mode 100644 index 0000000000..1f2bba40b7 --- /dev/null +++ b/src/tests/api/test_deviceauth.py @@ -0,0 +1,148 @@ +import pytest + +from pretix.base.models import Device + + +@pytest.fixture +def new_device(organizer): + return Device.objects.create( + name="Foo", + all_events=True, + organizer=organizer + ) + + +@pytest.mark.django_db +def test_initialize_required_fields(client, new_device: Device): + resp = client.post('/api/v1/device/initialize') + assert resp.status_code == 400 + assert resp.data == { + 'token': ['This field is required.'], + 'hardware_brand': ['This field is required.'], + 'hardware_model': ['This field is required.'], + 'software_brand': ['This field is required.'], + 'software_version': ['This field is required.'], + } + + +@pytest.mark.django_db +def test_initialize_unknown_token(client, new_device: Device): + resp = client.post('/api/v1/device/initialize', { + 'token': 'aaa', + 'hardware_brand': 'Samsung', + 'hardware_model': 'Galaxy S', + 'software_brand': 'pretixdroid', + 'software_version': '4.0.0' + }) + assert resp.status_code == 400 + assert resp.data == {'token': ['Unknown initialization token.']} + + +@pytest.mark.django_db +def test_initialize_used_token(client, device: Device): + resp = client.post('/api/v1/device/initialize', { + 'token': device.initialization_token, + 'hardware_brand': 'Samsung', + 'hardware_model': 'Galaxy S', + 'software_brand': 'pretixdroid', + 'software_version': '4.0.0' + }) + assert resp.status_code == 400 + assert resp.data == {'token': ['This initialization token has already been used.']} + + +@pytest.mark.django_db +def test_initialize_valid_token(client, new_device: Device): + resp = client.post('/api/v1/device/initialize', { + 'token': new_device.initialization_token, + 'hardware_brand': 'Samsung', + 'hardware_model': 'Galaxy S', + 'software_brand': 'pretixdroid', + 'software_version': '4.0.0' + }) + assert resp.status_code == 200 + assert resp.data['organizer'] == 'dummy' + assert resp.data['name'] == 'Foo' + assert 'device_id' in resp.data + assert 'unique_serial' in resp.data + assert 'api_token' in resp.data + new_device.refresh_from_db() + assert new_device.api_token + assert new_device.initialized + + +@pytest.mark.django_db +def test_update_required_fields(device_client, device: Device): + resp = device_client.post('/api/v1/device/update') + assert resp.status_code == 400 + assert resp.data == { + 'hardware_brand': ['This field is required.'], + 'hardware_model': ['This field is required.'], + 'software_brand': ['This field is required.'], + 'software_version': ['This field is required.'], + } + + +@pytest.mark.django_db +def test_update_required_auth(client, token_client, device: Device): + resp = client.post('/api/v1/device/update', { + 'hardware_brand': 'Samsung', + 'hardware_model': 'Galaxy S', + 'software_brand': 'pretixdroid', + 'software_version': '5.0.0' + }) + assert resp.status_code == 401 + resp = token_client.post('/api/v1/device/update', { + 'hardware_brand': 'Samsung', + 'hardware_model': 'Galaxy S', + 'software_brand': 'pretixdroid', + 'software_version': '5.0.0' + }) + assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_update_valid_fields(device_client, device: Device): + resp = device_client.post('/api/v1/device/update', { + 'hardware_brand': 'Samsung', + 'hardware_model': 'Galaxy S', + 'software_brand': 'pretixdroid', + 'software_version': '5.0.0' + }) + assert resp.status_code == 200 + device.refresh_from_db() + assert device.software_version == '5.0.0' + + +@pytest.mark.django_db +def test_keyroll_required_auth(client, token_client, device: Device): + resp = client.post('/api/v1/device/roll', {}) + assert resp.status_code == 401 + resp = token_client.post('/api/v1/device/roll', {}) + assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_keyroll_valid(device_client, device: Device): + token = device.api_token + resp = device_client.post('/api/v1/device/roll') + assert resp.status_code == 200 + device.refresh_from_db() + assert device.api_token + assert device.api_token != token + + +@pytest.mark.django_db +def test_revoke_required_auth(client, token_client, device: Device): + resp = client.post('/api/v1/device/revoke', {}) + assert resp.status_code == 401 + resp = token_client.post('/api/v1/device/revoke', {}) + assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_revoke_valid(device_client, device: Device): + resp = device_client.post('/api/v1/device/revoke') + assert resp.status_code == 200 + device.refresh_from_db() + assert not device.api_token diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index ba6ca2d5cb..3a0125ab2c 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -127,6 +127,13 @@ def test_organizer_not_allowed(token_client, organizer): assert resp.status_code == 403 +@pytest.mark.django_db +def test_organizer_not_allowed_device(device_client, organizer): + o2 = Organizer.objects.create(slug='o2', name='Organizer 2') + resp = device_client.get('/api/v1/organizers/{}/events/'.format(o2.slug)) + assert resp.status_code == 403 + + @pytest.mark.django_db def test_organizer_not_existing(token_client, organizer): resp = token_client.get('/api/v1/organizers/{}/events/'.format('o2')) @@ -142,6 +149,13 @@ def test_event_allowed_all_events(token_client, team, organizer, event, url): assert resp.status_code == 200 +@pytest.mark.django_db +@pytest.mark.parametrize("url", event_urls) +def test_event_allowed_all_events_device(device_client, device, organizer, event, url): + resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + assert resp.status_code == 200 + + @pytest.mark.django_db @pytest.mark.parametrize("url", event_urls) def test_event_allowed_limit_events(token_client, organizer, team, event, url): @@ -152,6 +166,16 @@ def test_event_allowed_limit_events(token_client, organizer, team, event, url): assert resp.status_code == 200 +@pytest.mark.django_db +@pytest.mark.parametrize("url", event_urls) +def test_event_allowed_limit_events_device(device_client, organizer, device, event, url): + device.all_events = False + device.save() + device.limit_events.add(event) + resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + assert resp.status_code == 200 + + @pytest.mark.django_db @pytest.mark.parametrize("url", event_urls) def test_event_not_allowed(token_client, organizer, team, event, url): @@ -161,6 +185,15 @@ def test_event_not_allowed(token_client, organizer, team, event, url): assert resp.status_code == 403 +@pytest.mark.django_db +@pytest.mark.parametrize("url", event_urls) +def test_event_not_allowed_device(device_client, organizer, device, event, url): + device.all_events = False + device.save() + resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + assert resp.status_code == 403 + + @pytest.mark.django_db @pytest.mark.parametrize("url", event_urls) def test_event_not_existing(token_client, organizer, url, event): From ec58ab07b6bbe1baa4fe14562f78757f820eef4a Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 20 Sep 2018 10:27:57 +0200 Subject: [PATCH 09/13] Add tests for control --- src/tests/control/test_devices.py | 83 +++++++++++++++++++++++++++ src/tests/control/test_permissions.py | 5 ++ src/tests/control/test_views.py | 2 + 3 files changed, 90 insertions(+) create mode 100644 src/tests/control/test_devices.py diff --git a/src/tests/control/test_devices.py b/src/tests/control/test_devices.py new file mode 100644 index 0000000000..70b301f57a --- /dev/null +++ b/src/tests/control/test_devices.py @@ -0,0 +1,83 @@ +import pytest +from django.utils.timezone import now + +from pretix.base.models import Device, Event, Organizer, Team, User +from pretix.base.models.devices import generate_api_token + + +@pytest.fixture +def organizer(): + return Organizer.objects.create(name='Dummy', slug='dummy') + + +@pytest.fixture +def event(organizer): + event = Event.objects.create( + organizer=organizer, name='Dummy', slug='dummy', + date_from=now() + ) + return event + + +@pytest.fixture +def device(organizer): + return organizer.devices.create(name='Cashdesk') + + +@pytest.fixture +def admin_user(admin_team): + u = User.objects.create_user('dummy@dummy.dummy', 'dummy') + admin_team.members.add(u) + return u + + +@pytest.fixture +def admin_team(organizer): + return Team.objects.create(organizer=organizer, can_change_organizer_settings=True, name='Admin team') + + +@pytest.mark.django_db +def test_list_of_devices(event, admin_user, client, device): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/devices') + assert 'Cashdesk' in resp.rendered_content + + +@pytest.mark.django_db +def test_create_device(event, admin_user, admin_team, client): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.post('/control/organizer/dummy/device/add', { + 'name': 'Foo', + 'limit_events': str(event.pk), + }, follow=True) + d = Device.objects.last() + assert d.name == 'Foo' + assert not d.all_events + assert list(d.limit_events.all()) == [event] + assert d.initialization_token in resp.content.decode() + + +@pytest.mark.django_db +def test_update_device(event, admin_user, admin_team, device, client): + client.login(email='dummy@dummy.dummy', password='dummy') + client.post('/control/organizer/dummy/device/{}/edit'.format(device.pk), { + 'name': 'Cashdesk 2', + 'limit_events': str(event.pk), + }, follow=True) + device.refresh_from_db() + assert device.name == 'Cashdesk 2' + assert not device.all_events + assert list(device.limit_events.all()) == [event] + + +@pytest.mark.django_db +def test_revoke_device(event, admin_user, admin_team, device, client): + client.login(email='dummy@dummy.dummy', password='dummy') + device.api_token = generate_api_token() + device.initialized = now() + device.save() + + client.get('/control/organizer/dummy/device/{}/revoke'.format(device.pk)) + client.post('/control/organizer/dummy/device/{}/revoke'.format(device.pk), {}, follow=True) + device.refresh_from_db() + assert device.api_token is None diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index f99d9524c1..7e58a61ffa 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -131,6 +131,11 @@ organizer_urls = [ 'organizer/abc/team/1/edit', 'organizer/abc/team/1/delete', 'organizer/abc/team/add', + 'organizer/abc/devices', + 'organizer/abc/device/add', + 'organizer/abc/device/1/edit', + 'organizer/abc/device/1/connect', + 'organizer/abc/device/1/revoke', ] diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index 4658af6845..93d2ca7b78 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -98,6 +98,8 @@ def logged_in_client(client, event): ('/control/organizers/', 200), ('/control/organizers/add', 200), ('/control/organizer/{orga}/edit', 200), + ('/control/organizer/{orga}/teams', 200), + ('/control/organizer/{orga}/devices', 200), ('/control/events/', 200), ('/control/events/add', 200), From 304ad4e3db6fd63f64d0f842ee9612f671ba2ef6 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 25 Sep 2018 10:54:36 +0200 Subject: [PATCH 10/13] Restrict list of events --- src/pretix/api/views/event.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 9f429f44a0..75ce37d5f0 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -12,7 +12,9 @@ from pretix.api.serializers.event import ( TaxRuleSerializer, ) from pretix.api.views import ConditionalListView -from pretix.base.models import Event, ItemCategory, TaxRule, TeamAPIToken +from pretix.base.models import ( + Device, Event, ItemCategory, TaxRule, TeamAPIToken, +) from pretix.base.models.event import SubEvent from pretix.helpers.dicts import merge_dicts @@ -73,7 +75,7 @@ class EventViewSet(viewsets.ModelViewSet): filterset_class = EventFilter def get_queryset(self): - if isinstance(self.request.auth, TeamAPIToken): + if isinstance(self.request.auth, (TeamAPIToken, Device)): qs = self.request.auth.get_events_with_any_permission() elif self.request.user.is_authenticated: qs = self.request.user.get_events_with_any_permission(self.request).filter( @@ -224,7 +226,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet): def get_queryset(self): if getattr(self.request, 'event', None): qs = self.request.event.subevents - elif isinstance(self.request.auth, TeamAPIToken): + elif isinstance(self.request.auth, (TeamAPIToken, Device)): qs = SubEvent.objects.filter( event__organizer=self.request.organizer, event__in=self.request.auth.get_events_with_any_permission() From 23fdf8c4579ad625a8f73f0064c0fb51376757f3 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 25 Sep 2018 12:12:33 +0200 Subject: [PATCH 11/13] Add compatibility note --- .../organizers/device_connect.html | 13 +++++++++++++ .../pretixcontrol/organizers/devices.html | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html b/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html index 55da884e8d..3622ba7345 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/device_connect.html @@ -17,6 +17,19 @@
  • +
    + + {% blocktrans trimmed %} + Please note that this is a new feature that currently only works for beta-stage software, such as + pretixPOS. pretixdroid 1.x and pretixdesk 0.x are not supported by this feature. Future versions of + pretixdroid and pretixdesk will be supported through this menu. + {% endblocktrans %} + +

    + {% blocktrans trimmed %} + To set up pretixdroid or pretixdesk, please go to the Check-in devices section of an event. + {% endblocktrans %} +
    {% trans "Device overview" %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/devices.html b/src/pretix/control/templates/pretixcontrol/organizers/devices.html index edf95fb294..f9773f8cf0 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/devices.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/devices.html @@ -5,6 +5,23 @@ {% trans "Connected devices" %} +
    + {% blocktrans trimmed %} + This menu allows you to connect hardware devices such as box office terminals or scanning terminals to + your account. + {% endblocktrans %} + + {% blocktrans trimmed %} + Please note that this is a new feature that currently only works for beta-stage software, such as + pretixPOS. pretixdroid 1.x and pretixdesk 0.x are not supported by this feature. Future versions of + pretixdroid and pretixdesk will be supported through this menu. + {% endblocktrans %} + +

    + {% blocktrans trimmed %} + To set up pretixdroid or pretixdesk, please go to the Check-in devices section of an event. + {% endblocktrans %} +
    {% if devices|length == 0 %}

    From 63ba393c12f61aea1f29325a3f090e1a5ce928a5 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 25 Sep 2018 12:29:05 +0200 Subject: [PATCH 12/13] Proper permission handling and testing --- src/pretix/api/views/item.py | 15 ++-- src/pretix/base/models/devices.py | 2 - src/tests/api/test_permissions.py | 109 ++++++++++++++++++++++-------- 3 files changed, 89 insertions(+), 37 deletions(-) diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index b89d5c8e3b..fe31f3a93b 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -42,7 +42,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): ordering_fields = ('id', 'position') ordering = ('position', 'id') filterset_class = ItemFilter - permission = 'can_change_items' + permission = None write_permission = 'can_change_items' def get_queryset(self): @@ -92,7 +92,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet): filter_backends = (DjangoFilterBackend, OrderingFilter,) ordering_fields = ('id', 'position') ordering = ('id',) - permission = 'can_change_items' + permission = None write_permission = 'can_change_items' def get_queryset(self): @@ -154,7 +154,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet): filter_backends = (DjangoFilterBackend, OrderingFilter,) ordering_fields = ('id', 'position') ordering = ('id',) - permission = 'can_change_items' + permission = None write_permission = 'can_change_items' def get_queryset(self): @@ -210,7 +210,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): filterset_class = ItemCategoryFilter ordering_fields = ('id', 'position') ordering = ('position', 'id') - permission = 'can_change_items' + permission = None write_permission = 'can_change_items' def get_queryset(self): @@ -264,7 +264,8 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): filterset_class = QuestionFilter ordering_fields = ('id', 'position') ordering = ('position', 'id') - permission = 'can_change_items' + permission = None + write_permission = 'can_change_items' def get_queryset(self): return self.request.event.questions.prefetch_related('options').all() @@ -307,7 +308,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): filter_backends = (DjangoFilterBackend, OrderingFilter,) ordering_fields = ('id', 'position') ordering = ('position',) - permission = 'can_change_items' + permission = None write_permission = 'can_change_items' def get_queryset(self): @@ -362,7 +363,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): filterset_class = QuotaFilter ordering_fields = ('id', 'size') ordering = ('id',) - permission = 'can_change_items' + permission = None write_permission = 'can_change_items' def get_queryset(self): diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index 068b484ab8..38a5533a3e 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -85,10 +85,8 @@ class Device(LoggedModel): def permission_set(self) -> set: return { - 'can_change_items', # TODO: Remove, after read operations are allowed without 'can_view_orders', 'can_change_orders', - 'can_view_vouchers', # TODO: Really required } def get_event_permission_set(self, organizer, event) -> set: diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 3a0125ab2c..db57921019 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -6,18 +6,18 @@ from django.test import override_settings from pretix.base.models import Organizer event_urls = [ - 'categories/', - 'invoices/', - 'items/', - 'orders/', - 'orderpositions/', - 'questions/', - 'quotas/', - 'vouchers/', - 'subevents/', - 'taxrules/', - 'waitinglistentries/', - 'checkinlists/', + (None, 'categories/'), + ('can_view_orders', 'invoices/'), + (None, 'items/'), + ('can_view_orders', 'orders/'), + ('can_view_orders', 'orderpositions/'), + (None, 'questions/'), + (None, 'quotas/'), + ('can_view_vouchers', 'vouchers/'), + (None, 'subevents/'), + (None, 'taxrules/'), + ('can_view_orders', 'waitinglistentries/'), + ('can_view_orders', 'checkinlists/'), ] event_permission_sub_urls = [ @@ -36,38 +36,63 @@ event_permission_sub_urls = [ ('patch', 'can_change_orders', 'waitinglistentries/1/', 404), ('put', 'can_change_orders', 'waitinglistentries/1/', 404), ('post', 'can_change_orders', 'waitinglistentries/1/send_voucher/', 404), - ('get', 'can_change_items', 'categories/', 200), - ('get', 'can_change_items', 'items/', 200), - ('get', 'can_change_items', 'questions/', 200), - ('get', 'can_change_items', 'quotas/', 200), + ('get', None, 'categories/', 200), + ('get', None, 'items/', 200), + ('get', None, 'questions/', 200), + ('get', None, 'quotas/', 200), ('post', 'can_change_items', 'items/', 400), + ('get', None, 'items/1/', 404), ('put', 'can_change_items', 'items/1/', 404), ('patch', 'can_change_items', 'items/1/', 404), ('delete', 'can_change_items', 'items/1/', 404), ('post', 'can_change_items', 'categories/', 400), + ('get', None, 'categories/1/', 404), ('put', 'can_change_items', 'categories/1/', 404), ('patch', 'can_change_items', 'categories/1/', 404), ('delete', 'can_change_items', 'categories/1/', 404), ('post', 'can_change_items', 'items/1/variations/', 404), + ('get', None, 'items/1/variations/', 404), + ('get', None, 'items/1/variations/1/', 404), ('put', 'can_change_items', 'items/1/variations/1/', 404), ('patch', 'can_change_items', 'items/1/variations/1/', 404), ('delete', 'can_change_items', 'items/1/variations/1/', 404), + ('get', None, 'items/1/addons/', 404), + ('get', None, 'items/1/addons/1/', 404), ('post', 'can_change_items', 'items/1/addons/', 404), ('put', 'can_change_items', 'items/1/addons/1/', 404), ('patch', 'can_change_items', 'items/1/addons/1/', 404), ('delete', 'can_change_items', 'items/1/addons/1/', 404), + ('get', None, 'subevents/', 200), + ('get', None, 'subevents/1/', 404), + ('get', None, 'taxrules/', 200), + ('get', None, 'taxrules/1/', 404), ('post', 'can_change_event_settings', 'taxrules/', 400), ('put', 'can_change_event_settings', 'taxrules/1/', 404), ('patch', 'can_change_event_settings', 'taxrules/1/', 404), ('delete', 'can_change_event_settings', 'taxrules/1/', 404), + ('get', 'can_view_vouchers', 'vouchers/', 200), + ('get', 'can_view_vouchers', 'vouchers/1/', 404), ('post', 'can_change_vouchers', 'vouchers/', 400), ('put', 'can_change_vouchers', 'vouchers/1/', 404), ('patch', 'can_change_vouchers', 'vouchers/1/', 404), ('delete', 'can_change_vouchers', 'vouchers/1/', 404), + ('get', None, 'quotas/', 200), + ('get', None, 'quotas/1/', 404), ('post', 'can_change_items', 'quotas/', 400), ('put', 'can_change_items', 'quotas/1/', 404), ('patch', 'can_change_items', 'quotas/1/', 404), ('delete', 'can_change_items', 'quotas/1/', 404), + ('get', None, 'questions/', 200), + ('get', None, 'questions/1/', 404), + ('post', 'can_change_items', 'questions/', 400), + ('put', 'can_change_items', 'questions/1/', 404), + ('patch', 'can_change_items', 'questions/1/', 404), + ('delete', 'can_change_items', 'questions/1/', 404), + ('get', None, 'questions/1/options/', 404), + ('get', None, 'questions/1/options/1/', 404), + ('put', 'can_change_items', 'questions/1/options/1/', 404), + ('patch', 'can_change_items', 'questions/1/options/1/', 404), + ('delete', 'can_change_items', 'questions/1/options/1/', 404), ('post', 'can_change_orders', 'orders/', 400), ('post', 'can_change_orders', 'orders/ABC12/mark_paid/', 404), ('post', 'can_change_orders', 'orders/ABC12/mark_pending/', 404), @@ -92,6 +117,10 @@ event_permission_sub_urls = [ ('patch', 'can_change_event_settings', 'checkinlists/1/', 404), ('delete', 'can_change_event_settings', 'checkinlists/1/', 404), ('post', 'can_create_events', 'clone/', 400), + ('get', 'can_view_orders', 'cartpositions/', 200), + ('get', 'can_view_orders', 'cartpositions/1/', 404), + ('post', 'can_change_orders', 'cartpositions/', 400), + ('delete', 'can_change_orders', 'cartpositions/1/', 404), ] @@ -145,15 +174,18 @@ def test_organizer_not_existing(token_client, organizer): def test_event_allowed_all_events(token_client, team, organizer, event, url): team.all_events = True team.save() - resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url[1])) assert resp.status_code == 200 @pytest.mark.django_db @pytest.mark.parametrize("url", event_urls) def test_event_allowed_all_events_device(device_client, device, organizer, event, url): - resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) - assert resp.status_code == 200 + resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url[1])) + if url[0] is None or url[0] in device.permission_set(): + assert resp.status_code == 200 + else: + assert resp.status_code == 403 @pytest.mark.django_db @@ -162,7 +194,7 @@ def test_event_allowed_limit_events(token_client, organizer, team, event, url): team.all_events = False team.save() team.limit_events.add(event) - resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url[1])) assert resp.status_code == 200 @@ -172,8 +204,11 @@ def test_event_allowed_limit_events_device(device_client, organizer, device, eve device.all_events = False device.save() device.limit_events.add(event) - resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) - assert resp.status_code == 200 + resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url[1])) + if url[0] is None or url[0] in device.permission_set(): + assert resp.status_code == 200 + else: + assert resp.status_code == 403 @pytest.mark.django_db @@ -181,7 +216,7 @@ def test_event_allowed_limit_events_device(device_client, organizer, device, eve def test_event_not_allowed(token_client, organizer, team, event, url): team.all_events = False team.save() - resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url[1])) assert resp.status_code == 403 @@ -190,14 +225,14 @@ def test_event_not_allowed(token_client, organizer, team, event, url): def test_event_not_allowed_device(device_client, organizer, device, event, url): device.all_events = False device.save() - resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url[1])) assert resp.status_code == 403 @pytest.mark.django_db @pytest.mark.parametrize("url", event_urls) def test_event_not_existing(token_client, organizer, url, event): - resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url[1])) assert resp.status_code == 403 @@ -205,7 +240,8 @@ def test_event_not_existing(token_client, organizer, url, event): @pytest.mark.parametrize("urlset", event_permission_sub_urls) def test_token_event_subresources_permission_allowed(token_client, team, organizer, event, urlset): team.all_events = True - setattr(team, urlset[1], True) + if urlset[1]: + setattr(team, urlset[1], True) team.save() resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/{}'.format( organizer.slug, event.slug, urlset[2])) @@ -215,8 +251,11 @@ def test_token_event_subresources_permission_allowed(token_client, team, organiz @pytest.mark.django_db @pytest.mark.parametrize("urlset", event_permission_sub_urls) def test_token_event_subresources_permission_not_allowed(token_client, team, organizer, event, urlset): - team.all_events = True - setattr(team, urlset[1], False) + if urlset[1] is None: + team.all_events = False + else: + team.all_events = True + setattr(team, urlset[1], False) team.save() resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/{}'.format( organizer.slug, event.slug, urlset[2])) @@ -346,3 +385,17 @@ def test_update_session_activity(user_client, team, organizer, event): assert response.status_code == 200 assert user_client.session['pretix_auth_last_used'] > t1 + + +@pytest.mark.django_db +@pytest.mark.parametrize("urlset", event_permission_sub_urls) +def test_device_subresource_permission_check(device_client, device, organizer, event, urlset): + resp = getattr(device_client, urlset[0])('/api/v1/organizers/{}/events/{}/{}'.format( + organizer.slug, event.slug, urlset[2])) + if urlset[1] is None or urlset[1] in device.permission_set(): + assert resp.status_code == urlset[3] + else: + if urlset[3] == 404: + assert resp.status_code == 403 + else: + assert resp.status_code in (404, 403) From cd3ce848d15e214a2b6149b49ed3530aec22eaa5 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 25 Sep 2018 12:30:15 +0200 Subject: [PATCH 13/13] Document permissions --- doc/api/deviceauth.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/doc/api/deviceauth.rst b/doc/api/deviceauth.rst index 52f784afc1..1cd337b9c3 100644 --- a/doc/api/deviceauth.rst +++ b/doc/api/deviceauth.rst @@ -125,3 +125,13 @@ invalidate your API key. There is no way to reverse this operation. Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd This can also be done by the user through the web interface. + +Permissions +----------- + +Device authentication is currently hardcoded to grant the following permissions: + +* View event meta data and products etc. +* View and change orders + +Devices cannot change events or products and cannot access vouchers.