Add option to automatically check out all attendees at night (#1819)

This commit is contained in:
Raphael Michel
2020-10-21 18:26:57 +02:00
committed by GitHub
parent ffde521fcb
commit e3d9b3546d
11 changed files with 151 additions and 8 deletions
+7
View File
@@ -33,6 +33,7 @@ auto_checkin_sales_channels list of strings All items on th
allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in.
allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan.
rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged.
exit_all_at datetime Automatically check out (i.e. perform an exit scan) at this point in time. After this happened, this property will automatically be set exactly one day into the future. Note that this field is considered "internal configuration" and if you pull the list with ``If-Modified-Since``, the daily change in this field will not trigger a response.
===================================== ========================== =======================================================
.. versionchanged:: 1.10
@@ -60,6 +61,10 @@ rules object Custom check-in
The ``subevent_match`` and ``exclude`` query parameters have been added.
.. versionchanged:: 3.12
The ``exit_all_at`` attribute has been added.
Endpoints
---------
@@ -103,6 +108,7 @@ Endpoints
"subevent": null,
"allow_multiple_entries": false,
"allow_entry_after_exit": true,
"exit_all_at": null,
"rules": {},
"auto_checkin_sales_channels": [
"pretixpos"
@@ -152,6 +158,7 @@ Endpoints
"subevent": null,
"allow_multiple_entries": false,
"allow_entry_after_exit": true,
"exit_all_at": null,
"rules": {},
"auto_checkin_sales_channels": [
"pretixpos"
+1 -1
View File
@@ -15,7 +15,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
model = CheckinList
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit',
'rules')
'rules', 'exit_all_at')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -0,0 +1,18 @@
# Generated by Django 3.0.10 on 2020-10-20 06:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0166_auto_20201015_2029'),
]
operations = [
migrations.AddField(
model_name='checkinlist',
name='exit_all_at',
field=models.DateTimeField(blank=True, null=True),
),
]
+10 -3
View File
@@ -30,7 +30,10 @@ class CheckinList(LoggedModel):
help_text=_('Use this option to turn off warnings if a ticket is scanned a second time.'),
default=False
)
exit_all_at = models.DateTimeField(
verbose_name=_('Automatically check out everyone at'),
null=True, blank=True
)
auto_checkin_sales_channels = MultiStringField(
default=[],
blank=True,
@@ -62,7 +65,7 @@ class CheckinList(LoggedModel):
return qs
@property
def inside_count(self):
def positions_inside(self):
return self.positions.annotate(
last_entry=Subquery(
Checkin.objects.filter(
@@ -87,7 +90,11 @@ class CheckinList(LoggedModel):
& Q(
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry'))
)
).count()
)
@property
def inside_count(self):
return self.positions_inside.count()
@property
@scopes_disabled()
+21 -2
View File
@@ -7,11 +7,12 @@ from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import now, override
from django.utils.translation import gettext as _
from django_scopes import scope, scopes_disabled
from pretix.base.models import (
Checkin, CheckinList, Device, Order, OrderPosition, QuestionOption,
)
from pretix.base.signals import checkin_created, order_placed
from pretix.base.signals import checkin_created, order_placed, periodic_task
from pretix.helpers.jsonlogic import Logic
@@ -262,5 +263,23 @@ def order_placed(sender, **kwargs):
for cl in cls:
if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}:
if not cl.subevent_id or cl.subevent_id == op.subevent_id:
ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True)
ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True, type=Checkin.TYPE_ENTRY)
checkin_created.send(event, checkin=ci)
@receiver(periodic_task, dispatch_uid="autocheckin_exit_all")
@scopes_disabled()
def process_exit_all(sender, **kwargs):
qs = CheckinList.objects.filter(
exit_all_at__lte=now(),
exit_all_at__isnull=False
).select_related('event', 'event__organizer')
for cl in qs:
for p in cl.positions_inside:
with scope(organizer=cl.event.organizer):
ci = Checkin.objects.create(
position=p, list=cl, auto_checked_in=True, type=Checkin.TYPE_EXIT, datetime=cl.exit_all_at
)
checkin_created.send(cl.event, checkin=ci)
cl.exit_all_at = cl.exit_all_at + timedelta(days=1)
cl.save(update_fields=['exit_all_at'])
+21
View File
@@ -1,5 +1,8 @@
from datetime import datetime, timedelta
from django import forms
from django.urls import reverse
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import pgettext_lazy
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
@@ -10,6 +13,21 @@ from pretix.base.models.checkin import CheckinList
from pretix.control.forms.widgets import Select2
class NextTimeField(forms.TimeField):
def to_python(self, value):
value = super().to_python(value)
if value is None:
return
tz = get_current_timezone()
result = make_aware(datetime.combine(
now().astimezone(tz).date(),
value,
), tz)
if result <= now():
result += timedelta(days=1)
return result
class CheckinListForm(forms.ModelForm):
def __init__(self, **kwargs):
self.event = kwargs.pop('event')
@@ -55,16 +73,19 @@ class CheckinListForm(forms.ModelForm):
'allow_multiple_entries',
'allow_entry_after_exit',
'rules',
'exit_all_at',
]
widgets = {
'limit_products': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '<[name$=all_products]'
}),
'auto_checkin_sales_channels': forms.CheckboxSelectMultiple(),
'exit_all_at': forms.TimeInput(attrs={'class': 'timepickerfield'}),
}
field_classes = {
'limit_products': SafeModelMultipleChoiceField,
'subevent': SafeModelChoiceField,
'exit_all_at': NextTimeField,
}
@@ -58,6 +58,7 @@
{% bootstrap_field form.allow_multiple_entries layout="control" %}
{% 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" %}
<h3>{% trans "Custom check-in rule" %}</h3>
@@ -299,7 +299,11 @@
{% if line.checkins.all %}
{% for c in line.checkins.all %}
{% if c.type == "exit" %}
<span class="fa fa-fw fa-sign-out" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Exit scan: {{ date }}{% endblocktrans %}"></span>
{% if c.auto_checked_in %}
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
{% else %}
<span class="fa fa-fw fa-sign-out" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Exit scan: {{ date }}{% endblocktrans %}"></span>
{% endif %}
{% elif c.forced %}
<span class="fa fa-fw fa-warning" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}"></span>
{% elif c.auto_checked_in %}
+1
View File
@@ -143,6 +143,7 @@ TEST_LIST_RES = {
"allow_multiple_entries": False,
"allow_entry_after_exit": True,
"subevent": None,
"exit_all_at": None,
"rules": {}
}
+38 -1
View File
@@ -10,7 +10,7 @@ from freezegun import freeze_time
from pretix.base.models import Checkin, Event, Order, OrderPosition, Organizer
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, perform_checkin,
CheckInError, RequiredQuestionsError, perform_checkin, process_exit_all,
)
@@ -22,6 +22,7 @@ def event():
date_from=now(),
plugins='pretix.plugins.banktransfer'
)
event.settings.timezone = 'Europe/Berlin'
with scope(organizer=o):
yield event
@@ -600,3 +601,39 @@ def test_position_queries(django_assert_num_queries, position, clist):
perform_checkin(position, clist, {})
if 'sqlite' not in settings.DATABASES['default']['ENGINE']:
assert any('FOR UPDATE' in s['sql'] for s in captured)
@pytest.mark.django_db(transaction=True)
def test_auto_checkout_at_correct_time(event, position, clist):
clist.exit_all_at = event.timezone.localize(datetime(2020, 1, 2, 3, 0))
clist.save()
with freeze_time("2020-01-01 10:00:00+01:00"):
perform_checkin(position, clist, {})
with freeze_time("2020-01-02 02:55:00+01:00"):
process_exit_all(sender=None)
assert position.checkins.count() == 1
with freeze_time("2020-01-02 03:05:00+01:00"):
process_exit_all(sender=None)
assert clist.inside_count == 0
assert position.checkins.count() == 2
assert position.checkins.first().type == Checkin.TYPE_EXIT
clist.refresh_from_db()
assert clist.exit_all_at == event.timezone.localize(datetime(2020, 1, 3, 3, 0))
@pytest.mark.django_db(transaction=True)
def test_auto_check_out_only_if_checked_in(event, position, clist):
clist.exit_all_at = event.timezone.localize(datetime(2020, 1, 2, 3, 0))
clist.save()
with freeze_time("2020-01-02 03:05:00+01:00"):
process_exit_all(sender=None)
assert position.checkins.count() == 0
with freeze_time("2020-01-02 04:05:00+01:00"):
perform_checkin(position, clist, {})
with freeze_time("2020-01-02 04:10:00+01:00"):
perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT)
with freeze_time("2020-01-03 03:05:00+01:00"):
process_exit_all(sender=None)
assert position.checkins.count() == 2
+28
View File
@@ -4,6 +4,7 @@ from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from freezegun import freeze_time
from pretix.base.models import (
Checkin, Event, Item, ItemAddOn, ItemCategory, LogEntry, Order,
@@ -398,6 +399,7 @@ class CheckinListFormTest(SoupTest):
organizer=self.orga1, name='30C3', slug='30c3',
date_from=datetime(2013, 12, 26, tzinfo=timezone.utc),
)
self.event1.settings.timezone = 'Europe/Berlin'
t = Team.objects.create(organizer=self.orga1, can_change_event_settings=True, can_view_orders=True)
t.members.add(self.user)
t.limit_events.add(self.event1)
@@ -432,6 +434,32 @@ class CheckinListFormTest(SoupTest):
with scopes_disabled():
assert list(cl.limit_products.all()) == [self.item_ticket]
@freeze_time("2020-01-02 02:55:00+01:00")
def test_update_exit_all_at_current_day(self):
with scopes_disabled():
cl = self.event1.checkin_lists.create(name='All', all_products=True)
doc = self.get_doc('/control/event/%s/%s/checkinlists/%s/change' % (self.orga1.slug, self.event1.slug, cl.id))
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
form_data['exit_all_at'] = '03:00:00'
doc = self.post_doc('/control/event/%s/%s/checkinlists/%s/change' % (self.orga1.slug, self.event1.slug, cl.id),
form_data)
assert doc.select(".alert-success")
cl.refresh_from_db()
assert cl.exit_all_at == self.event1.timezone.localize(datetime(2020, 1, 2, 3, 0))
@freeze_time("2020-01-02 03:05:00+01:00")
def test_update_exit_all_at_next_day(self):
with scopes_disabled():
cl = self.event1.checkin_lists.create(name='All', all_products=True)
doc = self.get_doc('/control/event/%s/%s/checkinlists/%s/change' % (self.orga1.slug, self.event1.slug, cl.id))
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
form_data['exit_all_at'] = '03:00:00'
doc = self.post_doc('/control/event/%s/%s/checkinlists/%s/change' % (self.orga1.slug, self.event1.slug, cl.id),
form_data)
assert doc.select(".alert-success")
cl.refresh_from_db()
assert cl.exit_all_at == self.event1.timezone.localize(datetime(2020, 1, 3, 3, 0))
def test_delete(self):
with scopes_disabled():
cl = self.event1.checkin_lists.create(name='All', all_products=True)