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 () {