forked from CGM_Public/pretix_original
Discounts (#2510)
This commit is contained in:
@@ -54,11 +54,14 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import OrderFee
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
from pretix.base.models.tax import TaxRule
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.checkin import _save_answers
|
||||
from pretix.base.services.locking import LockTimeoutException, NoLockManager
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.pricing import (
|
||||
apply_discounts, get_line_price, get_listed_price, get_price,
|
||||
is_included_for_free,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList
|
||||
@@ -145,13 +148,15 @@ error_messages = {
|
||||
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat',
|
||||
'price_before_voucher'))
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
|
||||
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
|
||||
'price_after_voucher', 'custom_price_input',
|
||||
'custom_price_input_is_net'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price'))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas', 'subevent', 'seat', 'price_before_voucher'))
|
||||
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price_after_voucher'))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'voucher',
|
||||
'quotas', 'subevent', 'seat', 'listed_price',
|
||||
'price_after_voucher'))
|
||||
order = {
|
||||
RemoveOperation: 10,
|
||||
VoucherOperation: 15,
|
||||
@@ -178,8 +183,8 @@ class CartManager:
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
return CartPosition.objects.filter(
|
||||
Q(cart_id=self.cart_id) & Q(event=self.event)
|
||||
return self.event.cartposition_set.filter(
|
||||
Q(cart_id=self.cart_id)
|
||||
).select_related('item', 'subevent')
|
||||
|
||||
def _is_seated(self, item, subevent):
|
||||
@@ -390,7 +395,6 @@ class CartManager:
|
||||
'addons'
|
||||
).order_by('-is_bundled')
|
||||
err = None
|
||||
changed_prices = {}
|
||||
for cp in expired:
|
||||
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
|
||||
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
|
||||
@@ -401,40 +405,16 @@ class CartManager:
|
||||
if cp.is_bundled:
|
||||
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
|
||||
if bundle:
|
||||
price = bundle.designated_price or 0
|
||||
listed_price = bundle.designated_price or 0
|
||||
else:
|
||||
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)
|
||||
pbv = TAXED_ZERO
|
||||
listed_price = cp.price
|
||||
price_after_voucher = listed_price
|
||||
else:
|
||||
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=Decimal('0'), tax=Decimal('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=Decimal('0'), tax=Decimal('0'), name='')
|
||||
listed_price = get_listed_price(cp.item, cp.variation, cp.subevent)
|
||||
if cp.voucher:
|
||||
price_after_voucher = cp.voucher.calculate_price(listed_price)
|
||||
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)
|
||||
price_after_voucher = listed_price
|
||||
|
||||
quotas = list(cp.quotas)
|
||||
if not quotas:
|
||||
@@ -450,7 +430,8 @@ 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_before_voucher=pbv
|
||||
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
|
||||
price_after_voucher=price_after_voucher,
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
|
||||
@@ -489,26 +470,22 @@ class CartManager:
|
||||
if p.is_bundled:
|
||||
continue
|
||||
|
||||
bundled_sum = Decimal('0.00')
|
||||
if not p.addon_to_id:
|
||||
for bundledp in p.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
bundledprice = bundledp.price
|
||||
bundled_sum += bundledprice
|
||||
|
||||
price = self._get_price(p.item, p.variation, voucher, None, p.subevent, bundled_sum=bundled_sum)
|
||||
"""
|
||||
if price.gross > p.price:
|
||||
continue
|
||||
"""
|
||||
if p.listed_price is None:
|
||||
if p.addon_to_id and is_included_for_free(p.item, p.addon_to):
|
||||
listed_price = Decimal('0.00')
|
||||
else:
|
||||
listed_price = get_listed_price(p.item, p.variation, p.subevent)
|
||||
else:
|
||||
listed_price = p.listed_price
|
||||
price_after_voucher = voucher.calculate_price(listed_price)
|
||||
|
||||
voucher_use_diff[voucher] += 1
|
||||
ops.append((p.price - price.gross, self.VoucherOperation(p, voucher, price)))
|
||||
ops.append((listed_price - price_after_voucher, self.VoucherOperation(p, voucher, price_after_voucher)))
|
||||
|
||||
# If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
|
||||
# the user the most.
|
||||
ops.sort(key=lambda k: k[0], reverse=True)
|
||||
self._operations += [k[1] for k in ops]\
|
||||
self._operations += [k[1] for k in ops]
|
||||
|
||||
if not voucher_use_diff:
|
||||
raise CartError(error_messages['voucher_no_match'])
|
||||
@@ -575,7 +552,6 @@ class CartManager:
|
||||
|
||||
# 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:
|
||||
@@ -595,28 +571,49 @@ class CartManager:
|
||||
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=[], seat=None, price_before_voucher=bprice,
|
||||
count=bundle.count,
|
||||
item=bitem,
|
||||
variation=bvar,
|
||||
voucher=None,
|
||||
quotas=bundle_quotas,
|
||||
addon_to='FAKE',
|
||||
subevent=subevent,
|
||||
bundled=[],
|
||||
seat=None,
|
||||
listed_price=bundle.designated_price,
|
||||
price_after_voucher=bundle.designated_price,
|
||||
custom_price_input=None,
|
||||
custom_price_input_is_net=False,
|
||||
)
|
||||
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)
|
||||
listed_price = get_listed_price(item, variation, subevent)
|
||||
if voucher:
|
||||
price_after_voucher = voucher.calculate_price(listed_price)
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
custom_price = None
|
||||
if item.free_price and i.get('price'):
|
||||
custom_price = Decimal(str(i.get('price')).replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
raise ValueError('price_too_high')
|
||||
|
||||
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,
|
||||
price_before_voucher=pbv
|
||||
count=i['count'],
|
||||
item=item,
|
||||
variation=variation,
|
||||
voucher=voucher,
|
||||
quotas=quotas,
|
||||
addon_to=False,
|
||||
subevent=subevent,
|
||||
bundled=bundled,
|
||||
seat=seat,
|
||||
listed_price=listed_price,
|
||||
price_after_voucher=price_after_voucher,
|
||||
custom_price_input=custom_price,
|
||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||
)
|
||||
self._check_item_constraints(op, operations)
|
||||
operations.append(op)
|
||||
@@ -707,16 +704,27 @@ class CartManager:
|
||||
input_addons[cp.id][a['item'], a['variation']] = a.get('count', 1)
|
||||
selected_addons[cp.id, item.category_id][a['item'], a['variation']] = a.get('count', 1)
|
||||
|
||||
if price_included[cp.pk].get(item.category_id):
|
||||
price = TAXED_ZERO
|
||||
if is_included_for_free(item, cp):
|
||||
listed_price = Decimal('0.00')
|
||||
else:
|
||||
price = self._get_price(item, variation, None, a.get('price'), cp.subevent)
|
||||
listed_price = get_listed_price(item, variation, cp.subevent)
|
||||
custom_price = None
|
||||
if item.free_price and a.get('price'):
|
||||
custom_price = Decimal(str(a.get('price')).replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
raise ValueError('price_too_high')
|
||||
|
||||
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
|
||||
for ca in current_addons[cp][a['item'], a['variation']]:
|
||||
if ca.price != price.gross:
|
||||
ca.price = price.gross
|
||||
ca.save(update_fields=['price'])
|
||||
if ca.listed_price != listed_price:
|
||||
ca.listed_price = ca.listed_price
|
||||
ca.price_after_voucher = ca.price_after_voucher
|
||||
ca.save(update_fields=['listed_price', 'price_after_voucher'])
|
||||
if ca.custom_price_input != custom_price:
|
||||
ca.custom_price_input = custom_price
|
||||
ca.custom_price_input_is_net = self.event.settings.display_net_prices
|
||||
ca.price_after_voucher = ca.price_after_voucher
|
||||
ca.save(update_fields=['custom_price_input', 'custom_price_input'])
|
||||
|
||||
if a.get('count', 1) > len(current_addons[cp][a['item'], a['variation']]):
|
||||
# This add-on is new, add it to the cart
|
||||
@@ -725,9 +733,18 @@ class CartManager:
|
||||
|
||||
op = self.AddOperation(
|
||||
count=a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']]),
|
||||
item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None,
|
||||
price_before_voucher=None
|
||||
item=item,
|
||||
variation=variation,
|
||||
voucher=None,
|
||||
quotas=quotas,
|
||||
addon_to=cp,
|
||||
subevent=cp.subevent,
|
||||
bundled=[],
|
||||
seat=None,
|
||||
listed_price=listed_price,
|
||||
price_after_voucher=listed_price,
|
||||
custom_price_input=custom_price,
|
||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||
)
|
||||
self._check_item_constraints(op, operations)
|
||||
operations.append(op)
|
||||
@@ -972,13 +989,31 @@ class CartManager:
|
||||
err = err or error_messages['seat_unavailable']
|
||||
|
||||
for k in range(available_count):
|
||||
line_price = get_line_price(
|
||||
price_after_voucher=op.price_after_voucher,
|
||||
custom_price_input=op.custom_price_input,
|
||||
custom_price_input_is_net=op.custom_price_input_is_net,
|
||||
tax_rule=op.item.tax_rule,
|
||||
invoice_address=self.invoice_address,
|
||||
bundled_sum=sum([pp.count * pp.price_after_voucher for pp in op.bundled]),
|
||||
)
|
||||
cp = CartPosition(
|
||||
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,
|
||||
override_tax_rate=op.price.rate,
|
||||
price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None
|
||||
event=self.event,
|
||||
item=op.item,
|
||||
variation=op.variation,
|
||||
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,
|
||||
seat=op.seat,
|
||||
listed_price=op.listed_price,
|
||||
price_after_voucher=op.price_after_voucher,
|
||||
custom_price_input=op.custom_price_input,
|
||||
custom_price_input_is_net=op.custom_price_input_is_net,
|
||||
line_price_gross=line_price.gross,
|
||||
tax_rate=line_price.tax,
|
||||
price=line_price.gross,
|
||||
)
|
||||
if self.event.settings.attendee_names_asked:
|
||||
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
|
||||
@@ -1007,12 +1042,26 @@ class CartManager:
|
||||
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:
|
||||
bline_price = (
|
||||
b.item.tax_rule or TaxRule(rate=Decimal('0.00'))
|
||||
).tax(b.listed_price, base_price_is='gross', invoice_address=self.invoice_address) # todo compare with previous behaviour
|
||||
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, override_tax_rate=b.price.rate,
|
||||
subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True
|
||||
event=self.event,
|
||||
item=b.item,
|
||||
variation=b.variation,
|
||||
expires=self._expiry, cart_id=self.cart_id,
|
||||
voucher=None,
|
||||
addon_to=cp,
|
||||
subevent=b.subevent,
|
||||
listed_price=b.listed_price,
|
||||
price_after_voucher=b.price_after_voucher,
|
||||
custom_price_input=b.custom_price_input,
|
||||
custom_price_input_is_net=b.custom_price_input_is_net,
|
||||
line_price_gross=bline_price.gross,
|
||||
tax_rate=bline_price.tax,
|
||||
price=bline_price.gross,
|
||||
is_bundled=True
|
||||
))
|
||||
|
||||
new_cart_positions.append(cp)
|
||||
@@ -1024,11 +1073,11 @@ class CartManager:
|
||||
op.position.delete()
|
||||
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
|
||||
op.position.listed_price = op.listed_price
|
||||
op.position.price_after_voucher = op.price_after_voucher
|
||||
# op.position.price will be updated by recompute_final_prices_and_taxes()
|
||||
try:
|
||||
op.position.save(force_update=True)
|
||||
op.position.save(force_update=True, update_fields=['expires', 'listed_price', 'price_after_voucher'])
|
||||
except DatabaseError:
|
||||
# Best effort... The position might have been deleted in the meantime!
|
||||
pass
|
||||
@@ -1046,10 +1095,10 @@ class CartManager:
|
||||
# be expected
|
||||
continue
|
||||
|
||||
op.position.price_before_voucher = op.position.price
|
||||
op.position.price = op.price.gross
|
||||
op.position.price_after_voucher = op.price_after_voucher
|
||||
op.position.voucher = op.voucher
|
||||
op.position.save()
|
||||
# op.posiiton.price will be set in recompute_final_prices_and_taxes
|
||||
op.position.save(update_fields=['price_after_voucher', 'voucher'])
|
||||
vouchers_ok[op.voucher] -= 1
|
||||
|
||||
for p in new_cart_positions:
|
||||
@@ -1074,6 +1123,35 @@ class CartManager:
|
||||
|
||||
return False
|
||||
|
||||
def recompute_final_prices_and_taxes(self):
|
||||
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
|
||||
diff = Decimal('0.00')
|
||||
for cp in positions:
|
||||
if cp.listed_price is None:
|
||||
# migration from old system? also used in unit tests
|
||||
cp.update_listed_price_and_voucher()
|
||||
cp.migrate_free_price_if_necessary()
|
||||
|
||||
cp.update_line_price(self.invoice_address, [b for b in positions if b.addon_to_id == cp.pk and b.is_bundled])
|
||||
|
||||
discount_results = apply_discounts(
|
||||
self.event,
|
||||
self._sales_channel,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
||||
for cp in positions
|
||||
]
|
||||
)
|
||||
|
||||
for cp, (new_price, discount) in zip(positions, discount_results):
|
||||
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||
diff += new_price - cp.price
|
||||
cp.price = new_price
|
||||
cp.discount = discount
|
||||
cp.save(update_fields=['price', 'discount'])
|
||||
|
||||
return diff
|
||||
|
||||
def commit(self):
|
||||
self._check_presale_dates()
|
||||
self._check_max_cart_size()
|
||||
@@ -1091,33 +1169,11 @@ class CartManager:
|
||||
self.now_dt = now_dt
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
err = self._perform_operations() or err
|
||||
self.recompute_final_prices_and_taxes()
|
||||
if err:
|
||||
raise CartError(err)
|
||||
|
||||
|
||||
def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress):
|
||||
positions = CartPosition.objects.filter(
|
||||
cart_id=cart_id, event=event
|
||||
).select_related('item', 'item__tax_rule')
|
||||
totaldiff = Decimal('0.00')
|
||||
for pos in positions:
|
||||
if not pos.item.tax_rule:
|
||||
continue
|
||||
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
|
||||
|
||||
if pos.tax_rate != rate:
|
||||
if not pos.item.tax_rule.keep_gross_if_rate_changes:
|
||||
current_net = pos.price - pos.tax_value
|
||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||
totaldiff += new_gross - pos.price
|
||||
pos.price = new_gross
|
||||
pos.includes_tax = rate != Decimal('0.00')
|
||||
pos.override_tax_rate = rate
|
||||
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
|
||||
|
||||
return totaldiff
|
||||
|
||||
|
||||
def get_fees(event, request, total, invoice_address, provider, positions):
|
||||
from pretix.presale.views.cart import cart_session
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import Counter, defaultdict, namedtuple
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
@@ -68,7 +69,6 @@ from pretix.base.models import (
|
||||
Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import ItemBundle
|
||||
from pretix.base.models.orders import (
|
||||
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
|
||||
)
|
||||
@@ -86,7 +86,9 @@ from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.memberships import (
|
||||
create_membership, validate_memberships_in_order,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.pricing import (
|
||||
apply_discounts, get_listed_price, get_price,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||
from pretix.base.signals import (
|
||||
@@ -565,7 +567,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
_check_date(event, now_dt)
|
||||
|
||||
products_seen = Counter()
|
||||
changed_prices = {}
|
||||
q_avail = Counter()
|
||||
v_avail = Counter()
|
||||
v_budget = {}
|
||||
deleted_positions = set()
|
||||
seats_seen = set()
|
||||
@@ -582,6 +585,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.delete()
|
||||
|
||||
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
|
||||
|
||||
# Check availability
|
||||
for i, cp in enumerate(sorted_positions):
|
||||
if cp.pk in deleted_positions:
|
||||
continue
|
||||
@@ -601,29 +606,17 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
break
|
||||
|
||||
if cp.voucher:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
).exclude(pk=cp.pk)
|
||||
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
|
||||
if v_avail < 1:
|
||||
if cp.voucher not in v_avail:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
).exclude(cart_id=cp.cart_id)
|
||||
v_avail[cp.voucher] = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
|
||||
v_avail[cp.voucher] -= 1
|
||||
if v_avail[cp.voucher] < 0:
|
||||
err = err or error_messages['voucher_redeemed']
|
||||
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)
|
||||
@@ -662,7 +655,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
|
||||
) and not cp.is_bundled:
|
||||
delete(cp)
|
||||
cp.delete()
|
||||
err = error_messages['voucher_required']
|
||||
break
|
||||
|
||||
@@ -671,56 +663,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
# time, since we absolutely can not overbook a seat.
|
||||
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel):
|
||||
err = err or error_messages['seat_unavailable']
|
||||
cp.delete()
|
||||
delete(cp)
|
||||
continue
|
||||
|
||||
if cp.expires >= now_dt and not cp.voucher:
|
||||
# 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)
|
||||
|
||||
try:
|
||||
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
|
||||
except ItemBundle.MultipleObjectsReturned:
|
||||
raise OrderError("Invalid product configuration (duplicate bundle)")
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
|
||||
custom_price_is_tax_rate=cp.override_tax_rate,
|
||||
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,
|
||||
custom_price_is_tax_rate=cp.override_tax_rate,
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
changed_prices[cp.pk] = bprice
|
||||
else:
|
||||
bundled_sum = Decimal('0.00')
|
||||
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,
|
||||
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
|
||||
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, custom_price_is_tax_rate=cp.override_tax_rate)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
err = err or error_messages['country_blocked']
|
||||
cp.delete()
|
||||
continue
|
||||
|
||||
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:
|
||||
if len(quotas) == 0:
|
||||
err = err or error_messages['unavailable']
|
||||
delete(cp)
|
||||
continue
|
||||
@@ -742,42 +692,88 @@ 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)
|
||||
cp.save()
|
||||
err = err or error_messages['price_changed']
|
||||
continue
|
||||
|
||||
quota_ok = True
|
||||
|
||||
ignore_all_quotas = cp.expires >= now_dt or (
|
||||
cp.voucher and (cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)))
|
||||
cp.voucher and (
|
||||
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
|
||||
)
|
||||
)
|
||||
|
||||
if not ignore_all_quotas:
|
||||
for quota in quotas:
|
||||
if cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id == quota.pk:
|
||||
continue
|
||||
avail = quota.availability(now_dt)
|
||||
if avail[0] != Quota.AVAILABILITY_OK:
|
||||
# This quota is sold out/currently unavailable, so do not sell this at all
|
||||
if quota not in q_avail:
|
||||
avail = quota.availability(now_dt)
|
||||
q_avail[quota] = avail[1] if avail[1] is not None else sys.maxsize
|
||||
q_avail[quota] -= 1
|
||||
if q_avail[quota] < 0:
|
||||
err = err or error_messages['unavailable']
|
||||
quota_ok = False
|
||||
break
|
||||
|
||||
if quota_ok:
|
||||
cp.expires = now_dt + timedelta(
|
||||
minutes=event.settings.get('reservation_time', as_type=int))
|
||||
cp.save()
|
||||
else:
|
||||
if not quota_ok:
|
||||
# Sorry, can't let you keep that!
|
||||
delete(cp)
|
||||
|
||||
# Check prices
|
||||
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
|
||||
old_total = sum(cp.price for cp in sorted_positions)
|
||||
for i, cp in enumerate(sorted_positions):
|
||||
if cp.listed_price is None:
|
||||
# migration from pre-discount cart positions
|
||||
cp.update_listed_price_and_voucher(max_discount=None)
|
||||
cp.migrate_free_price_if_necessary()
|
||||
|
||||
# deal with max discount
|
||||
max_discount = None
|
||||
if cp.voucher and cp.voucher.budget is not None:
|
||||
if cp.voucher not in v_budget:
|
||||
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
|
||||
max_discount = max(v_budget[cp.voucher], 0)
|
||||
|
||||
if cp.expires < now_dt or cp.listed_price is None:
|
||||
# Guarantee on listed price is expired
|
||||
cp.update_listed_price_and_voucher(max_discount=max_discount)
|
||||
elif cp.voucher:
|
||||
cp.update_listed_price_and_voucher(max_discount=max_discount, voucher_only=True)
|
||||
|
||||
if max_discount is not None:
|
||||
v_budget[cp.voucher] = v_budget[cp.voucher] - (cp.listed_price - cp.price_after_voucher)
|
||||
|
||||
try:
|
||||
cp.update_line_price(address, [b for b in sorted_positions if b.addon_to_id == cp.pk and b.is_bundled and b.pk and b.pk not in deleted_positions])
|
||||
except TaxRule.SaleNotAllowed:
|
||||
err = err or error_messages['country_blocked']
|
||||
delete(cp)
|
||||
continue
|
||||
|
||||
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
|
||||
discount_results = apply_discounts(
|
||||
event,
|
||||
sales_channel,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
||||
for cp in sorted_positions
|
||||
]
|
||||
)
|
||||
for cp, (new_price, discount) in zip(sorted_positions, discount_results):
|
||||
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||
cp.price = new_price
|
||||
cp.discount = discount
|
||||
cp.save(update_fields=['price', 'discount'])
|
||||
|
||||
new_total = sum(cp.price for cp in sorted_positions)
|
||||
if old_total != new_total:
|
||||
err = err or error_messages['price_changed']
|
||||
|
||||
# Store updated positions
|
||||
for cp in sorted_positions:
|
||||
cp.expires = now_dt + timedelta(
|
||||
minutes=event.settings.get('reservation_time', as_type=int))
|
||||
cp.save()
|
||||
|
||||
if err:
|
||||
raise OrderError(err, errargs)
|
||||
|
||||
@@ -1858,16 +1854,14 @@ 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
|
||||
)
|
||||
|
||||
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
|
||||
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
|
||||
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
|
||||
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
|
||||
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate=False, save=False
|
||||
)
|
||||
@@ -1908,16 +1902,13 @@ class OrderChangeManager:
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate=False, save=False
|
||||
)
|
||||
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
|
||||
)
|
||||
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
|
||||
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
|
||||
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
|
||||
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
|
||||
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
|
||||
op.position.save()
|
||||
elif isinstance(op, self.AddFeeOperation):
|
||||
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={
|
||||
|
||||
@@ -20,39 +20,30 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import (
|
||||
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.event import Event, SubEvent
|
||||
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,
|
||||
custom_price_is_tax_rate: Decimal=None,
|
||||
custom_price_is_tax_rate: Decimal = None,
|
||||
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
|
||||
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'),
|
||||
max_discount: Decimal = None, tax_rule=None) -> TaxedPrice:
|
||||
if addon_to:
|
||||
try:
|
||||
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
|
||||
if iao.price_included:
|
||||
return TAXED_ZERO
|
||||
except ItemAddOn.DoesNotExist:
|
||||
pass
|
||||
if is_included_for_free(item, addon_to):
|
||||
return TAXED_ZERO
|
||||
|
||||
price = item.default_price
|
||||
if subevent and item.pk in subevent.item_price_overrides:
|
||||
price = subevent.item_price_overrides[item.pk]
|
||||
|
||||
if variation is not None:
|
||||
if variation.default_price is not None:
|
||||
price = variation.default_price
|
||||
if subevent and variation.pk in subevent.var_price_overrides:
|
||||
price = subevent.var_price_overrides[variation.pk]
|
||||
price = get_listed_price(item, variation, subevent)
|
||||
|
||||
if voucher:
|
||||
price = voucher.calculate_price(price, max_discount=max_discount)
|
||||
@@ -85,10 +76,10 @@ def get_price(item: Item, variation: ItemVariation = None,
|
||||
price = tax_rule.tax(price, invoice_address=invoice_address)
|
||||
|
||||
if custom_price_is_net:
|
||||
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net',
|
||||
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', override_tax_rate=price.rate,
|
||||
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', gross_price_is_tax_rate=custom_price_is_tax_rate,
|
||||
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', override_tax_rate=price.rate,
|
||||
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
@@ -98,3 +89,83 @@ def get_price(item: Item, variation: ItemVariation = None,
|
||||
price.tax = price.gross - price.net
|
||||
|
||||
return price
|
||||
|
||||
|
||||
def is_included_for_free(item: Item, addon_to: AbstractPosition):
|
||||
if addon_to:
|
||||
try:
|
||||
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
|
||||
if iao.price_included:
|
||||
return True
|
||||
except ItemAddOn.DoesNotExist:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubEvent = None) -> Decimal:
|
||||
price = item.default_price
|
||||
if subevent and item.pk in subevent.item_price_overrides:
|
||||
price = subevent.item_price_overrides[item.pk]
|
||||
|
||||
if variation is not None:
|
||||
if variation.default_price is not None:
|
||||
price = variation.default_price
|
||||
if subevent and variation.pk in subevent.var_price_overrides:
|
||||
price = subevent.var_price_overrides[variation.pk]
|
||||
|
||||
return price
|
||||
|
||||
|
||||
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
|
||||
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal) -> TaxedPrice:
|
||||
if not tax_rule:
|
||||
tax_rule = TaxRule(
|
||||
name='',
|
||||
rate=Decimal('0.00'),
|
||||
price_includes_tax=True,
|
||||
eu_reverse_charge=False,
|
||||
)
|
||||
if custom_price_input:
|
||||
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address)
|
||||
|
||||
if custom_price_input_is_net:
|
||||
price = tax_rule.tax(max(custom_price_input, price.net), base_price_is='net', override_tax_rate=price.rate,
|
||||
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
|
||||
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
|
||||
return price
|
||||
|
||||
|
||||
def apply_discounts(event: Event, sales_channel: str,
|
||||
positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]:
|
||||
"""
|
||||
Applies any dynamic discounts to a cart
|
||||
|
||||
:param event: Event the cart belongs to
|
||||
:param sales_channel: Sales channel the cart was created with
|
||||
:param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
|
||||
:return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input
|
||||
"""
|
||||
new_prices = {}
|
||||
|
||||
discount_qs = event.discounts.filter(
|
||||
Q(available_from__isnull=True) | Q(available_from__lte=now()),
|
||||
Q(available_until__isnull=True) | Q(available_until__gte=now()),
|
||||
sales_channels__contains=sales_channel,
|
||||
active=True,
|
||||
).prefetch_related('condition_limit_products').order_by('position', 'pk')
|
||||
for discount in discount_qs:
|
||||
result = discount.apply({
|
||||
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
|
||||
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
|
||||
if not is_bundled and idx not in new_prices
|
||||
})
|
||||
for k in result.keys():
|
||||
result[k] = (result[k], discount)
|
||||
new_prices.update(result)
|
||||
|
||||
return [new_prices.get(idx, (p[2], None)) for idx, p in enumerate(positions)]
|
||||
|
||||
Reference in New Issue
Block a user