Order change form: Allow to add multiple identical positions (Z#23227479) (#6044)

* Order change form: Allow to add multiple identical positions (Z#23227479)

* New implementation
This commit is contained in:
Raphael Michel
2026-04-01 11:54:48 +02:00
committed by GitHub
parent 8c251029b9
commit ed1459b1dd
6 changed files with 95 additions and 55 deletions

View File

@@ -67,9 +67,9 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership, CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry,
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
Voucher, SeatCategoryMapping, User, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import ( from pretix.base.models.orders import (
@@ -1618,7 +1618,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled', 'result')) 'valid_from', 'valid_until', 'is_bundled', 'result', 'count'))
SplitOperation = namedtuple('SplitOperation', ('position',)) SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1632,16 +1632,24 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple()) ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult: class AddPositionResult:
_position: Optional[OrderPosition] _positions: Optional[List[OrderPosition]]
def __init__(self): def __init__(self):
self._position = None self._positions = None
@property @property
def position(self) -> OrderPosition: def position(self) -> OrderPosition:
if self._position is None: if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._position if len(self._positions) != 1:
raise RuntimeError("More than one position created.")
return self._positions[0]
@property
def positions(self) -> List[OrderPosition]:
if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._positions
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False): def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order self.order = order
@@ -1848,8 +1856,12 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult': valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult':
if count < 1:
raise ValueError("Count must be positive")
if isinstance(seat, str): if isinstance(seat, str):
if count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat: if not seat:
seat = None seat = None
else: else:
@@ -1903,14 +1915,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross self._totaldiff_guesstimate += price.gross * count
self._quotadiff.update(new_quotas) self._quotadiff.update({q: count for q in new_quotas})
if seat: if seat:
self._seatdiff.update([seat]) self._seatdiff.update([seat])
result = self.AddPositionResult() result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled, result)) valid_from, valid_until, is_bundled, result, count))
return result return result
def split(self, position: OrderPosition): def split(self, position: OrderPosition):
@@ -2530,6 +2542,9 @@ class OrderChangeManager:
secret_dirty.remove(position) secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret']) position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
new_pos = []
new_logs = []
for i in range(op.count):
pos = OrderPosition.objects.create( pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to, item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
@@ -2539,7 +2554,8 @@ class OrderChangeManager:
is_bundled=op.is_bundled, is_bundled=op.is_bundled,
) )
nextposid += 1 nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ new_pos.append(pos)
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk, 'position': pos.pk,
'item': op.item.pk, 'item': op.item.pk,
'variation': op.variation.pk if op.variation else None, 'variation': op.variation.pk if op.variation else None,
@@ -2551,8 +2567,10 @@ class OrderChangeManager:
'seat': op.seat.pk if op.seat else None, 'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None, 'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None, 'valid_until': op.valid_until.isoformat() if op.valid_until else None,
}) }, save=False))
op.result._position = pos
op.result._positions = new_pos
LogEntry.bulk_create_and_postprocess(new_logs)
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position) position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position) split_positions.append(position)
@@ -2877,7 +2895,7 @@ class OrderChangeManager:
return total return total
def _check_order_size(self): def _check_order_size(self):
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
raise OrderError( raise OrderError(
self.error_messages['max_order_size'] % { self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE, 'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2938,7 +2956,7 @@ class OrderChangeManager:
]) + len([ ]) + len([
o for o in self._operations if isinstance(o, self.SplitOperation) o for o in self._operations if isinstance(o, self.SplitOperation)
]) ])
adds = len([o for o in self._operations if isinstance(o, self.AddOperation)]) adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)])
if current > 0 and current - cancels + adds < 1: if current > 0 and current - cancels + adds < 1:
raise OrderError(self.error_messages['complete_cancel']) raise OrderError(self.error_messages['complete_cancel'])
@@ -2985,6 +3003,7 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart: elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position]) fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
for i in range(op.count):
cp = CartPosition( cp = CartPosition(
event=self.event, event=self.event,
item=op.item, item=op.item,

View File

@@ -331,6 +331,10 @@ class OtherOperationsForm(forms.Form):
class OrderPositionAddForm(forms.Form): class OrderPositionAddForm(forms.Form):
count = forms.IntegerField(
label=_('Number of products to add'),
initial=1,
)
itemvar = forms.ChoiceField( itemvar = forms.ChoiceField(
label=_('Product') label=_('Product')
) )
@@ -432,6 +436,10 @@ class OrderPositionAddForm(forms.Form):
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0] d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
else: else:
d['used_membership'] = None d['used_membership'] = None
if d.get("count", 1) and d.get("seat"):
raise ValidationError({
"seat": _("You can not choose a seat when adding multiple products at once.")
})
return d return d

View File

@@ -329,6 +329,7 @@
{{ add_form.custom_error }} {{ add_form.custom_error }}
</div> </div>
{% endif %} {% endif %}
{% bootstrap_field add_form.count layout="control" %}
{% bootstrap_field add_form.itemvar layout="control" %} {% bootstrap_field add_form.itemvar layout="control" %}
{% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %} {% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %}
{% if add_form.addon_to %} {% if add_form.addon_to %}
@@ -364,6 +365,7 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="form-horizontal"> <div class="form-horizontal">
{% bootstrap_field add_position_formset.empty_form.count layout="control" %}
{% bootstrap_field add_position_formset.empty_form.itemvar layout="control" %} {% bootstrap_field add_position_formset.empty_form.itemvar layout="control" %}
{% bootstrap_field add_position_formset.empty_form.price addon_after=request.event.currency layout="control" %} {% bootstrap_field add_position_formset.empty_form.price addon_after=request.event.currency layout="control" %}
{% if add_position_formset.empty_form.addon_to %} {% if add_position_formset.empty_form.addon_to %}

View File

@@ -2059,6 +2059,7 @@ class OrderChange(OrderView):
else: else:
variation = None variation = None
try: try:
for i in range(f.cleaned_data.get("count", 1)):
ocm.add_position(item, variation, ocm.add_position(item, variation,
f.cleaned_data['price'], f.cleaned_data['price'],
f.cleaned_data.get('addon_to'), f.cleaned_data.get('addon_to'),

View File

@@ -2406,6 +2406,15 @@ class OrderChangeManagerTests(TestCase):
self.ocm.commit() self.ocm.commit()
assert self.order.positions.count() == 2 assert self.order.positions.count() == 2
@classscope(attr='o')
def test_add_item_quota_partial(self):
q1 = self.event.quotas.create(name='Test', size=1)
q1.items.add(self.shirt)
self.ocm.add_position(self.shirt, None, None, None, count=2)
with self.assertRaises(OrderError):
self.ocm.commit()
assert self.order.positions.count() == 2
@classscope(attr='o') @classscope(attr='o')
def test_add_item_addon(self): def test_add_item_addon(self):
self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True) self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True)

View File

@@ -1584,10 +1584,11 @@ class OrderChangeTests(SoupTest):
'add_position-MAX_NUM_FORMS': '100', 'add_position-MAX_NUM_FORMS': '100',
'add_position-0-itemvar': str(self.shirt.pk), 'add_position-0-itemvar': str(self.shirt.pk),
'add_position-0-do': 'on', 'add_position-0-do': 'on',
'add_position-0-count': '2',
'add_position-0-price': '14.00', 'add_position-0-price': '14.00',
}) })
with scopes_disabled(): with scopes_disabled():
assert self.order.positions.count() == 3 assert self.order.positions.count() == 4
assert self.order.positions.last().item == self.shirt assert self.order.positions.last().item == self.shirt
assert self.order.positions.last().price == 14 assert self.order.positions.last().price == 14