forked from CGM_Public/pretix_original
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:
@@ -720,6 +720,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
consume_carts = validated_data.pop('consume_carts', [])
|
||||
delete_cps = []
|
||||
quota_avail_cache = {}
|
||||
v_budget = {}
|
||||
voucher_usage = Counter()
|
||||
if consume_carts:
|
||||
for cp in CartPosition.objects.filter(
|
||||
@@ -742,9 +743,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
errs = [{} for p in positions_data]
|
||||
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
|
||||
if pos_data.get('voucher'):
|
||||
v = pos_data['voucher']
|
||||
|
||||
if pos_data.get('addon_to'):
|
||||
errs[i]['voucher'] = ['Vouchers are currently not supported for add-on products.']
|
||||
continue
|
||||
|
||||
if not v.applies_to(pos_data['item'], pos_data.get('variation')):
|
||||
errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
|
||||
continue
|
||||
@@ -768,6 +774,44 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
'The voucher has already been used the maximum number of times.'
|
||||
]
|
||||
|
||||
if v.budget is not None:
|
||||
price = pos_data.get('price')
|
||||
if price is None:
|
||||
price = get_price(
|
||||
item=pos_data.get('item'),
|
||||
variation=pos_data.get('variation'),
|
||||
voucher=v,
|
||||
custom_price=None,
|
||||
subevent=pos_data.get('subevent'),
|
||||
addon_to=pos_data.get('addon_to'),
|
||||
invoice_address=ia,
|
||||
).gross
|
||||
pbv = get_price(
|
||||
item=pos_data['item'],
|
||||
variation=pos_data.get('variation'),
|
||||
voucher=None,
|
||||
custom_price=None,
|
||||
subevent=pos_data.get('subevent'),
|
||||
addon_to=pos_data.get('addon_to'),
|
||||
invoice_address=ia,
|
||||
)
|
||||
|
||||
if v not in v_budget:
|
||||
v_budget[v] = v.budget - v.budget_used()
|
||||
disc = pbv.gross - price
|
||||
if disc > v_budget[v]:
|
||||
new_disc = v_budget[v]
|
||||
v_budget[v] -= new_disc
|
||||
if new_disc == Decimal('0.00') or pos_data.get('price') is not None:
|
||||
errs[i]['voucher'] = [
|
||||
'The voucher has a remaining budget of {}, therefore a discount of {} can not be '
|
||||
'given.'.format(v_budget[v] + new_disc, disc)
|
||||
]
|
||||
continue
|
||||
pos_data['price'] = price + (disc - new_disc)
|
||||
else:
|
||||
v_budget[v] -= disc
|
||||
|
||||
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
|
||||
if pos_data.get('seat'):
|
||||
if not seated:
|
||||
@@ -856,6 +900,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
pos.tax_rule = pos.item.tax_rule
|
||||
else:
|
||||
pos._calculate_tax()
|
||||
|
||||
pos.price_before_voucher = get_price(
|
||||
item=pos.item,
|
||||
variation=pos.variation,
|
||||
voucher=None,
|
||||
custom_price=None,
|
||||
subevent=pos.subevent,
|
||||
addon_to=pos.addon_to,
|
||||
invoice_address=ia,
|
||||
).gross
|
||||
|
||||
if pos.voucher:
|
||||
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
|
||||
pos.save()
|
||||
|
||||
30
src/pretix/base/migrations/0142_auto_20191215_1522.py
Normal file
30
src/pretix/base/migrations/0142_auto_20191215_1522.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 2.2.7 on 2019-12-15 15:22
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0141_seat_sorting_rank'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='price_before_voucher',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='price_before_voucher',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='budget',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
|
||||
),
|
||||
]
|
||||
@@ -687,10 +687,12 @@ class Order(LockModel, LoggedModel):
|
||||
error_messages = {
|
||||
'unavailable': _('The ordered product "{item}" is no longer available.'),
|
||||
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
|
||||
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
|
||||
}
|
||||
now_dt = now_dt or now()
|
||||
positions = self.positions.all().select_related('item', 'variation', 'seat')
|
||||
positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher')
|
||||
quota_cache = {}
|
||||
v_budget = {}
|
||||
try:
|
||||
for i, op in enumerate(positions):
|
||||
if op.seat:
|
||||
@@ -699,6 +701,16 @@ class Order(LockModel, LoggedModel):
|
||||
if force:
|
||||
continue
|
||||
|
||||
if op.voucher and op.voucher.budget is not None and op.price_before_voucher is not None:
|
||||
if op.voucher not in v_budget:
|
||||
v_budget[op.voucher] = op.voucher.budget - op.voucher.budget_used()
|
||||
disc = op.price_before_voucher - op.price
|
||||
if disc > v_budget[op.voucher]:
|
||||
raise Quota.QuotaExceededException(error_messages['voucher_budget'].format(
|
||||
voucher=op.voucher.code
|
||||
))
|
||||
v_budget[op.voucher] -= disc
|
||||
|
||||
quotas = list(op.quotas)
|
||||
if len(quotas) == 0:
|
||||
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
|
||||
@@ -991,6 +1003,9 @@ class AbstractPosition(models.Model):
|
||||
verbose_name=_("Variation"),
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
price_before_voucher = models.DecimalField(
|
||||
decimal_places=2, max_digits=10, null=True,
|
||||
)
|
||||
price = models.DecimalField(
|
||||
decimal_places=2, max_digits=10,
|
||||
verbose_name=_("Price")
|
||||
|
||||
@@ -4,7 +4,8 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models import F, OuterRef, Q, Subquery, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
@@ -17,7 +18,7 @@ from ..decimal import round_decimal
|
||||
from .base import LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Quota
|
||||
from .orders import Order
|
||||
from .orders import Order, OrderPosition
|
||||
|
||||
|
||||
def _generate_random_code(prefix=None):
|
||||
@@ -114,6 +115,13 @@ class Voucher(LoggedModel):
|
||||
verbose_name=_("Redeemed"),
|
||||
default=0
|
||||
)
|
||||
budget = models.DecimalField(
|
||||
verbose_name=_("Maximum discount budget"),
|
||||
help_text=_("This is the maximum monetary amount that will be discounted using this voucher across all usages. "
|
||||
"If this is sum reached, the voucher can no longer be used."),
|
||||
decimal_places=2, max_digits=10,
|
||||
null=True, blank=True
|
||||
)
|
||||
valid_until = models.DateTimeField(
|
||||
blank=True, null=True, db_index=True,
|
||||
verbose_name=_("Valid until")
|
||||
@@ -430,7 +438,7 @@ class Voucher(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def calculate_price(self, original_price: Decimal) -> Decimal:
|
||||
def calculate_price(self, original_price: Decimal, max_discount: Decimal=None) -> Decimal:
|
||||
"""
|
||||
Returns how the price given in original_price would be modified if this
|
||||
voucher is applied, i.e. replaced by a different price or reduced by a
|
||||
@@ -448,7 +456,9 @@ class Voucher(LoggedModel):
|
||||
p = original_price
|
||||
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
|
||||
if places < 2:
|
||||
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
p = p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
if max_discount is not None:
|
||||
p = max(p, original_price - max_discount)
|
||||
return p
|
||||
return original_price
|
||||
|
||||
@@ -470,3 +480,26 @@ class Voucher(LoggedModel):
|
||||
return self.item.seat_category_mappings.filter(**kwargs).exists()
|
||||
else:
|
||||
return bool(subevent.seating_plan) if subevent else self.event.seating_plan
|
||||
|
||||
@classmethod
|
||||
def annotate_budget_used_orders(cls, qs):
|
||||
opq = OrderPosition.objects.filter(
|
||||
voucher_id=OuterRef('pk'),
|
||||
price_before_voucher__isnull=False,
|
||||
order__status__in=[
|
||||
Order.STATUS_PAID,
|
||||
Order.STATUS_PENDING
|
||||
]
|
||||
).order_by().values('voucher_id').annotate(s=Sum(F('price_before_voucher') - F('price'))).values('s')
|
||||
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=10, decimal_places=2)), Decimal('0.00')))
|
||||
|
||||
def budget_used(self):
|
||||
ops = OrderPosition.objects.filter(
|
||||
voucher=self,
|
||||
price_before_voucher__isnull=False,
|
||||
order__status__in=[
|
||||
Order.STATUS_PAID,
|
||||
Order.STATUS_PENDING
|
||||
]
|
||||
).aggregate(s=Sum(F('price_before_voucher') - F('price')))['s'] or Decimal('0.00')
|
||||
return ops
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,7 +38,7 @@ class VoucherForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
|
||||
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items',
|
||||
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
|
||||
]
|
||||
field_classes = {
|
||||
'valid_until': SplitDateTimeField,
|
||||
@@ -268,7 +268,7 @@ class VoucherBulkForm(VoucherForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
|
||||
'max_usages', 'price_mode', 'subevent', 'show_hidden_items'
|
||||
'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
|
||||
]
|
||||
field_classes = {
|
||||
'valid_until': SplitDateTimeField,
|
||||
|
||||
@@ -80,6 +80,15 @@
|
||||
{{ position.custom_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if position.voucher and position.voucher.budget %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This position has been created with a voucher with a limited budget. If you
|
||||
change the price or item, the discount will still be calculated from the original
|
||||
price at the time of purchase.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-sm-5 col-sm-offset-3">
|
||||
<strong>{% trans "Current value" %}</strong>
|
||||
|
||||
@@ -272,7 +272,9 @@
|
||||
{% endif %}
|
||||
{% if line.voucher %}
|
||||
<br/><span class="fa fa-tags"></span> {% trans "Voucher code used:" %}
|
||||
<a href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
|
||||
<a
|
||||
{% if line.price_before_voucher|default_if_none:"NONE" != "NONE" %}data-toggle="tooltip" title="{% blocktrans trimmed with price=line.price_before_voucher|money:request.event.currency %}Original price: {{ price }}{% endblocktrans %}"{% endif %}
|
||||
href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
|
||||
{{ line.voucher.code }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
<legend>{% trans "Advanced settings" %}</legend>
|
||||
{% bootstrap_field form.block_quota layout="control" %}
|
||||
{% bootstrap_field form.allow_ignore_quota layout="control" %}
|
||||
{% bootstrap_field form.budget addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field form.tag layout="control" %}
|
||||
{% bootstrap_field form.comment layout="control" %}
|
||||
{% bootstrap_field form.show_hidden_items layout="control" %}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
<legend>{% trans "Advanced settings" %}</legend>
|
||||
{% bootstrap_field form.block_quota layout="control" %}
|
||||
{% bootstrap_field form.allow_ignore_quota layout="control" %}
|
||||
{% bootstrap_field form.budget addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field form.tag layout="control" %}
|
||||
{% bootstrap_field form.comment layout="control" %}
|
||||
{% bootstrap_field form.show_hidden_items layout="control" %}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load urlreplace %}
|
||||
{% load money %}
|
||||
{% block title %}{% trans "Vouchers" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Vouchers" %}</h1>
|
||||
@@ -143,7 +144,15 @@
|
||||
<strong><a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}">{{ v.code }}</a></strong>
|
||||
{% if not v.is_active %}</del>{% endif %}
|
||||
</td>
|
||||
<td>{{ v.redeemed }} / {{ v.max_usages }}</td>
|
||||
<td>
|
||||
{{ v.redeemed }} / {{ v.max_usages }}
|
||||
{% if v.budget|default_if_none:"NONE" != "NONE" %}
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
{{ v.budget_used_orders|money:request.event.currency }} / {{ v.budget|money:request.event.currency }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ v.valid_until|date }}</td>
|
||||
<td>
|
||||
{{ v.tag }}
|
||||
|
||||
@@ -37,9 +37,11 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
|
||||
permission = 'can_view_vouchers'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.event.vouchers.filter(waitinglistentries__isnull=True).select_related(
|
||||
qs = Voucher.annotate_budget_used_orders(self.request.event.vouchers.filter(
|
||||
waitinglistentries__isnull=True
|
||||
).select_related(
|
||||
'item', 'variation', 'seat'
|
||||
)
|
||||
))
|
||||
if self.filter_form.is_valid():
|
||||
qs = self.filter_form.filter_qs(qs)
|
||||
|
||||
|
||||
@@ -3105,6 +3105,81 @@ def test_order_create_auto_pricing_reverse_charge_require_valid_vatid(token_clie
|
||||
assert p.tax_rate == Decimal('19.00')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_autopricing_voucher_budget_partially(token_client, organizer, event, item, quota, question,
|
||||
taxrule):
|
||||
with scopes_disabled():
|
||||
voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('2.50'),
|
||||
max_usages=999)
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['positions'][0]['item'] = item.pk
|
||||
res['positions'][0]['answers'][0]['question'] = question.pk
|
||||
res['positions'][0]['voucher'] = voucher.code
|
||||
del res['positions'][0]['price']
|
||||
del res['positions'][0]['positionid']
|
||||
res['positions'].append(res['positions'][0])
|
||||
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
print(resp.data)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
p = o.positions.first()
|
||||
p2 = o.positions.last()
|
||||
assert p.price == Decimal('21.50')
|
||||
assert p2.price == Decimal('22.00')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_autopricing_voucher_budget_full(token_client, organizer, event, item, quota, question, taxrule):
|
||||
with scopes_disabled():
|
||||
voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('0.50'),
|
||||
max_usages=999)
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['positions'][0]['item'] = item.pk
|
||||
res['positions'][0]['answers'][0]['question'] = question.pk
|
||||
res['positions'][0]['voucher'] = voucher.code
|
||||
del res['positions'][0]['price']
|
||||
del res['positions'][0]['positionid']
|
||||
res['positions'].append(res['positions'][0])
|
||||
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.data == {'positions': [{}, {'voucher': ['The voucher has a remaining budget of 0.00, therefore a '
|
||||
'discount of 1.50 can not be given.']}]}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_voucher_budget_exceeded(token_client, organizer, event, item, quota, question, taxrule):
|
||||
with scopes_disabled():
|
||||
voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('3.00'),
|
||||
max_usages=999)
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['positions'][0]['item'] = item.pk
|
||||
res['positions'][0]['answers'][0]['question'] = question.pk
|
||||
res['positions'][0]['voucher'] = voucher.code
|
||||
res['positions'][0]['price'] = '19.00'
|
||||
del res['positions'][0]['positionid']
|
||||
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
print(resp.data)
|
||||
assert resp.status_code == 400
|
||||
assert resp.data == {'positions': [{'voucher': ['The voucher has a remaining budget of 3.00, therefore a '
|
||||
'discount of 4.00 can not be given.']}]}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_voucher_price(token_client, organizer, event, item, quota, question):
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
|
||||
@@ -795,6 +795,31 @@ class VoucherTestCase(BaseQuotaTestCase):
|
||||
v = Voucher.objects.create(event=self.event, price_mode='percent', value=Decimal('23.00'))
|
||||
assert v.calculate_price(Decimal('100.00')) == Decimal('77.00')
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_calculate_price_max_discount(self):
|
||||
v = Voucher.objects.create(event=self.event, price_mode='subtract', value=Decimal('10.00'))
|
||||
assert v.calculate_price(Decimal('23.42'), max_discount=Decimal('5.00')) == Decimal('18.42')
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_calculate_budget_used(self):
|
||||
v = Voucher.objects.create(event=self.event, price_mode='sset', value=Decimal('20.00'))
|
||||
|
||||
order = Order.objects.create(
|
||||
status=Order.STATUS_PENDING, event=self.event,
|
||||
datetime=now() - timedelta(days=5), expires=now() + timedelta(days=5), total=46,
|
||||
)
|
||||
OrderPosition.objects.create(order=order, item=self.item1, voucher=v, price=Decimal('20.00'),
|
||||
price_before_voucher=Decimal('23.00'))
|
||||
assert v.budget_used() == Decimal('3.00')
|
||||
|
||||
order = Order.objects.create(
|
||||
status=Order.STATUS_PAID, event=self.event,
|
||||
datetime=now() - timedelta(days=5), expires=now() + timedelta(days=5), total=46,
|
||||
)
|
||||
OrderPosition.objects.create(order=order, item=self.item1, voucher=v, price=Decimal('20.00'),
|
||||
price_before_voucher=Decimal('23.00'))
|
||||
assert v.budget_used() == Decimal('6.00')
|
||||
|
||||
|
||||
class OrderTestCase(BaseQuotaTestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -878,6 +878,36 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.op1.tax_value == Decimal('3.67')
|
||||
assert self.op1.tax_rule == self.shirt.tax_rule
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_item_change_price_before_voucher(self):
|
||||
self.op1.voucher = self.event.vouchers.create(item=self.shirt, redeemed=1, price_mode='set', value='5.00')
|
||||
self.op1.price = Decimal('5.00')
|
||||
self.op1.price_before_voucher = Decimal('23.00')
|
||||
self.op1.save()
|
||||
p = self.op1.price
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
self.order.refresh_from_db()
|
||||
assert self.op1.item == self.shirt
|
||||
assert self.op1.price == p
|
||||
assert self.op1.price_before_voucher == Decimal('12.00')
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_item_change_price_before_voucher_minimum_value(self):
|
||||
self.op1.voucher = self.event.vouchers.create(item=self.shirt, redeemed=1, price_mode='set', value='20.00')
|
||||
self.op1.price = Decimal('20.00')
|
||||
self.op1.price_before_voucher = Decimal('23.00')
|
||||
self.op1.save()
|
||||
p = self.op1.price
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
self.order.refresh_from_db()
|
||||
assert self.op1.item == self.shirt
|
||||
assert self.op1.price == p
|
||||
assert self.op1.price_before_voucher == Decimal('20.00')
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_item_success(self):
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
|
||||
@@ -845,6 +845,69 @@ def test_order_extend_expired_quota_partial(client, env):
|
||||
assert o.status == Order.STATUS_EXPIRED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_extend_expired_voucher_budget_ok(client, env):
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(id=env[2].id)
|
||||
o.expires = now() - timedelta(days=5)
|
||||
o.status = Order.STATUS_EXPIRED
|
||||
o.save()
|
||||
v = env[0].vouchers.create(
|
||||
code="foo", price_mode='subtract', value=Decimal('1.50'), budget=Decimal('1.50')
|
||||
)
|
||||
p = o.positions.first()
|
||||
p.voucher = v
|
||||
p.price_before_voucher = p.price
|
||||
p.price -= Decimal('1.50')
|
||||
p.save()
|
||||
|
||||
q = Quota.objects.create(event=env[0], size=100)
|
||||
q.items.add(env[3])
|
||||
newdate = (now() + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/extend', {
|
||||
'expires': newdate
|
||||
}, follow=True)
|
||||
assert b'alert-success' in response.content
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(id=env[2].id)
|
||||
assert o.status == Order.STATUS_PENDING
|
||||
assert v.budget_used() == Decimal('1.50')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_extend_expired_voucher_budget_fail(client, env):
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(id=env[2].id)
|
||||
o.expires = now() - timedelta(days=5)
|
||||
o.status = Order.STATUS_EXPIRED
|
||||
olddate = o.expires
|
||||
o.save()
|
||||
v = env[0].vouchers.create(
|
||||
code="foo", price_mode='subtract', value=Decimal('1.50'), budget=Decimal('0.00')
|
||||
)
|
||||
p = o.positions.first()
|
||||
p.voucher = v
|
||||
p.price_before_voucher = p.price
|
||||
p.price -= Decimal('1.50')
|
||||
p.save()
|
||||
|
||||
q = Quota.objects.create(event=env[0], size=100)
|
||||
q.items.add(env[3])
|
||||
newdate = (now() + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
response = client.post('/control/event/dummy/dummy/orders/FOO/extend', {
|
||||
'expires': newdate
|
||||
}, follow=True)
|
||||
assert b'alert-danger' in response.content
|
||||
assert b'The voucher "FOO" no longer has sufficient budget.' in response.content
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(id=env[2].id)
|
||||
assert o.expires.strftime("%Y-%m-%d %H:%M:%S") == olddate.strftime("%Y-%m-%d %H:%M:%S")
|
||||
assert o.status == Order.STATUS_EXPIRED
|
||||
assert v.budget_used() == Decimal('0.00')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_mark_paid_overdue_quota_blocked_by_waiting_list(client, env):
|
||||
with scopes_disabled():
|
||||
|
||||
@@ -1388,6 +1388,32 @@ class CartTest(CartTestMixin, TestCase):
|
||||
self.assertEqual(objs[0].item, self.ticket)
|
||||
self.assertIsNone(objs[0].variation)
|
||||
self.assertEqual(objs[0].price, Decimal('21.00'))
|
||||
self.assertEqual(objs[0].price_before_voucher, Decimal('23.00'))
|
||||
|
||||
def test_voucher_free_price_before_voucher_cap(self):
|
||||
with scopes_disabled():
|
||||
v = Voucher.objects.create(item=self.ticket, value=Decimal('10.00'), price_mode='percent', event=self.event)
|
||||
self.ticket.free_price = True
|
||||
self.ticket.save()
|
||||
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'item_%d' % self.ticket.id: '1',
|
||||
'price_%d' % self.ticket.id: '41.00',
|
||||
'_voucher_code': v.code,
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
doc = BeautifulSoup(response.rendered_content, "lxml")
|
||||
self.assertIn('Early-bird', doc.select('.cart .cart-row')[0].select('strong')[0].text)
|
||||
self.assertIn('1', doc.select('.cart .cart-row')[0].select('.count')[0].text)
|
||||
self.assertIn('41', doc.select('.cart .cart-row')[0].select('.price')[0].text)
|
||||
self.assertIn('41', doc.select('.cart .cart-row')[0].select('.price')[1].text)
|
||||
with scopes_disabled():
|
||||
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
|
||||
self.assertEqual(len(objs), 1)
|
||||
self.assertEqual(objs[0].item, self.ticket)
|
||||
self.assertIsNone(objs[0].variation)
|
||||
self.assertEqual(objs[0].price, Decimal('41.00'))
|
||||
self.assertEqual(objs[0].price_before_voucher, Decimal('41.00'))
|
||||
|
||||
def test_voucher_free_price_lower_bound(self):
|
||||
with scopes_disabled():
|
||||
@@ -1412,6 +1438,7 @@ class CartTest(CartTestMixin, TestCase):
|
||||
self.assertEqual(objs[0].item, self.ticket)
|
||||
self.assertIsNone(objs[0].variation)
|
||||
self.assertEqual(objs[0].price, Decimal('20.70'))
|
||||
self.assertEqual(objs[0].price_before_voucher, Decimal('23.00'))
|
||||
|
||||
def test_voucher_redemed(self):
|
||||
with scopes_disabled():
|
||||
@@ -2438,6 +2465,20 @@ class CartAddonTest(CartTestMixin, TestCase):
|
||||
assert cp2.expires > now()
|
||||
assert cp2.addon_to_id == cp1.pk
|
||||
|
||||
@classscope(attr='orga')
|
||||
def test_expand_expired_refresh_voucher(self):
|
||||
v = Voucher.objects.create(item=self.ticket, value=Decimal('20.00'), event=self.event, price_mode='set',
|
||||
valid_until=now() + timedelta(days=2), max_usages=999, redeemed=0)
|
||||
cp1 = CartPosition.objects.create(
|
||||
expires=now() - timedelta(minutes=10), item=self.ticket, price=Decimal('21.50'),
|
||||
event=self.event, cart_id=self.session_key, voucher=v
|
||||
)
|
||||
self.cm.extend_expired_positions()
|
||||
self.cm.commit()
|
||||
cp1.refresh_from_db()
|
||||
assert cp1.expires > now()
|
||||
assert cp1.price_before_voucher == Decimal('23.00')
|
||||
|
||||
|
||||
class CartBundleTest(CartTestMixin, TestCase):
|
||||
@scopes_disabled()
|
||||
@@ -2490,6 +2531,30 @@ class CartBundleTest(CartTestMixin, TestCase):
|
||||
assert cp.price == 23 - 1.5
|
||||
assert cp.addons.count() == 1
|
||||
assert cp.voucher == v
|
||||
assert cp.price_before_voucher == 23 - 1.5
|
||||
a = cp.addons.get()
|
||||
assert a.item == self.trans
|
||||
assert a.price == 1.5
|
||||
assert not a.voucher
|
||||
|
||||
@classscope(attr='orga')
|
||||
def test_discounted_voucher_on_base_product(self):
|
||||
v = self.event.vouchers.create(code="foo", item=self.ticket, price_mode='subtract', value=Decimal('1.50'))
|
||||
self.cm.add_new_items([
|
||||
{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'voucher': v.code,
|
||||
'count': 1
|
||||
}
|
||||
])
|
||||
self.cm.commit()
|
||||
cp = CartPosition.objects.get(addon_to__isnull=True)
|
||||
assert cp.item == self.ticket
|
||||
assert cp.price == 23 - 1.5 - 1.5
|
||||
assert cp.addons.count() == 1
|
||||
assert cp.voucher == v
|
||||
assert cp.price_before_voucher == 23 - 1.5
|
||||
a = cp.addons.get()
|
||||
assert a.item == self.trans
|
||||
assert a.price == 1.5
|
||||
|
||||
@@ -3000,3 +3000,140 @@ class CheckoutSeatingTest(BaseCheckoutTestCase, TestCase):
|
||||
with self.assertRaises(OrderError):
|
||||
_perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web')
|
||||
assert not CartPosition.objects.filter(pk=self.cp1.pk).exists()
|
||||
|
||||
|
||||
class CheckoutVoucherBudgetTest(BaseCheckoutTestCase, TestCase):
|
||||
@scopes_disabled()
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.v = Voucher.objects.create(item=self.ticket, value=Decimal('21.50'), event=self.event, price_mode='set',
|
||||
valid_until=now() + timedelta(days=2), max_usages=999, redeemed=0)
|
||||
self.cp1 = CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price_before_voucher=23, price=21.5, expires=now() + timedelta(minutes=10), voucher=self.v
|
||||
)
|
||||
self.cp2 = CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price_before_voucher=23, price=21.5, expires=now() + timedelta(minutes=10), voucher=self.v
|
||||
)
|
||||
|
||||
@scopes_disabled()
|
||||
def test_no_budget(self):
|
||||
oid = _perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
o = Order.objects.get(pk=oid)
|
||||
op = o.positions.first()
|
||||
assert op.item == self.ticket
|
||||
assert op.price_before_voucher == Decimal('23.00')
|
||||
|
||||
@scopes_disabled()
|
||||
def test_budget_exceeded_for_second_order(self):
|
||||
self.v.budget = Decimal('1.50')
|
||||
self.v.save()
|
||||
oid = _perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
o = Order.objects.get(pk=oid)
|
||||
op = o.positions.first()
|
||||
assert op.item == self.ticket
|
||||
|
||||
with self.assertRaises(OrderError):
|
||||
_perform_order(self.event, 'manual', [self.cp2.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
self.cp2.refresh_from_db()
|
||||
assert self.cp2.price == Decimal('23.00')
|
||||
|
||||
@scopes_disabled()
|
||||
def test_budget_exceeded_between_positions(self):
|
||||
self.v.budget = Decimal('1.50')
|
||||
self.v.save()
|
||||
with self.assertRaises(OrderError):
|
||||
_perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
self.cp1.refresh_from_db()
|
||||
assert self.cp1.price == Decimal('21.50')
|
||||
self.cp2.refresh_from_db()
|
||||
assert self.cp2.price == Decimal('23.00')
|
||||
|
||||
@scopes_disabled()
|
||||
def test_budget_exceeded_in_first_position(self):
|
||||
self.v.budget = Decimal('1.00')
|
||||
self.v.save()
|
||||
with self.assertRaises(OrderError):
|
||||
_perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
self.cp1.refresh_from_db()
|
||||
assert self.cp1.price == Decimal('22.00')
|
||||
self.cp2.refresh_from_db()
|
||||
assert self.cp2.price == Decimal('23.00')
|
||||
|
||||
@scopes_disabled()
|
||||
def test_budget_exceeded_in_second_position(self):
|
||||
self.v.budget = Decimal('2.50')
|
||||
self.v.save()
|
||||
with self.assertRaises(OrderError):
|
||||
_perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
self.cp1.refresh_from_db()
|
||||
assert self.cp1.price == Decimal('21.50')
|
||||
self.cp2.refresh_from_db()
|
||||
assert self.cp2.price == Decimal('22.00')
|
||||
|
||||
@scopes_disabled()
|
||||
def test_budget_exceeded_during_price_change(self):
|
||||
self.v.budget = Decimal('2.50')
|
||||
self.v.value = Decimal('21.00')
|
||||
self.v.save()
|
||||
self.cp1.expires = now() - timedelta(hours=1)
|
||||
self.cp1.save()
|
||||
self.cp2.expires = now() - timedelta(hours=1)
|
||||
self.cp2.save()
|
||||
|
||||
with self.assertRaises(OrderError):
|
||||
_perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
self.cp1.refresh_from_db()
|
||||
assert self.cp1.price == Decimal('21.00')
|
||||
self.cp2.refresh_from_db()
|
||||
assert self.cp2.price == Decimal('22.50')
|
||||
|
||||
@scopes_disabled()
|
||||
def test_budget_exceeded_expired_cart(self):
|
||||
self.v.budget = Decimal('0.00')
|
||||
self.v.value = Decimal('21.00')
|
||||
self.v.save()
|
||||
self.cp1.expires = now() - timedelta(hours=1)
|
||||
self.cp1.save()
|
||||
self.cp2.expires = now() - timedelta(hours=1)
|
||||
self.cp2.save()
|
||||
|
||||
with self.assertRaises(OrderError):
|
||||
_perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
self.cp1.refresh_from_db()
|
||||
assert self.cp1.price == Decimal('23.00')
|
||||
self.cp2.refresh_from_db()
|
||||
assert self.cp2.price == Decimal('23.00')
|
||||
|
||||
@scopes_disabled()
|
||||
def test_budget_overbooked_expired_cart(self):
|
||||
self.v.budget = Decimal('1.50')
|
||||
self.v.value = Decimal('21.50')
|
||||
self.v.save()
|
||||
self.cp1.expires = now() - timedelta(hours=1)
|
||||
self.cp1.save()
|
||||
self.cp2.expires = now() - timedelta(hours=1)
|
||||
self.cp2.save()
|
||||
oid = _perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
o = Order.objects.get(pk=oid)
|
||||
op = o.positions.first()
|
||||
|
||||
assert op.item == self.ticket
|
||||
self.v.budget = Decimal('1.00')
|
||||
self.v.save()
|
||||
|
||||
with self.assertRaises(OrderError):
|
||||
_perform_order(self.event, 'manual', [self.cp2.pk], 'admin@example.org', 'en', None, {},
|
||||
'web')
|
||||
self.cp2.refresh_from_db()
|
||||
assert self.cp2.price == Decimal('23.00')
|
||||
|
||||
Reference in New Issue
Block a user