diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 6baf246b1c..4da2e9099a 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -47,7 +47,8 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import ( - Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value, + Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum, + Value, ) from django.db.models.functions import Coalesce, Greatest from django.db.transaction import get_connection @@ -1701,7 +1702,19 @@ class OrderChangeManager: for a in position.addons.all(): self._operations.append(self.SplitOperation(a)) - def set_addons(self, addons): + def set_addons(self, addons, limit_main_positions=None): + """ + This is a convenience method to change the add-on products selected on an order. The input structure is similar + to CartManager.set_addons. It will automatically compute the correct operations to add, cancel, or change + positions on the order. Every existing add-on not in the input will be canceled. Availability of the + products is validated (with some exceptions). + + :param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values), + ``"count"``, and ``"price"``. + :param limit_main_positions: By default, the method works on all methods of the order. If you set this to a + queryset or a list of positions, all other positions and their add-ons will be kept + untouched. + """ if self._operations: raise ValueError("Setting addons should be the first/only operation") @@ -1713,7 +1726,13 @@ class OrderChangeManager: quota_diff = Counter() # Quota -> Number of usages available_categories = defaultdict(set) # OrderPos -> Category IDs to choose from price_included = defaultdict(dict) # OrderPos -> CategoryID -> bool(price is included) - toplevel_op = self.order.positions.filter( + if isinstance(limit_main_positions, QuerySet): + toplevel_qs = limit_main_positions + elif limit_main_positions is not None: + toplevel_qs = self.order.positions.filter(pk__in=[p.pk for p in limit_main_positions]) + else: + toplevel_qs = self.order.position + toplevel_op = toplevel_qs.filter( addon_to__isnull=True ).prefetch_related( 'addons', 'item__addons', 'item__addons__addon_category' diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index cf10dbfb15..4612e4f3c2 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -1436,7 +1436,7 @@ class OrderChangeMixin: 'price': price, }) try: - ocm.set_addons(addons_data) + ocm.set_addons(addons_data, self.get_position_queryset()) except OrderError as e: messages.error(self.request, str(e)) form_valid = False @@ -1543,7 +1543,7 @@ class OrderChange(OrderChangeMixin, EventViewMixin, OrderDetailMixin, TemplateVi return self.get_order_url() def get_position_queryset(self): - return self.order.positions + return self.order.positions.all() def get_price_requirement(self): return self.request.event.settings.change_allow_user_price diff --git a/src/tests/presale/test_order_change.py b/src/tests/presale/test_order_change.py index 6372d667fb..746883e8ec 100644 --- a/src/tests/presale/test_order_change.py +++ b/src/tests/presale/test_order_change.py @@ -1514,9 +1514,9 @@ class OrderChangeAddonsTest(BaseOrdersTest): { f'cp_{ticket_pos2.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '1' }, - follow=False + follow=True ) - assert response.status_code == 302 # nothing changed + assert 'did not make any changes' in response.content.decode() def test_attendee_needs_to_keep_price(self): self.event.settings.change_allow_user_price = 'any' # ignored, for attendees its always "eq" @@ -1556,3 +1556,46 @@ class OrderChangeAddonsTest(BaseOrdersTest): follow=True ) assert '€' in response.content.decode() + + def test_attendee_change_of_addons_does_not_affect_other_positions(self): + with scopes_disabled(): + ticket_pos2 = OrderPosition.objects.create( + order=self.order, + item=self.ticket, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"} + ) + a1 = OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("0"), + addon_to=self.ticket_pos, + ) + a2 = OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("0"), + addon_to=ticket_pos2, + ) + + self.event.settings.change_allow_attendee = True + + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret), + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = self.client.post( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret), + form_data, follow=True + ) + assert 'alert-success' in response.content.decode() + + a1.refresh_from_db() + a2.refresh_from_db() + assert not a1.canceled + assert not a2.canceled