diff --git a/doc/api/deviceauth.rst b/doc/api/deviceauth.rst index 1cd337b9c3..c5b92f2a43 100644 --- a/doc/api/deviceauth.rst +++ b/doc/api/deviceauth.rst @@ -49,11 +49,15 @@ information on your device as well as your API token: "device_id": 5, "unique_serial": "HHZ9LW9JWP390VFZ", "api_token": "1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd", - "name": "Bar" + "name": "Bar", + "gate": { + "id": 3, + "name": "South entrance" + } } 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. +``unique_serial``, and the ``organizer`` you have access to, but that's up to you. ``gate`` might be ``null``. In case of an error, the response will look like this: @@ -98,6 +102,8 @@ following endpoint: "software_version": "4.1.0" } +You will receive a response equivalent to the response of your initialization request. + Creating a new API key ---------------------- @@ -126,12 +132,65 @@ invalidate your API key. There is no way to reverse this operation. This can also be done by the user through the web interface. -Permissions ------------ +Permissions & security profiles +------------------------------- Device authentication is currently hardcoded to grant the following permissions: * View event meta data and products etc. -* View and change orders +* View orders +* Change orders +* Manage gift cards Devices cannot change events or products and cannot access vouchers. + +Additionally, when creating a device through the user interface or API, a user can specify a "security profile" for +the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security +policies for official pretix apps like pretixSCAN and pretixPOS. + +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. + +Event selection +--------------- + +In most cases, your application should allow the user to select the event and check-in list they work with manually +from a list. However, in some cases it is required to automatically configure the device for the correct event, for +example in a kiosk-like situation where nobody is operating the device. In this case, the app can query the server +for a suggestion which event should be used. You can also submit the configuration that is currently in use via +query parameters: + +.. sourcecode:: http + + GET /api/v1/device/eventselection?current_event=democon¤t_subevent=42¤t_checkinlist=542 HTTP/1.1 + Host: pretix.eu + Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd + +You can get three response codes: + +* ``304`` The server things you already selected a good event +* ``404`` The server has not found a suggestion for you +* ``200`` The server suggests a new event (body see below) + +.. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "event": "democon", + "subevent": 23, + "checkinlist": 5 + } + diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 7349b5862d..9731fcfb91 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -96,6 +96,7 @@ presale pretix pretixSCAN pretixdroid +pretixPOS pretixpresale prometheus proxied diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 6a0be502ee..b17ce79f5a 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -22,8 +22,10 @@ class PretixScanSecurityProfile(AllowListSecurityProfile): verbose_name = _('pretixSCAN') allowlist = ( ('GET', 'api-v1:version'), + ('GET', 'api-v1:device.eventselection'), ('POST', 'api-v1:device.update'), ('POST', 'api-v1:device.revoke'), + ('POST', 'api-v1:device.roll'), ('GET', 'api-v1:event-list'), ('GET', 'api-v1:event-detail'), ('GET', 'api-v1:subevent-list'), @@ -48,8 +50,10 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile): verbose_name = _('pretixSCAN (kiosk mode, online only)') allowlist = ( ('GET', 'api-v1:version'), + ('GET', 'api-v1:device.eventselection'), ('POST', 'api-v1:device.update'), ('POST', 'api-v1:device.revoke'), + ('POST', 'api-v1:device.roll'), ('GET', 'api-v1:event-list'), ('GET', 'api-v1:event-detail'), ('GET', 'api-v1:subevent-list'), @@ -72,8 +76,10 @@ class PretixPosSecurityProfile(AllowListSecurityProfile): verbose_name = _('pretixPOS') allowlist = ( ('GET', 'api-v1:version'), + ('GET', 'api-v1:device.eventselection'), ('POST', 'api-v1:device.update'), ('POST', 'api-v1:device.revoke'), + ('POST', 'api-v1:device.roll'), ('GET', 'api-v1:event-list'), ('GET', 'api-v1:event-detail'), ('GET', 'api-v1:subevent-list'), diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 3141cf6606..36e0b69a43 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -86,6 +86,7 @@ urlpatterns = [ 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"), + url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"), url(r"^me$", user.MeView.as_view(), name="user.me"), url(r"^version$", version.VersionView.as_view(), name="version"), ] diff --git a/src/pretix/api/views/device.py b/src/pretix/api/views/device.py index cd17b524dc..4a8419a4bd 100644 --- a/src/pretix/api/views/device.py +++ b/src/pretix/api/views/device.py @@ -1,13 +1,15 @@ import logging +from django.db.models import Exists, OuterRef, Q +from django.db.models.functions import Coalesce from django.utils.timezone import now -from rest_framework import serializers +from rest_framework import serializers, status 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 import CheckinList, Device, SubEvent from pretix.base.models.devices import Gate, generate_api_token logger = logging.getLogger(__name__) @@ -122,3 +124,156 @@ class RevokeKeyView(APIView): serializer = DeviceSerializer(device) return Response(serializer.data) + + +class EventSelectionView(APIView): + authentication_classes = (DeviceTokenAuthentication,) + + @property + def base_event_qs(self): + qs = self.request.auth.organizer.events.annotate( + first_date=Coalesce('date_admission', 'date_from'), + last_date=Coalesce('date_to', 'date_from'), + ).filter( + live=True, + has_subevents=False + ).order_by('first_date') + if self.request.auth.gate: + has_cl = CheckinList.objects.filter( + event=OuterRef('pk'), + gates__in=[self.request.auth.gate] + ) + qs = qs.annotate(has_cl=Exists(has_cl)).filter(has_cl=True) + return qs + + @property + def base_subevent_qs(self): + qs = SubEvent.objects.annotate( + first_date=Coalesce('date_admission', 'date_from'), + last_date=Coalesce('date_to', 'date_from'), + ).filter( + event__organizer=self.request.auth.organizer, + event__live=True, + active=True, + ).select_related('event').order_by('first_date') + if self.request.auth.gate: + has_cl = CheckinList.objects.filter( + Q(subevent__isnull=True) | Q(subevent=OuterRef('pk')), + event_id=OuterRef('event_id'), + gates__in=[self.request.auth.gate] + ) + qs = qs.annotate(has_cl=Exists(has_cl)).filter(has_cl=True) + return qs + + def get(self, request, format=None): + device = request.auth + current_event = None + current_subevent = None + if 'current_event' in request.query_params: + current_event = device.organizer.events.filter(slug=request.query_params['current_event']).first() + if current_event and 'current_subevent' in request.query_params: + current_subevent = current_event.subevents.filter(pk=request.query_params['current_subevent']).first() + if current_event and current_event.has_subevents and not current_subevent: + current_event = None + + if current_event: + current_ev = current_subevent or current_event + current_ev_start = current_ev.date_admission or current_ev.date_from + tz = current_event.timezone + if current_ev.date_to and current_ev_start < now() < current_ev.date_to: + # The event that is selected is currently running. Good enough. + return Response(status=status.HTTP_304_NOT_MODIFIED) + + # The event that is selected is not currently running. We cannot rely on all events having a proper end date. + # In any case, we'll need to decide between the event that last started (and might still be running) and the + # event that starts next (and might already be letting people in), so let's get these two! + last_started_ev = self.base_event_qs.filter(first_date__lte=now()).last() or self.base_subevent_qs.filter( + first_date__lte=now()).last() + + upcoming_event = self.base_event_qs.filter(first_date__gt=now()).first() + upcoming_subevent = self.base_subevent_qs.filter(first_date__gt=now()).first() + if upcoming_event and upcoming_subevent: + if upcoming_event.first_date > upcoming_subevent.first_date: + upcoming_ev = upcoming_subevent + else: + upcoming_ev = upcoming_event + else: + upcoming_ev = upcoming_event or upcoming_subevent + + if not upcoming_ev and not last_started_ev: + # Ooops, no events here + return Response(status=status.HTTP_404_NOT_FOUND) + elif upcoming_ev and not last_started_ev: + # No event running, so let's take the next one + return self._suggest_event(current_event, upcoming_ev) + elif last_started_ev and not upcoming_ev: + # No event upcoming, so let's take the next one + return self._suggest_event(current_event, last_started_ev) + + if last_started_ev.date_to and now() < last_started_ev.date_to: + # The event that last started is currently running. Good enough. + return self._suggest_event(current_event, last_started_ev) + + if not current_event: + tz = (upcoming_event or last_started_ev).timezone + + lse_d = last_started_ev.date_from.astimezone(tz).date() + upc_d = upcoming_ev.date_from.astimezone(tz).date() + now_d = now().astimezone(tz).date() + if lse_d == now_d and upc_d != now_d: + # Last event was today, next is tomorrow, stick with today + return self._suggest_event(current_event, last_started_ev) + elif lse_d != now_d and upc_d == now_d: + # Last event was yesterday, next is today, stick with today + return self._suggest_event(current_event, upcoming_ev) + + # Both last and next event are today, we switch over in the middle + if now() > last_started_ev.last_date + (upcoming_ev.first_date - last_started_ev.last_date) / 2: + return self._suggest_event(current_event, upcoming_ev) + else: + return self._suggest_event(current_event, last_started_ev) + + def _suggest_event(self, current_event, ev): + current_checkinlist = None + if current_event and 'current_checkinlist' in self.request.query_params: + current_checkinlist = current_event.checkin_lists.filter( + pk=self.request.query_params['current_checkinlist'] + ).first() + if isinstance(ev, SubEvent): + checkinlist_qs = ev.event.checkin_lists.filter(Q(subevent__isnull=True) | Q(subevent=ev)) + else: + checkinlist_qs = ev.checkin_lists + + if self.request.auth.gate: + checkinlist_qs = checkinlist_qs.filter(gates__in=[self.request.auth.gate]) + + checkinlist = None + if current_checkinlist: + checkinlist = checkinlist_qs.filter(Q(name=current_checkinlist.name) | Q(pk=current_checkinlist.pk)).first() + if not checkinlist: + checkinlist = checkinlist_qs.first() + r = { + 'event': { + 'slug': ev.event.slug if isinstance(ev, SubEvent) else ev.slug, + 'name': str(ev.event.name) if isinstance(ev, SubEvent) else str(ev.name), + }, + 'subevent': ev.pk if isinstance(ev, SubEvent) else None, + 'checkinlist': checkinlist.pk if checkinlist else None, + } + + if r == { + 'event': { + 'slug': current_event.slug if current_event else None, + 'name': str(current_event.name) if current_event else None, + }, + 'subevent': ( + int(self.request.query_params.get('current_subevent')) + if self.request.query_params.get('current_subevent') else None + ), + 'checkinlist': ( + int(self.request.query_params.get('current_checkinlist')) + if self.request.query_params.get('current_checkinlist') else None + ), + }: + return Response(status=status.HTTP_304_NOT_MODIFIED) + return Response(r) diff --git a/src/pretix/base/migrations/0169_checkinlist_gates.py b/src/pretix/base/migrations/0169_checkinlist_gates.py new file mode 100644 index 0000000000..20e8a096bb --- /dev/null +++ b/src/pretix/base/migrations/0169_checkinlist_gates.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.10 on 2020-10-24 15:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0168_auto_20201023_1447'), + ] + + operations = [ + migrations.AddField( + model_name='checkinlist', + name='gates', + field=models.ManyToManyField(to='pretixbase.Gate'), + ), + ] diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 8afad4360a..27d67c3d51 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -21,6 +21,11 @@ class CheckinList(LoggedModel): default=False, help_text=_('With this option, people will be able to check in even if the ' 'order have not been paid.')) + gates = models.ManyToManyField( + 'Gate', verbose_name=_("Gates"), blank=True, + help_text=_("Does not have any effect for the validation of tickets, only for the automatic configuration of " + "check-in devices.") + ) allow_entry_after_exit = models.BooleanField( verbose_name=_('Allow re-entering after an exit scan'), default=True diff --git a/src/pretix/control/forms/checkin.py b/src/pretix/control/forms/checkin.py index 4ba39bde26..16e13555b1 100644 --- a/src/pretix/control/forms/checkin.py +++ b/src/pretix/control/forms/checkin.py @@ -44,6 +44,11 @@ class CheckinListForm(forms.ModelForm): widget=forms.CheckboxSelectMultiple ) + if not self.event.organizer.gates.exists(): + del self.fields['gates'] + else: + self.fields['gates'].queryset = self.event.organizer.gates.all() + if self.event.has_subevents: self.fields['subevent'].queryset = self.event.subevents.all() self.fields['subevent'].widget = Select2( @@ -73,17 +78,22 @@ class CheckinListForm(forms.ModelForm): 'allow_multiple_entries', 'allow_entry_after_exit', 'rules', + 'gates', 'exit_all_at', ] widgets = { 'limit_products': forms.CheckboxSelectMultiple(attrs={ 'data-inverse-dependency': '<[name$=all_products]' }), + 'gates': forms.CheckboxSelectMultiple(attrs={ + 'class': 'scrolling-multiple-choice' + }), 'auto_checkin_sales_channels': forms.CheckboxSelectMultiple(), 'exit_all_at': forms.TimeInput(attrs={'class': 'timepickerfield'}), } field_classes = { 'limit_products': SafeModelMultipleChoiceField, + 'gates': SafeModelMultipleChoiceField, 'subevent': SafeModelChoiceField, 'exit_all_at': NextTimeField, } @@ -96,6 +106,11 @@ class SimpleCheckinListForm(forms.ModelForm): super().__init__(**kwargs) self.fields['limit_products'].queryset = self.event.items.all() + if not self.event.organizer.gates.exists(): + del self.fields['gates'] + else: + self.fields['gates'].queryset = self.event.organizer.gates.all() + class Meta: model = CheckinList localized_fields = '__all__' @@ -105,13 +120,18 @@ class SimpleCheckinListForm(forms.ModelForm): 'limit_products', 'include_pending', 'allow_entry_after_exit', + 'gates', ] widgets = { 'limit_products': forms.CheckboxSelectMultiple(attrs={ 'data-inverse-dependency': '<[name$=all_products]' }), + 'gates': forms.CheckboxSelectMultiple(attrs={ + 'class': 'scrolling-multiple-choice' + }), } field_classes = { 'limit_products': SafeModelMultipleChoiceField, 'subevent': SafeModelChoiceField, + 'gates': SafeModelMultipleChoiceField, } diff --git a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html index 58ce46cc7f..a313b97c8d 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html @@ -60,6 +60,9 @@ {% bootstrap_field form.allow_entry_after_exit layout="control" %} {% bootstrap_field form.exit_all_at layout="control" %} {% bootstrap_field form.auto_checkin_sales_channels layout="control" %} + {% if form.gates %} + {% bootstrap_field form.gates layout="control" %} + {% endif %}