forked from CGM_Public/pretix_original
* 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:
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user