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

@@ -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")

View File

@@ -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