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

@@ -0,0 +1,60 @@
# Generated by Django 2.1.7 on 2019-03-16 10:14
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0113_auto_20190312_0942'),
]
operations = [
migrations.CreateModel(
name='ItemBundle',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('count', models.PositiveIntegerField(default=1, verbose_name='Number')),
('designated_price', models.DecimalField(blank=True, decimal_places=2, help_text="If set, it will be shown that this bundled item is responsible for the given value of the total price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This value will NOT be added to the base item's price.", max_digits=10, null=True, verbose_name='Designated price part')),
],
),
migrations.AddField(
model_name='cartposition',
name='is_bundled',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='cartposition',
name='addon_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='addons', to='pretixbase.CartPosition'),
),
migrations.AlterField(
model_name='orderposition',
name='addon_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='addons', to='pretixbase.OrderPosition'),
),
migrations.AddField(
model_name='itembundle',
name='base_item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bundles', to='pretixbase.Item'),
),
migrations.AddField(
model_name='itembundle',
name='bundled_item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bundled_with', to='pretixbase.Item', verbose_name='Bundled item'),
),
migrations.AddField(
model_name='itembundle',
name='bundled_variation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bundled_with', to='pretixbase.ItemVariation', verbose_name='Bundled variation'),
),
migrations.AddField(
model_name='item',
name='require_bundling',
field=models.BooleanField(default=False, help_text='If this option is set, the product will only be sold as part of bundle products.', verbose_name='Only sell this product as part of a bundle'),
),
]

View File

@@ -9,8 +9,9 @@ from .event import (
)
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to,
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
itempicture_upload_to,
)
from .log import LogEntry
from .notifications import NotificationSetting

View File

