Add a maximum budget to vouchers (#1526)

* Data model changes

* Fix test failures

* Adjustments

* Some tests and API support

* Check when extending orders

* Make things more deterministic, fix style

* Do not apply negative discounts

* Update price_before_voucher on item/subevent changes

* Add tests for price_before_voucher in combination with free price

* Fix InvoiceAddress.DoesNotExist
This commit is contained in:
Raphael Michel
2020-01-03 16:15:17 +01:00
committed by GitHub
parent b738e3bd9d
commit 8e2821b398
20 changed files with 649 additions and 23 deletions

View File

@@ -108,11 +108,12 @@ error_messages = {
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat'))
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat',
'price_before_voucher'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price'))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent', 'seat'))
'quotas', 'subevent', 'seat', 'price_before_voucher'))
order = {
RemoveOperation: 10,
VoucherOperation: 15,
@@ -384,6 +385,7 @@ class CartManager:
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True)
pbv = TAXED_ZERO
else:
bundled_sum = Decimal('0.00')
if not cp.addon_to_id:
@@ -396,9 +398,14 @@ class CartManager:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
bundled_sum=bundled_sum)
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
bundled_sum=bundled_sum)
quotas = list(cp.quotas)
if not quotas:
@@ -414,7 +421,7 @@ class CartManager:
op = self.ExtendOperation(
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat, price_before_voucher=pbv
)
self._check_item_constraints(op)
@@ -569,16 +576,18 @@ class CartManager:
bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice,
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
includes_tax=bool(bprice.rate), bundled=[], seat=None
includes_tax=bool(bprice.rate), bundled=[], seat=None, price_before_voucher=bprice,
)
self._check_item_constraints(bop, operations)
bundled.append(bop)
price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum)
pbv = self._get_price(item, variation, None, i.get('price'), subevent, bundled_sum=bundled_sum)
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat,
price_before_voucher=pbv
)
self._check_item_constraints(op, operations)
operations.append(op)
@@ -684,7 +693,8 @@ class CartManager:
op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None,
price_before_voucher=None
)
self._check_item_constraints(op, operations)
operations.append(op)
@@ -898,7 +908,8 @@ class CartManager:
event=self.event, item=op.item, variation=op.variation,
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat,
price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None
)
if self.event.settings.attendee_names_asked:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
@@ -945,6 +956,8 @@ class CartManager:
elif available_count == 1:
op.position.expires = self._expiry
op.position.price = op.price.gross
if op.price_before_voucher is not None:
op.position.price_before_voucher = op.price_before_voucher.gross
try:
op.position.save(force_update=True)
except DatabaseError:
@@ -964,6 +977,7 @@ class CartManager:
# be expected
continue
op.position.price_before_voucher = op.position.price
op.position.price = op.price.gross
op.position.voucher = op.voucher
op.position.save()

View File

@@ -70,6 +70,8 @@ error_messages = {
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
'number of times allowed. We removed this item from your cart.'),
'voucher_budget_used': _('The voucher code used for one of the items in your cart has already been too often. We '
'adjusted the price of the item in your cart.'),
'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item '
'from your cart.'),
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
@@ -426,6 +428,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
products_seen = Counter()
changed_prices = {}
v_budget = {}
deleted_positions = set()
seats_seen = set()
@@ -468,6 +471,20 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
if cp.voucher.budget is not None:
if cp.voucher not in v_budget:
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
disc = cp.price_before_voucher - cp.price
if disc > v_budget[cp.voucher]:
new_disc = max(0, v_budget[cp.voucher])
cp.price = cp.price + (disc - new_disc)
cp.save()
err = err or error_messages['voucher_budget_used']
v_budget[cp.voucher] -= new_disc
continue
else:
v_budget[cp.voucher] -= disc
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
err = err or error_messages['some_subevent_not_started']
delete(cp)
@@ -522,6 +539,11 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
# Other checks are not necessary
continue
max_discount = None
if cp.price_before_voucher is not None and cp.voucher in v_budget:
current_discount = cp.price_before_voucher - cp.price
max_discount = max(v_budget[cp.voucher] + current_discount, 0)
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
@@ -529,7 +551,9 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
except ItemBundle.DoesNotExist:
bprice = cp.price
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
invoice_address=address, force_custom_price=True)
invoice_address=address, force_custom_price=True, max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
changed_prices[cp.pk] = bprice
else:
bundled_sum = 0
@@ -539,7 +563,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum)
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount)
if max_discount is not None:
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross)
if price is False or len(quotas) == 0:
err = err or error_messages['unavailable']
@@ -552,6 +583,11 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
if pbv is not None and pbv.gross != price.gross:
cp.price_before_voucher = pbv.gross
else:
cp.price_before_voucher = None
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
cp.price = price.gross
cp.includes_tax = bool(price.rate)
@@ -802,7 +838,10 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
lockfn = event.lock
with lockfn() as now_dt:
positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons'))
positions = list(
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
)
positions.sort(key=lambda k: position_ids.index(k.pk))
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
@@ -1362,6 +1401,16 @@ class OrderChangeManager:
op.position.item = op.item
op.position.variation = op.variation
op.position._calculate_tax()
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
op.position.price_before_voucher = max(
op.position.price,
get_price(
op.position.item, op.position.variation,
subevent=op.position.subevent,
custom_price=op.position.price,
invoice_address=self._invoice_address
).gross
)
op.position.save()
elif isinstance(op, self.SeatOperation):
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
@@ -1385,6 +1434,16 @@ class OrderChangeManager:
})
op.position.subevent = op.subevent
op.position.save()
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
op.position.price_before_voucher = max(
op.position.price,
get_price(
op.position.item, op.position.variation,
subevent=op.position.subevent,
custom_price=op.position.price,
invoice_address=self._invoice_address
).gross
)
elif isinstance(op, self.FeeValueOperation):
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
'fee': op.fee.pk,

View File

@@ -12,7 +12,8 @@ def get_price(item: Item, variation: ItemVariation = None,
voucher: Voucher = None, custom_price: Decimal = None,
subevent: SubEvent = None, custom_price_is_net: bool = False,
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00')) -> TaxedPrice:
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'),
max_discount: Decimal = None) -> TaxedPrice:
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
@@ -32,7 +33,7 @@ def get_price(item: Item, variation: ItemVariation = None,
price = subevent.var_price_overrides[variation.pk]
if voucher:
price = voucher.calculate_price(price)
price = voucher.calculate_price(price, max_discount=max_discount)
if item.tax_rule:
tax_rule = item.tax_rule