Fix #1001 -- Add product bundles (#1041)

* Data model + Editor

* Cart and order management

* Rebase migrations

* Fix typos, add tests on cart handling

* Add tests for checkout and quotas

* Add API endpoints

* Validation of settings

* Front page tax display

* Voucher handling

* Widget foo

* Show correct net pricing

* Front page tests

* reverse charge foo

* Allow to require bundling

* Fix test failure on postgres
This commit is contained in:
Raphael Michel
2019-03-22 14:48:48 +00:00
committed by GitHub
parent c4b18a4c81
commit 90f881c48e
34 changed files with 2747 additions and 153 deletions

View File

@@ -13,7 +13,8 @@ from django.utils.translation import pgettext_lazy, ugettext as _
from pretix.base.i18n import language
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Voucher,
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation,
Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
@@ -87,12 +88,13 @@ error_messages = {
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
'product %(base)s.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
}
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax'))
'addon_to', 'subevent', 'includes_tax', 'bundled'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent'))
@@ -162,7 +164,7 @@ class CartManager:
self._items_cache.update({
i.pk: i
for i in self.event.items.select_related('category').prefetch_related(
'addons', 'addons__addon_category', 'quotas'
'addons', 'bundles', 'addons__addon_category', 'quotas'
).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)
@@ -215,9 +217,12 @@ class CartManager:
raise CartError(error_messages['ended'])
if isinstance(op, self.AddOperation):
if op.item.category and op.item.category.is_addon and not op.addon_to:
if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'):
raise CartError(error_messages['addon_only'])
if op.item.require_bundling and not op.addon_to == 'FAKE':
raise CartError(error_messages['bundled_only'])
if op.item.max_per_order or op.item.min_per_order:
new_total = (
len([1 for p in self.positions if p.item_id == op.item.pk]) +
@@ -246,12 +251,13 @@ class CartManager:
def _get_price(self, item: Item, variation: Optional[ItemVariation],
voucher: Optional[Voucher], custom_price: Optional[Decimal],
subevent: Optional[SubEvent], cp_is_net: bool=None):
subevent: Optional[SubEvent], cp_is_net: bool=None, force_custom_price=False,
bundled_sum=Decimal('0.00')):
try:
return get_price(
item, variation, voucher, custom_price, subevent,
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
invoice_address=self.invoice_address
invoice_address=self.invoice_address, force_custom_price=force_custom_price, bundled_sum=bundled_sum
)
except ValueError as e:
if str(e) == 'price_too_high':
@@ -261,22 +267,52 @@ class CartManager:
def extend_expired_positions(self):
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
'item', 'variation', 'voucher'
).prefetch_related('item__quotas', 'variation__quotas')
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
).prefetch_related(
'item__quotas',
'variation__quotas',
'addons'
).order_by('-is_bundled')
err = None
changed_prices = {}
for cp in expired:
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
price = bundle.designated_price or 0
except ItemBundle.DoesNotExist:
price = cp.price
changed_prices[cp.pk] = price
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True, cp_is_net=False)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True)
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent)
bundled_sum = Decimal('0.00')
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
bundledprice = changed_prices.get(bundledp.pk, bundledp.price)
bundled_sum += bundledprice
if not cp.includes_tax:
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='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
bundled_sum=bundled_sum)
quotas = list(cp.quotas)
if not quotas:
self._operations.append(self.RemoveOperation(position=cp))
continue
err = error_messages['unavailable']
continue
if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota):
for quota in quotas:
@@ -341,10 +377,48 @@ class CartManager:
else:
quotas = []
price = self._get_price(item, variation, voucher, i.get('price'), subevent)
# Fetch bundled items
bundled = []
bundled_sum = Decimal('0.00')
db_bundles = list(item.bundles.all())
self._update_items_cache([b.bundled_item_id for b in db_bundles], [b.bundled_variation_id for b in db_bundles])
for bundle in db_bundles:
if bundle.bundled_item_id not in self._items_cache or (
bundle.bundled_variation_id and bundle.bundled_variation_id not in self._variations_cache
):
raise CartError(error_messages['not_for_sale'])
bitem = self._items_cache[bundle.bundled_item_id]
bvar = self._variations_cache[bundle.bundled_variation_id] if bundle.bundled_variation_id else None
bundle_quotas = list(bitem.quotas.filter(subevent=subevent)
if bvar is None else bvar.quotas.filter(subevent=subevent))
if not bundle_quotas:
raise CartError(error_messages['unavailable'])
if not voucher or not voucher.allow_ignore_quota:
for quota in bundle_quotas:
quota_diff[quota] += bundle.count * i['count']
else:
bundle_quotas = []
if bundle.designated_price:
bprice = self._get_price(bitem, bvar, None, bundle.designated_price, subevent, force_custom_price=True,
cp_is_net=False)
else:
bprice = TAXED_ZERO
bundled_sum += bundle.designated_price * bundle.count
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=[]
)
self._check_item_constraints(bop)
bundled.append(bop)
price = self._get_price(item, variation, voucher, 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)
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled
)
self._check_item_constraints(op)
operations.append(op)
@@ -403,6 +477,7 @@ class CartManager:
current_addons[cp] = {
(a.item_id, a.variation_id): a
for a in cp.addons.all()
if not a.is_bundled
}
# Create operations, perform various checks
@@ -449,7 +524,7 @@ 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)
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[]
)
self._check_item_constraints(op)
operations.append(op)
@@ -609,11 +684,29 @@ class CartManager:
available_count = min(quota_available_count, voucher_available_count)
if isinstance(op, self.AddOperation):
for b in op.bundled:
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b.quotas))
if b_quota_available_count < b.count:
err = err or error_messages['unavailable']
available_count = 0
elif b_quota_available_count < available_count * b.count:
err = err or error_messages['in_part']
available_count = b_quota_available_count // b.count
for q in b.quotas:
quotas_ok[q] -= available_count * b.count
# TODO: is this correct?
for q in op.quotas:
quotas_ok[q] -= available_count
if op.voucher:
vouchers_ok[op.voucher] -= available_count
if any(qa < 0 for qa in quotas_ok.values()):
# Safeguard, shouldn't happen
err = err or error_messages['unavailable']
available_count = 0
if isinstance(op, self.AddOperation):
for k in range(available_count):
cp = CartPosition(
@@ -646,6 +739,17 @@ class CartManager:
except ValidationError:
pass
if op.bundled:
cp.save() # Needs to be in the database already so we have a PK that we can reference
for b in op.bundled:
for j in range(b.count):
new_cart_positions.append(CartPosition(
event=self.event, item=b.item, variation=b.variation,
price=b.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=None, addon_to=cp,
subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True
))
new_cart_positions.append(cp)
elif isinstance(op, self.ExtendOperation):
if available_count == 1:
@@ -659,10 +763,11 @@ class CartManager:
raise AssertionError("ExtendOperation cannot affect more than one item")
for p in new_cart_positions:
if p._answers:
p.save()
if getattr(p, '_answers', None):
if not p.pk: # We stored some to the database already before
p.save()
_save_answers(p, {}, p._answers)
CartPosition.objects.bulk_create([p for p in new_cart_positions if not p._answers])
CartPosition.objects.bulk_create([p for p in new_cart_positions if not getattr(p, '_answers', None) and not p.pk])
return err
def commit(self):
@@ -747,7 +852,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher
:param cart_id: Session ID of a guest
:raises CartError: On any error that occured
"""

View File

@@ -26,6 +26,7 @@ from pretix.base.models import (
OrderPosition, Quota, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle
from pretix.base.models.orders import (
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee, OrderRefund,
generate_position_secret, generate_secret,
@@ -400,10 +401,27 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
_check_date(event, now_dt)
products_seen = Counter()
for i, cp in enumerate(positions):
changed_prices = {}
deleted_positions = set()
def delete(cp):
# Delete a cart position, including parents and children, if applicable
if cp.is_bundled:
delete(cp.addon_to)
else:
for p in cp.addons.all():
deleted_positions.add(p.pk)
p.delete()
deleted_positions.add(cp.pk)
cp.delete()
for i, cp in enumerate(sorted(positions, key=lambda s: -int(s.is_bundled))):
if cp.pk in deleted_positions:
continue
if not cp.item.is_available() or (cp.variation and not cp.variation.active):
err = err or error_messages['unavailable']
cp.delete()
delete(cp)
continue
quotas = list(cp.quotas)
@@ -412,7 +430,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = error_messages['max_items_per_product']
errargs = {'max': cp.item.max_per_order,
'product': cp.item.name}
cp.delete() # Sorry!
delete(cp)
break
if cp.voucher:
@@ -422,27 +440,27 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
err = err or error_messages['voucher_redeemed']
cp.delete() # Sorry!
delete(cp)
continue
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
err = err or error_messages['some_subevent_not_started']
cp.delete()
delete(cp)
break
if cp.subevent and cp.subevent.presale_has_ended:
err = err or error_messages['some_subevent_ended']
cp.delete()
delete(cp)
break
if cp.item.require_voucher and cp.voucher is None:
cp.delete()
delete(cp)
err = err or error_messages['voucher_required']
break
if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None
or cp.voucher.item.pk != cp.item.pk):
cp.delete()
delete(cp)
err = error_messages['voucher_required']
break
@@ -450,18 +468,34 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
# Other checks are not necessary
continue
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)
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
bprice = bundle.designated_price or 0
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)
changed_prices[cp.pk] = bprice
else:
bundled_sum = 0
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
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)
if price is False or len(quotas) == 0:
err = err or error_messages['unavailable']
cp.delete()
delete(cp)
continue
if cp.voucher:
if cp.voucher.valid_until and cp.voucher.valid_until < now_dt:
err = err or error_messages['voucher_expired']
cp.delete()
delete(cp)
continue
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
@@ -494,7 +528,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
else:
cp.delete() # Sorry!
# Sorry, can't let you keep that!
delete(cp)
if err:
raise OrderError(err, errargs)
@@ -599,7 +634,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
with event.lock() as now_dt:
positions = list(CartPosition.objects.filter(
id__in=position_ids).select_related('item', 'variation', 'subevent'))
id__in=position_ids).select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons'))
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):

View File

@@ -11,7 +11,8 @@ from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
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) -> TaxedPrice:
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00')) -> TaxedPrice:
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
@@ -44,6 +45,11 @@ def get_price(item: Item, variation: ItemVariation = None,
)
price = tax_rule.tax(price)
if force_custom_price and custom_price is not None and custom_price != "":
if custom_price_is_net:
price = tax_rule.tax(custom_price, base_price_is='net')
else:
price = tax_rule.tax(custom_price, base_price_is='gross')
if item.free_price and custom_price is not None and custom_price != "":
if not isinstance(custom_price, Decimal):
custom_price = Decimal(str(custom_price).replace(",", "."))
@@ -54,6 +60,11 @@ def get_price(item: Item, variation: ItemVariation = None,
else:
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross')
if bundled_sum:
price = price - TaxedPrice(net=bundled_sum, gross=bundled_sum, rate=0, tax=0, name='')
if price.gross < Decimal('0.00'):
return TAXED_ZERO
if invoice_address and not tax_rule.tax_applicable(invoice_address):
price.tax = Decimal('0.00')
price.rate = Decimal('0.00')