@@ -1,5 +1,6 @@
import sys
import uuid
from collections import Counter
from datetime import date, datetime, time
from decimal import Decimal, DecimalException
from typing import Tuple
@@ -161,7 +162,7 @@ class ItemQuerySet(models.QuerySet):
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(sales_channels__contains=channel)
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
)
if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
@@ -328,6 +329,11 @@ class Item(LoggedModel):
help_text=_('This product will be hidden from the event page until the user enters a voucher '
'code that is specifically tied to this product (and not via a quota).')
)
require_bundling = models.BooleanField(
verbose_name=_('Only sell this product as part of a bundle'),
default=False,
help_text=_('If this option is set, the product will only be sold as part of bundle products.')
)
allow_cancel = models.BooleanField(
verbose_name=_('Allow product to be canceled'),
default=True,
@@ -386,12 +392,28 @@ class Item(LoggedModel):
if self.event:
self.event.cache.clear()
def tax(self, price=None, base_price_is='auto'):
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
price = price if price is not None else self.default_price
if not self.tax_rule:
return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
return self.tax_rule.tax(price, base_price_is=base_price_is)
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
if include_bundled:
for b in self.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
if b.bundled_variation:
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
else:
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
compare_price = self.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!"
return t
def is_available_by_time(self, now_dt: datetime=None) -> bool:
now_dt = now_dt or now()
@@ -411,7 +433,18 @@ class Item(LoggedModel):
return False
return True
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None):
def _get_quotas(self, ignored_quotas=None, subevent=None):
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.filter(subevent=subevent).select_related('subevent')
if subevent else self.quotas.all()
))
if ignored_quotas:
check_quotas -= set(ignored_quotas)
return check_quotas
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
include_bundled=False, trust_parameters=False):
"""
This method is used to determine whether this Item is currently available
for sale.
@@ -420,33 +453,60 @@ class Item(LoggedModel):
quotas will be ignored in the calculation. If this leads
to no quotas being checked at all, this method will return
unlimited availability.
:param include_bundled: Also take availability of bundled items into consideration.
:param trust_parameters: Disable checking of the subevent parameter and disable checking if
any variations exist (performance optimization).
:returns: any of the return codes of :py:meth:`Quota.availability()`.
:raises ValueError: if you call this on an item which has variations associated with it.
Please use the method on the ItemVariation object you are interested in.
"""
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.select_related('subevent').filter(subevent=subevent)
if subevent else self.quotas.all()
))
if not subevent and self.event.has_subevents:
if not trust_parameters and not subevent and self.event.has_subevents:
raise TypeError('You need to supply a subevent.')
if ignored_quotas:
check_quotas -= set(ignored_quotas)
if not check_quotas:
return Quota.AVAILABILITY_OK, sys.maxsize
if self.has_variations: # NOQA
raise ValueError('Do not call this directly on items which have variations '
'but call this on their ItemVariation objects')
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
check_quotas = self._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
quotacounter = Counter()
res = Quota.AVAILABILITY_OK, None
for q in check_quotas:
quotacounter[q] += 1
if include_bundled:
for b in self.bundles.all():
bundled_check_quotas = (b.bundled_variation or b.bundled_item)._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
if not bundled_check_quotas:
return Quota.AVAILABILITY_GONE, 0
for q in bundled_check_quotas:
quotacounter[q] += b.count
for q, n in quotacounter.items():
a = q.availability(count_waitinglist=count_waitinglist, _cache=_cache)
if a[1] is None:
continue
num_avail = a[1] // n
code_avail = Quota.AVAILABILITY_GONE if a[1] >= 1 and num_avail < 1 else a[0]
# this is not entirely accurate, as it shows "sold out" even if it is actually just "reserved",
# since we do not know that distinction here if at least one item is available. However, this
# is only relevant in connection with bundles.
if code_avail < res[0] or res[1] is None or num_avail < res[1]:
res = (code_avail, num_avail)
if len(quotacounter) == 0:
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
return res
def allow_delete(self):
from pretix.base.models.orders import OrderPosition
return not OrderPosition.all.filter(item=self).exists()
@property
def includes_mixed_tax_rate(self):
for b in self.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
return True
return False
@cached_property
def has_variations(self):
return self.variations.exists()
@@ -531,11 +591,28 @@ class ItemVariation(models.Model):
def price(self):
return self.default_price if self.default_price is not None else self.item.default_price
def tax(self, price=None):
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
price = price if price is not None else self.price
if not self.item.tax_rule:
return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
return self.item.tax_rule.tax(price)
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
if include_bundled:
for b in self.item.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.item.tax_rule_id:
if b.bundled_variation:
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
else:
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!"
return t
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -547,7 +624,18 @@ class ItemVariation(models.Model):
if self.item:
self.item.event.cache.clear()
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None) -> Tuple[int, int]:
def _get_quotas(self, ignored_quotas=None, subevent=None):
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.filter(subevent=subevent).select_related('subevent')
if subevent else self.quotas.all()
))
if ignored_quotas:
check_quotas -= set(ignored_quotas)
return check_quotas
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
include_bundled=False, trust_parameters=False) -> Tuple[int, int]:
"""
This method is used to determine whether this ItemVariation is currently
available for sale in terms of quotas.
@@ -559,19 +647,38 @@ class ItemVariation(models.Model):
:param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation.
:returns: any of the return codes of :py:meth:`Quota.availability()`.
"""
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.filter(subevent=subevent).select_related('subevent')
if subevent else self.quotas.all()
))
if ignored_quotas:
check_quotas -= set(ignored_quotas)
if not subevent and self.item.event.has_subevents: # NOQA
if not trust_parameters and not subevent and self.item.event.has_subevents: # NOQA
raise TypeError('You need to supply a subevent.')
if not check_quotas:
return Quota.AVAILABILITY_OK, sys.maxsize
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
check_quotas = self._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
quotacounter = Counter()
res = Quota.AVAILABILITY_OK, None
for q in check_quotas:
quotacounter[q] += 1
if include_bundled:
for b in self.item.bundles.all():
bundled_check_quotas = (b.bundled_variation or b.bundled_item)._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
if not bundled_check_quotas:
return Quota.AVAILABILITY_GONE, 0
for q in bundled_check_quotas:
quotacounter[q] += b.count
for q, n in quotacounter.items():
a = q.availability(count_waitinglist=count_waitinglist, _cache=_cache)
if a[1] is None:
continue
num_avail = a[1] // n
code_avail = Quota.AVAILABILITY_GONE if a[1] >= 1 and num_avail < 1 else a[0]
# this is not entirely accurate, as it shows "sold out" even if it is actually just "reserved",
# since we do not know that distinction here if at least one item is available. However, this
# is only relevant in connection with bundles.
if code_avail < res[0] or res[1] is None or num_avail < res[1]:
res = (code_avail, num_avail)
if len(quotacounter) == 0:
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
return res
def __lt__(self, other):
if self.position == other.position:
@@ -672,6 +779,83 @@ class ItemAddOn(models.Model):
raise ValidationError(_('The maximum count needs to be greater than the minimum count.'))
class ItemBundle(models.Model):
"""
An instance of this model indicates that buying a ticket of the type ``base_item``
automatically also buys ``count`` items of type ``bundled_item``.
:param base_item: The base item the bundle is attached to
:type base_item: Item
:param bundled_item: The bundled item
:type bundled_item: Item
:param bundled_variation: The variation, if the bundled item has variations
:type bundled_variation: ItemVariation
:param count: The number of items to bundle
:type count: int
:param designated_price: The designated part price (optional)
:type designated_price: bool
"""
base_item = models.ForeignKey(
Item,
related_name='bundles',
on_delete=models.CASCADE
)
bundled_item = models.ForeignKey(
Item,
related_name='bundled_with',
verbose_name=_('Bundled item'),
on_delete=models.CASCADE
)
bundled_variation = models.ForeignKey(
ItemVariation,
related_name='bundled_with',
verbose_name=_('Bundled variation'),
null=True, blank=True,
on_delete=models.CASCADE
)
count = models.PositiveIntegerField(
default=1,
verbose_name=_('Number')
)
designated_price = models.DecimalField(
null=True, blank=True,
decimal_places=2, max_digits=10,
verbose_name=_('Designated price part'),
help_text=_('If set, it will be shown that this bundled item is responsible for the given value of the total '
'gross price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This '
'value will NOT be added to the base item\'s price.')
)
def clean(self):
self.clean_count(self.count)
def describe(self):
if self.count == 1:
if self.bundled_variation_id:
return "{} {}".format(self.bundled_item.name, self.bundled_variation.value)
else:
return self.bundled_item.name
else:
if self.bundled_variation_id:
return "{}× {} {}".format(self.count, self.bundled_item.name, self.bundled_variation.value)
else:
return "{}x {}".format(self.count, self.bundled_item.name)
@staticmethod
def clean_itemvar(event, bundled_item, bundled_variation):
if event != bundled_item.event:
raise ValidationError(_('The bundled item must belong to the same event as the item.'))
if bundled_item.has_variations and not bundled_variation:
raise ValidationError(_('A variation needs to be set for this item.'))
if bundled_variation and bundled_variation.item != bundled_item:
raise ValidationError(_('The chosen variation does not belong to this item.'))
@staticmethod
def clean_count(count):
if count < 0:
raise ValidationError(_('The count needs to be equal to or greater than zero.'))
class Question(LoggedModel):
"""
A question is an input field that can be used to extend a ticket by custom information,

View File

@@ -1691,8 +1691,9 @@ class OrderPosition(AbstractPosition):
# Delete afterwards. Deleting in between might cause deletion of things related to add-ons
# due to the deletion cascade.
for cartpos in cp:
cartpos.addons.all().delete()
cartpos.delete()
if cartpos.pk:
cartpos.addons.all().delete()
cartpos.delete()
return ops
def __str__(self):
@@ -1789,6 +1790,7 @@ class CartPosition(AbstractPosition):
includes_tax = models.BooleanField(
default=True
)
is_bundled = models.BooleanField(default=False)
class Meta:
verbose_name = _("Cart position")

View File

@@ -33,6 +33,32 @@ class TaxedPrice:
money_filter(self.gross, currency)
)
def __sub__(self, other):
newgross = self.gross - other.gross
newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize(
Decimal('10') ** self.gross.as_tuple().exponent
)
return TaxedPrice(
gross=newgross,
net=newnet,
tax=newgross - newnet,
rate=self.rate,
name=self.name,
)
def __mul__(self, other):
newgross = self.gross * other
newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize(
Decimal('10') ** self.gross.as_tuple().exponent
)
return TaxedPrice(
gross=newgross,
net=newnet,
tax=newgross - newnet,
rate=self.rate,
name=self.name,
)
TAXED_ZERO = TaxedPrice(
gross=Decimal('0.00'),
@@ -129,7 +155,12 @@ class TaxRule(LoggedModel):
def has_custom_rules(self):
return self.custom_rules and self.custom_rules != '[]'
def tax(self, base_price, base_price_is='auto'):
def tax(self, base_price, base_price_is='auto', currency=None):
from .event import Event
try:
currency = currency or self.event.currency
except Event.DoesNotExist:
pass
if self.rate == Decimal('0.00'):
return TaxedPrice(
net=base_price, gross=base_price, tax=Decimal('0.00'),
@@ -145,11 +176,11 @@ class TaxRule(LoggedModel):
if base_price_is == 'gross':
gross = base_price
net = round_decimal(gross - (base_price * (1 - 100 / (100 + self.rate))),
self.event.currency if self.event else None)
currency)
elif base_price_is == 'net':
net = base_price
gross = round_decimal((net * (1 + self.rate / 100)),
self.event.currency if self.event else None)
currency)
else:
raise ValueError('Unknown base price type: {}'.format(base_price_is))

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')