diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index 7ce5d5603e..9fa2527dd0 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -37,12 +37,18 @@ allow_entry_after_exit boolean If ``true``, su 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. addon_match boolean If ``true``, tickets on this list can be redeemed by scanning their parent ticket if this still leads to an unambiguous match. +ignore_in_statistics boolean If ``true``, check-ins on this list will be ignored in most reporting features. +consider_tickets_used boolean If ``true`` (default), tickets checked in on this list will be considered "used" by other functionality, i.e. when checking if they can still be canceled. ===================================== ========================== ======================================================= .. versionchanged:: 4.12 The ``addon_match`` attribute has been added. +.. versionchanged:: 2023.9 + + The ``ignore_in_statistics`` and ``consider_tickets_used`` attributes have been added. + Endpoints --------- @@ -767,4 +773,4 @@ Order position endpoints :statuscode 404: The requested order position or check-in list does not exist. -.. _security issues: https://pretix.eu/about/de/blog/20220705-release-4111/ \ No newline at end of file +.. _security issues: https://pretix.eu/about/de/blog/20220705-release-4111/ diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index 2c41fc18d4..c40bb21d78 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -38,7 +38,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', 'exit_all_at', 'addon_match') + 'rules', 'exit_all_at', 'addon_match', 'ignore_in_statistics', 'consider_tickets_used') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/pretix/base/migrations/0247_checkinlist.py b/src/pretix/base/migrations/0247_checkinlist.py new file mode 100644 index 0000000000..750bff492e --- /dev/null +++ b/src/pretix/base/migrations/0247_checkinlist.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.4 on 2023-09-06 11:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pretixbase", "0246_bigint"), + ] + + operations = [ + migrations.AddField( + model_name="checkinlist", + name="consider_tickets_used", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="checkinlist", + name="ignore_in_statistics", + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index ceb3e2f6dd..c54ff9a5df 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -62,6 +62,16 @@ class CheckinList(LoggedModel): 'and valid for check-in regardless of which date they are purchased for. ' 'You can limit their validity through the advanced check-in rules, ' 'though.')) + ignore_in_statistics = models.BooleanField( + verbose_name=pgettext_lazy('checkin', 'Ignore check-ins on this list in statistics'), + default=False + ) + consider_tickets_used = models.BooleanField( + verbose_name=pgettext_lazy('checkin', 'Tickets with a check-in on this list should be considered "used"'), + help_text=_('This is relevant in various situations, e.g. for deciding if a ticket can still be canceled by ' + 'the customer.'), + default=True + ) include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'), default=False, help_text=_('With this option, people will be able to check in even if the ' diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 6c641cd7ad..3f5e35d63f 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -633,7 +633,7 @@ class Order(LockModel, LoggedModel): positions = list( self.positions.all().annotate( has_variations=Exists(ItemVariation.objects.filter(item_id=OuterRef('item_id'))), - has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'))) + has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True)) ).select_related('item').prefetch_related('issued_gift_cards') ) if self.event.settings.change_allow_user_if_checked_in: @@ -665,7 +665,7 @@ class Order(LockModel, LoggedModel): return False positions = list( self.positions.all().annotate( - has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'))) + has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True)) ).select_related('item').prefetch_related('issued_gift_cards') ) cancelable = all([op.item.allow_cancel and not op.has_checkin and not op.blocked for op in positions]) @@ -820,7 +820,7 @@ class Order(LockModel, LoggedModel): positions = list( self.positions.all().annotate( - has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'))) + has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True)) ).select_related('item').prefetch_related('item__questions') ) if not self.event.settings.allow_modifications_after_checkin: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index dbe343925e..4fd37306a3 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1996,7 +1996,7 @@ class OrderChangeManager: for a in current_addons[cp][k][:current_num - input_num]: if a.canceled: continue - if a.checkins.exists(): + if a.checkins.filter(list__consider_tickets_used=True).exists(): raise OrderError( error_messages['addon_already_checked_in'] % { 'addon': str(a.item.name), diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index bb8d00374b..9f022ed5a2 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -2072,7 +2072,8 @@ class VoucherFilterForm(FilterForm): qs = qs.filter(Q(valid_until__isnull=False) & Q(valid_until__lt=now())).filter(redeemed=0) elif s == 'c': checkins = Checkin.objects.filter( - position__voucher=OuterRef('pk') + position__voucher=OuterRef('pk'), + list__consider_tickets_used=True, ) qs = qs.annotate(has_checkin=Exists(checkins)).filter( redeemed__gt=0, has_checkin=True diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 19cac2f022..02ae8aa870 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -390,7 +390,7 @@ {% elif c.auto_checked_in %} {% else %} - + {% endif %} {% endfor %} {% endif %} diff --git a/src/pretix/plugins/sendmail/models.py b/src/pretix/plugins/sendmail/models.py index c252a07122..ce7c9c5d05 100644 --- a/src/pretix/plugins/sendmail/models.py +++ b/src/pretix/plugins/sendmail/models.py @@ -133,10 +133,10 @@ class ScheduledMail(models.Model): if self.rule.checked_in_status == "no_checkin": filter_orders_by_op = True - op_qs = op_qs.filter(~Exists(Checkin.objects.filter(position_id=OuterRef('pk')))) + op_qs = op_qs.filter(~Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))) elif self.rule.checked_in_status == "checked_in": filter_orders_by_op = True - op_qs = op_qs.filter(Exists(Checkin.objects.filter(position_id=OuterRef('pk')))) + op_qs = op_qs.filter(Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))) status_q = Q(status__in=self.rule.restrict_to_status) if 'n__pending_approval' in self.rule.restrict_to_status: diff --git a/src/pretix/plugins/sendmail/tasks.py b/src/pretix/plugins/sendmail/tasks.py index d224dc2630..bb090e4dff 100644 --- a/src/pretix/plugins/sendmail/tasks.py +++ b/src/pretix/plugins/sendmail/tasks.py @@ -67,6 +67,7 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict, any_checkins=Exists( Checkin.objects.filter( Q(position_id=OuterRef('pk')) | Q(position__addon_to_id=OuterRef('pk')), + list__consider_tickets_used=True, ) ), matching_checkins=Exists( diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py index 594292fe93..471cbf8261 100644 --- a/src/pretix/plugins/sendmail/views.py +++ b/src/pretix/plugins/sendmail/views.py @@ -373,7 +373,8 @@ class OrderSendView(BaseSenderView): any_checkins=Exists( Checkin.all.filter( Q(position_id=OuterRef('pk')) | Q(position__addon_to_id=OuterRef('pk')), - successful=True + successful=True, + list__consider_tickets_used=True, ) ) ) diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index c94ad6ee05..52cf7cd447 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -246,7 +246,10 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin, qs = qs.annotate( checkin_count=Subquery( Checkin.objects.filter( - successful=True, type=Checkin.TYPE_ENTRY, position_id=OuterRef('pk') + successful=True, + type=Checkin.TYPE_ENTRY, + position_id=OuterRef('pk'), + list__consider_tickets_used=True, ).order_by().values('position').annotate(c=Count('*')).values('c') ) ) @@ -358,7 +361,10 @@ class OrderPositionDetails(EventViewMixin, OrderPositionDetailMixin, CartMixin, qs = qs.annotate( checkin_count=Subquery( Checkin.objects.filter( - successful=True, type=Checkin.TYPE_ENTRY, position_id=OuterRef('pk') + successful=True, + type=Checkin.TYPE_ENTRY, + position_id=OuterRef('pk'), + list__consider_tickets_used=True, ).order_by().values('position').annotate(c=Count('*')).values('c') ) ) diff --git a/src/tests/api/test_checkin.py b/src/tests/api/test_checkin.py index d412794d5c..1a4bbff59d 100644 --- a/src/tests/api/test_checkin.py +++ b/src/tests/api/test_checkin.py @@ -218,6 +218,8 @@ TEST_LIST_RES = { "subevent": None, "exit_all_at": None, "addon_match": False, + "ignore_in_statistics": False, + "consider_tickets_used": True, "rules": {} } diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index 1979f9be99..9cecc2bf60 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -776,6 +776,26 @@ class OrdersTest(BaseOrdersTest): self.order.refresh_from_db() assert self.order.status == Order.STATUS_PENDING + def test_orders_cancel_paid_checkin_list(self): + self.order.status = Order.STATUS_PAID + self.order.save() + with scopes_disabled(): + cl = self.event.checkin_lists.create(name="Foo") + self.order.positions.first().checkins.create(list=cl) + self.event.settings.cancel_allow_user_paid = True + response = self.client.get( + '/%s/%s/order/%s/%s/cancel' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 302 + + cl.consider_tickets_used = False + cl.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/cancel' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + def test_orders_cancel_forbidden(self): self.event.settings.set('cancel_allow_user', False) self.client.post(