diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index 621fcc60e3..d1a2edad5c 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -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" diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index a49f3b860b..4f7b4aff91 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -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) diff --git a/src/pretix/base/migrations/0167_checkinlist_exit_all_at.py b/src/pretix/base/migrations/0167_checkinlist_exit_all_at.py new file mode 100644 index 0000000000..820de9d788 --- /dev/null +++ b/src/pretix/base/migrations/0167_checkinlist_exit_all_at.py @@ -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), + ), + ] diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 1ef71be924..c350e703cc 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -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() diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index f90223a5a8..03c3e17491 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -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']) diff --git a/src/pretix/control/forms/checkin.py b/src/pretix/control/forms/checkin.py index ff7f0eb7bf..4ba39bde26 100644 --- a/src/pretix/control/forms/checkin.py +++ b/src/pretix/control/forms/checkin.py @@ -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, } diff --git a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html index 4ef22fd8c2..58ce46cc7f 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html @@ -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" %}

{% trans "Custom check-in rule" %}

diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 8209433a6a..c7eb5e524e 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -299,7 +299,11 @@ {% if line.checkins.all %} {% for c in line.checkins.all %} {% if c.type == "exit" %} - + {% if c.auto_checked_in %} + + {% else %} + + {% endif %} {% elif c.forced %} {% elif c.auto_checked_in %} diff --git a/src/tests/api/test_checkin.py b/src/tests/api/test_checkin.py index 5e231f9d72..60ef4a42b1 100644 --- a/src/tests/api/test_checkin.py +++ b/src/tests/api/test_checkin.py @@ -143,6 +143,7 @@ TEST_LIST_RES = { "allow_multiple_entries": False, "allow_entry_after_exit": True, "subevent": None, + "exit_all_at": None, "rules": {} } diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py index 5682742c77..89b565c08e 100644 --- a/src/tests/base/test_checkin.py +++ b/src/tests/base/test_checkin.py @@ -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 diff --git a/src/tests/control/test_checkins.py b/src/tests/control/test_checkins.py index 72be86ac51..9cced4b598 100644 --- a/src/tests/control/test_checkins.py +++ b/src/tests/control/test_checkins.py @@ -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)