diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index 7ce5d5603..9fa2527dd 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 2c41fc18d..c40bb21d7 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 000000000..750bff492 --- /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 ceb3e2f6d..c54ff9a5d 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 6c641cd7a..3f5e35d63 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 dbe343925..4fd37306a 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 bb8d00374..9f022ed5a 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 19cac2f02..02ae8aa87 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 c252a0712..ce7c9c5d0 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 d224dc263..bb090e4df 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 594292fe9..471cbf826 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 c94ad6ee0..52cf7cd44 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 d412794d5..1a4bbff59 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 1979f9be9..9cecc2bf6 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(