diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index ac70f7106..c2f8a78a4 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -626,7 +626,10 @@ class Order(LockModel, LoggedModel): has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'))) ).select_related('item').prefetch_related('issued_gift_cards') ) - cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions]) + if self.event.settings.change_allow_user_if_checked_in: + cancelable = all([op.item.allow_cancel for op in positions]) + else: + cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions]) if not cancelable or not positions: return False for op in positions: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 5b8a1b15e..5bd7647ac 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -189,6 +189,7 @@ error_messages = { 'min' ), 'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'), + 'addon_already_checked_in': gettext_lazy('You cannot remove the position %(addon)s since it has already been checked in.'), } logger = logging.getLogger(__name__) @@ -1896,6 +1897,12 @@ class OrderChangeManager: for a in current_addons[cp][k][:current_num - input_num]: if a.canceled: continue + if a.checkins.exists(): + raise OrderError( + error_messages['addon_already_checked_in'] % { + 'addon': str(a.item.name), + } + ) self.cancel(a) def _check_seats(self): diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index d5fb6faac..58e803d54 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1484,6 +1484,19 @@ DEFAULTS = { label=_("Do not allow changes after"), ) }, + 'change_allow_user_if_checked_in': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Allow change even though the ticket has already been checked in"), + help_text=_("By default, order changes are disabled after any ticket in the order has been checked in. " + "If you check this box, this requirement is lifted. It is still not possible to remove an " + "add-on product that has already been checked in individually. Use with care, and preferably " + "only in combination with a limitation on price changes above."), + ) + }, 'change_allow_attendee': { 'default': 'False', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index e52417c38..50ace26a1 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -690,6 +690,7 @@ class CancelSettingsForm(SettingsForm): 'change_allow_user_price', 'change_allow_user_until', 'change_allow_user_addons', + 'change_allow_user_if_checked_in', 'change_allow_attendee', ] diff --git a/src/pretix/control/templates/pretixcontrol/event/cancel.html b/src/pretix/control/templates/pretixcontrol/event/cancel.html index cd70e6d21..e06e0f966 100644 --- a/src/pretix/control/templates/pretixcontrol/event/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/event/cancel.html @@ -67,6 +67,7 @@ {% bootstrap_field form.change_allow_user_addons layout="control" %} {% bootstrap_field form.change_allow_user_until layout="control" %} {% bootstrap_field form.change_allow_user_price layout="control" %} + {% bootstrap_field form.change_allow_user_if_checked_in layout="control" %} {% bootstrap_field form.change_allow_attendee layout="control" %}
diff --git a/src/tests/presale/test_order_change.py b/src/tests/presale/test_order_change.py index 746883e8e..59f291083 100644 --- a/src/tests/presale/test_order_change.py +++ b/src/tests/presale/test_order_change.py @@ -122,6 +122,25 @@ class OrderChangeVariationTest(BaseOrdersTest): ) assert response.status_code == 302 + def test_change_with_checkin(self): + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_red, + price=Decimal("14"), + ) + shirt_pos.checkins.create(list=self.event.checkin_lists.create(name="Test")) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 302 + self.event.settings.change_allow_user_if_checked_in = True + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 302 + def test_change_variation_paid(self): self.event.settings.change_allow_user_variation = True self.event.settings.change_allow_user_price = 'any' @@ -746,6 +765,40 @@ class OrderChangeAddonsTest(BaseOrdersTest): self.order.refresh_from_db() assert self.order.total == Decimal('23.00') + def test_remove_addon_checked_in(self): + with scopes_disabled(): + self.event.settings.change_allow_user_if_checked_in = True + op = OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + op.checkins.create(list=self.event.checkin_lists.create(name="Test")) + self.order.total += Decimal("12") + self.order.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]')[0].attrs['checked'] + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + assert 'alert-danger' in response.content.decode() + assert 'You cannot remove the position' in response.content.decode() + def test_increase_existing_addon_free_price_net(self): self.event.settings.display_net_prices = True self.iao.multi_allowed = True