Add event selection endpoint (#1827)

* Add event selection endpoint

* Minor fixes

* Add filter by gate
This commit is contained in:
Raphael Michel
2020-10-24 19:20:07 +02:00
committed by GitHub
parent 3865063b12
commit 987597b298
12 changed files with 515 additions and 7 deletions

View File

@@ -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'),

View File

@@ -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"),
]

View File

@@ -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)

View File

@@ -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'),
),
]

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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 %}
<h3>{% trans "Custom check-in rule" %}</h3>
<div id="rules-editor" class="form-inline">

View File

@@ -509,6 +509,9 @@
{% bootstrap_field form.all_products layout="control" %}
{% bootstrap_field form.limit_products layout="control" %}
{% bootstrap_field form.allow_entry_after_exit layout="control" %}
{% if form.gates %}
{% bootstrap_field form.gates layout="control" %}
{% endif %}
</div>
</div>
{% endfor %}
@@ -538,6 +541,9 @@
{% bootstrap_field cl_formset.empty_form.all_products layout="control" %}
{% bootstrap_field cl_formset.empty_form.limit_products layout="control" %}
{% bootstrap_field cl_formset.empty_form.allow_entry_after_exit layout="control" %}
{% if cl_formset.empty_form.gates %}
{% bootstrap_field cl_formset.empty_form.gates layout="control" %}
{% endif %}
</div>
</div>
{% endescapescript %}

View File

@@ -193,6 +193,9 @@
{% bootstrap_field form.all_products layout="control" %}
{% bootstrap_field form.limit_products layout="control" %}
{% bootstrap_field form.allow_entry_after_exit layout="control" %}
{% if form.gates %}
{% bootstrap_field form.gates layout="control" %}
{% endif %}
</div>
</div>
{% endfor %}
@@ -222,6 +225,9 @@
{% bootstrap_field cl_formset.empty_form.all_products layout="control" %}
{% bootstrap_field cl_formset.empty_form.limit_products layout="control" %}
{% bootstrap_field cl_formset.empty_form.allow_entry_after_exit layout="control" %}
{% if cl_formset.empty_form.gates %}
{% bootstrap_field cl_formset.empty_form.gates layout="control" %}
{% endif %}
</div>
</div>
{% endescapescript %}

View File

@@ -0,0 +1,228 @@
from datetime import datetime
import pytest
import pytz
from django_scopes import scopes_disabled
from freezegun import freeze_time
tz = pytz.timezone("Asia/Tokyo")
@pytest.mark.django_db
def test_no_events(device_client, device):
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e1')
assert resp.status_code == 404
@pytest.mark.django_db
def test_choose_between_events(device_client, device):
with scopes_disabled():
e1 = device.organizer.events.create(
name="Event", slug="e1", live=True,
date_from=tz.localize(datetime(2020, 1, 10, 14, 0)),
date_to=tz.localize(datetime(2020, 1, 10, 15, 0)),
)
cl1 = e1.checkin_lists.create(name="Same name")
e2 = device.organizer.events.create(
name="Event", slug="e2", live=True,
date_from=tz.localize(datetime(2020, 1, 10, 16, 0)),
date_to=tz.localize(datetime(2020, 1, 10, 17, 0)),
)
e2.checkin_lists.create(name="Other name")
cl2 = e2.checkin_lists.create(name="Same name")
e2.checkin_lists.create(name="Yet another name")
tomorrow = device.organizer.events.create(
name="Event", slug="tomorrow", live=True,
date_from=tz.localize(datetime(2020, 1, 11, 15, 0)),
date_to=tz.localize(datetime(2020, 1, 11, 16, 0)),
)
cl3 = tomorrow.checkin_lists.create(name="Just any name")
for e in device.organizer.events.all():
e.settings.timezone = "Asia/Tokyo"
# Keep current when still running
with freeze_time("2020-01-10T14:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e1&current_checkinlist={cl1.pk}')
assert resp.status_code == 304
with freeze_time("2020-01-10T16:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e1')
assert resp.status_code == 200
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e2')
assert resp.status_code == 304
# Next one only
with freeze_time("2020-01-10T12:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
# Last one only
with freeze_time("2020-01-10T17:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e2'
# Running one
with freeze_time("2020-01-10T14:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
with freeze_time("2020-01-10T16:01:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e1&current_checkinlist={cl1.pk}')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e2'
assert resp.data['checkinlist'] == cl2.pk
# Prefer the one on the same day
with freeze_time("2020-01-10T23:59:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e1&current_checkinlist={cl1.pk}')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e2'
assert resp.data['checkinlist'] == cl2.pk
with freeze_time("2020-01-11T01:00:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e1&current_checkinlist={cl1.pk}')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'tomorrow'
assert resp.data['checkinlist'] == cl3.pk
# Switch at half-time
with freeze_time("2020-01-10T15:29:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
with freeze_time("2020-01-10T15:31:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e2'
@pytest.mark.django_db
def test_choose_between_subevents(device_client, device):
with scopes_disabled():
e = device.organizer.events.create(
name="Event", slug="e1", live=True,
date_from=tz.localize(datetime(2020, 1, 10, 14, 0)),
has_subevents=True,
)
e.settings.timezone = "Asia/Tokyo"
se1 = e.subevents.create(
name="Event", active=True,
date_from=tz.localize(datetime(2020, 1, 10, 14, 0)),
date_to=tz.localize(datetime(2020, 1, 10, 15, 0)),
)
cl1 = e.checkin_lists.create(name="Same name", subevent=se1)
se2 = e.subevents.create(
name="Event", active=True,
date_from=tz.localize(datetime(2020, 1, 10, 16, 0)),
date_to=tz.localize(datetime(2020, 1, 10, 17, 0)),
)
cl2 = e.checkin_lists.create(name="Same name", subevent=se2)
cl3 = e.checkin_lists.create(name="Other name")
e.checkin_lists.create(name="Yet another name", subevent=se2)
se_tomorrow = e.subevents.create(
name="Event", active=True,
date_from=tz.localize(datetime(2020, 1, 11, 15, 0)),
date_to=tz.localize(datetime(2020, 1, 11, 16, 0)),
)
with freeze_time("2020-01-10T14:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e1&current_subevent={se1.pk}')
assert resp.status_code == 304
with freeze_time("2020-01-10T16:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e1&current_subevent={se1.pk}')
assert resp.status_code == 200
resp = device_client.get(f'/api/v1/device/eventselection?current_event=e1&current_subevent={se2.pk}')
assert resp.status_code == 304
# Next one only
with freeze_time("2020-01-10T12:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
assert resp.data['subevent'] == se1.pk
# Last one only
with freeze_time("2020-01-10T17:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
assert resp.data['subevent'] == se2.pk
# Running one
with freeze_time("2020-01-10T14:30:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
assert resp.data['subevent'] == se1.pk
with freeze_time("2020-01-10T16:01:00+09:00"):
resp = device_client.get(
f'/api/v1/device/eventselection?current_event=e1&current_checkinlist={cl1.pk}&current_subevent={se1.pk}')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
assert resp.data['subevent'] == se2.pk
assert resp.data['checkinlist'] == cl2.pk
# Prefer the one on the same day
with freeze_time("2020-01-10T23:59:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
assert resp.data['subevent'] == se2.pk
with freeze_time("2020-01-11T01:00:00+09:00"):
resp = device_client.get(
f'/api/v1/device/eventselection?current_event=e1&current_checkinlist={cl1.pk}&current_subevent={se1.pk}')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
assert resp.data['subevent'] == se_tomorrow.pk
assert resp.data['checkinlist'] == cl3.pk
# Switch at half-time
with freeze_time("2020-01-10T15:29:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
assert resp.data['subevent'] == se1.pk
with freeze_time("2020-01-10T15:31:00+09:00"):
resp = device_client.get(f'/api/v1/device/eventselection')
assert resp.data['event']['slug'] == 'e1'
assert resp.data['subevent'] == se2.pk
@pytest.mark.django_db
def test_require_gate(device_client, device):
with scopes_disabled():
g = device.organizer.gates.create(name="Gate 1")
device.gate = g
device.save()
e = device.organizer.events.create(
name="Event", slug="e1", live=True,
date_from=tz.localize(datetime(2020, 1, 10, 14, 0)),
has_subevents=True,
)
e.settings.timezone = "Asia/Tokyo"
se0 = e.subevents.create(
name="Event", active=True,
date_from=tz.localize(datetime(2020, 1, 10, 9, 0)),
date_to=tz.localize(datetime(2020, 1, 10, 10, 0)),
)
e.subevents.create(
name="Event", active=True,
date_from=tz.localize(datetime(2020, 1, 10, 14, 0)),
date_to=tz.localize(datetime(2020, 1, 10, 15, 0)),
)
cl1 = e.checkin_lists.create(name="Same name", subevent=se0)
se2 = e.subevents.create(
name="Event", active=True,
date_from=tz.localize(datetime(2020, 1, 10, 16, 0)),
date_to=tz.localize(datetime(2020, 1, 10, 17, 0)),
)
e.checkin_lists.create(name="Same name", subevent=se2)
cl3 = e.checkin_lists.create(name="Other name", subevent=se2)
cl3.gates.add(g)
with freeze_time("2020-01-10T11:00:00+09:00"):
resp = device_client.get(
f'/api/v1/device/eventselection?current_event=e1&current_checkinlist={cl1.pk}&current_subevent={se0.pk}')
assert resp.status_code == 200
assert resp.data['event']['slug'] == 'e1'
assert resp.data['subevent'] == se2.pk
assert resp.data['checkinlist'] == cl3.pk