mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
first round of cleanup
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
# Generated by Django 4.2.11 on 2024-05-27 13:19
|
# Generated by Django 4.2.11 on 2024-05-27 13:19
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
import pretix.base.models.orders
|
import pretix.base.models.orders
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ LINE_PRICE_GROSS = 2
|
|||||||
IS_ADDON_TO = 3
|
IS_ADDON_TO = 3
|
||||||
VOUCHER_DISCOUNT = 4
|
VOUCHER_DISCOUNT = 4
|
||||||
|
|
||||||
|
|
||||||
class Discount(LoggedModel):
|
class Discount(LoggedModel):
|
||||||
SUBEVENT_MODE_MIXED = 'mixed'
|
SUBEVENT_MODE_MIXED = 'mixed'
|
||||||
SUBEVENT_MODE_SAME = 'same'
|
SUBEVENT_MODE_SAME = 'same'
|
||||||
@@ -280,30 +281,39 @@ class Discount(LoggedModel):
|
|||||||
if not self.condition_min_count:
|
if not self.condition_min_count:
|
||||||
raise ValueError('Validation invariant violated.')
|
raise ValueError('Validation invariant violated.')
|
||||||
|
|
||||||
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][LINE_PRICE_GROSS], -idx)) # sort by line_price
|
# sort by line_price
|
||||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][LINE_PRICE_GROSS], -idx)) # sort by line_price
|
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][LINE_PRICE_GROSS], -idx))
|
||||||
|
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][LINE_PRICE_GROSS], -idx))
|
||||||
|
|
||||||
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
||||||
# want to match multiples of 3
|
# want to match multiples of 3
|
||||||
possible_applications_cond = len(condition_idx_group) // self.condition_min_count # how many discount applications are allowed according to condition products in cart
|
|
||||||
possible_applications_benefit = ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches) # how many discount applications are possible according to benefitting products in cart
|
# how many discount applications are allowed according to condition products in cart
|
||||||
|
possible_applications_cond = len(condition_idx_group) // self.condition_min_count
|
||||||
|
|
||||||
|
# how many discount applications are possible according to benefitting products in cart
|
||||||
|
possible_applications_benefit = ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches)
|
||||||
|
|
||||||
n_groups = min(possible_applications_cond, possible_applications_benefit)
|
n_groups = min(possible_applications_cond, possible_applications_benefit)
|
||||||
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
|
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
|
||||||
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
|
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
|
||||||
|
|
||||||
if collect_potential_discounts is not None and n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(
|
if collect_potential_discounts is not None:
|
||||||
benefit_idx_group):
|
if n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
|
||||||
# "angebrochener" discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket but only 1 t-shirt) -> 1 shirt definitiv potential discount
|
# "angebrochener" discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
|
||||||
for idx in consume_idx:
|
# but only 1 t-shirt) -> 1 shirt definitiv potential discount
|
||||||
collect_potential_discounts[idx] = [(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1)]
|
for idx in consume_idx:
|
||||||
|
collect_potential_discounts[idx] = [
|
||||||
|
(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1)
|
||||||
|
]
|
||||||
|
|
||||||
if collect_potential_discounts is not None and possible_applications_cond * self.benefit_only_apply_to_cheapest_n_matches > len(
|
if possible_applications_cond * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
|
||||||
benefit_idx_group):
|
# "ungenutzter" discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
|
||||||
# "ungenutzter" discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket but 0 t-shirts) -> 2 shirt maybe potential discount (if the 1 ticket is not consumed by a later discount)
|
# but 0 t-shirts) -> 2 shirt maybe potential discount (if the 1 ticket is not consumed by a later discount)
|
||||||
for i, idx in enumerate(condition_idx_group[n_groups * self.condition_min_count:]):
|
for i, idx in enumerate(condition_idx_group[n_groups * self.condition_min_count:]):
|
||||||
collect_potential_discounts[idx] += [
|
collect_potential_discounts[idx] += [
|
||||||
(self, self.benefit_only_apply_to_cheapest_n_matches, i // self.condition_min_count)
|
(self, self.benefit_only_apply_to_cheapest_n_matches, i // self.condition_min_count)
|
||||||
]
|
]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
consume_idx = condition_idx_group
|
consume_idx = condition_idx_group
|
||||||
@@ -324,7 +334,8 @@ class Discount(LoggedModel):
|
|||||||
for idx in consume_idx:
|
for idx in consume_idx:
|
||||||
result.setdefault(idx, positions[idx][LINE_PRICE_GROSS])
|
result.setdefault(idx, positions[idx][LINE_PRICE_GROSS])
|
||||||
|
|
||||||
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]], collect_potential_discounts=None) -> Dict[int, Decimal]:
|
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]],
|
||||||
|
collect_potential_discounts=None) -> Dict[int, Decimal]:
|
||||||
"""
|
"""
|
||||||
Tries to apply this discount to a cart
|
Tries to apply this discount to a cart
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class ItemCategory(LoggedModel):
|
|||||||
cross_selling_mode = models.CharField(
|
cross_selling_mode = models.CharField(
|
||||||
choices=CROSS_SELLING_MODES,
|
choices=CROSS_SELLING_MODES,
|
||||||
null=True,
|
null=True,
|
||||||
|
max_length=5
|
||||||
)
|
)
|
||||||
CROSS_SELLING_CONDITION = (
|
CROSS_SELLING_CONDITION = (
|
||||||
('always', _('Always show in cross-selling step')),
|
('always', _('Always show in cross-selling step')),
|
||||||
@@ -131,6 +132,7 @@ class ItemCategory(LoggedModel):
|
|||||||
verbose_name=_("Cross-selling condition"),
|
verbose_name=_("Cross-selling condition"),
|
||||||
choices=CROSS_SELLING_CONDITION,
|
choices=CROSS_SELLING_CONDITION,
|
||||||
null=True,
|
null=True,
|
||||||
|
max_length=10,
|
||||||
)
|
)
|
||||||
cross_selling_match_products = models.ManyToManyField(
|
cross_selling_match_products = models.ManyToManyField(
|
||||||
'pretixbase.Item',
|
'pretixbase.Item',
|
||||||
@@ -158,15 +160,13 @@ class ItemCategory(LoggedModel):
|
|||||||
if self.cross_selling_condition == 'always':
|
if self.cross_selling_condition == 'always':
|
||||||
return self.items.all(), {}
|
return self.items.all(), {}
|
||||||
if self.cross_selling_condition == 'products':
|
if self.cross_selling_condition == 'products':
|
||||||
match = set(match.pk for match in self.cross_selling_match_products.only('pk')) # TODO prefetch this
|
match = set(match.pk for match in self.cross_selling_match_products.only('pk')) # TODO prefetch this
|
||||||
return (self.items.all(), {}) if any(pos.item.pk in match for pos in cart) else ([], {})
|
return (self.items.all(), {}) if any(pos.item.pk in match for pos in cart) else ([], {})
|
||||||
if self.cross_selling_condition == 'discounts':
|
if self.cross_selling_condition == 'discounts':
|
||||||
# aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaarrrrgggghhhhhh
|
potential_discounts_dict = defaultdict(list)
|
||||||
|
|
||||||
from ..services.pricing import apply_discounts
|
from ..services.pricing import apply_discounts
|
||||||
|
apply_discounts(
|
||||||
potential_discounts_dict = defaultdict(list)
|
|
||||||
discount_results = apply_discounts(
|
|
||||||
self.event,
|
self.event,
|
||||||
sales_channel,
|
sales_channel,
|
||||||
[
|
[
|
||||||
@@ -176,7 +176,6 @@ class ItemCategory(LoggedModel):
|
|||||||
],
|
],
|
||||||
collect_potential_discounts=potential_discounts_dict
|
collect_potential_discounts=potential_discounts_dict
|
||||||
)
|
)
|
||||||
print("potential_discounts_dict", potential_discounts_dict)
|
|
||||||
potential_discount_infos = dict.fromkeys(info for lst in potential_discounts_dict.values() for info in lst)
|
potential_discount_infos = dict.fromkeys(info for lst in potential_discounts_dict.values() for info in lst)
|
||||||
|
|
||||||
# sum up the max_counts and pass them on (also pass on the discount_rules so we can calculate actual discounted prices later):
|
# sum up the max_counts and pass them on (also pass on the discount_rules so we can calculate actual discounted prices later):
|
||||||
@@ -199,15 +198,18 @@ class ItemCategory(LoggedModel):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def sum_or_none(iter):
|
def sum_or_none(iter):
|
||||||
return functools.reduce(lambda x,y: None if x is None or y is None else x + y, iter, 0)
|
return functools.reduce(lambda x, y: None if x is None or y is None else x + y, iter, 0)
|
||||||
|
|
||||||
my_item_pks = self.items.values_list('pk', flat=True)
|
my_item_pks = self.items.values_list('pk', flat=True)
|
||||||
print("grouped:",grouped_by_item)
|
potential_discount_items = {
|
||||||
potential_discount_items = {item.pk: (sum_or_none(max_count for (item, discount_rule, max_count, i) in infos_for_item), next(discount_rule for (item, discount_rule, max_count, i) in infos_for_item))
|
item.pk: (
|
||||||
for item, infos_for_item in grouped_by_item
|
sum_or_none(max_count for (item, discount_rule, max_count, i) in infos_for_item),
|
||||||
if item.pk in my_item_pks}
|
next(discount_rule for (item, discount_rule, max_count, i) in infos_for_item)
|
||||||
|
)
|
||||||
|
for item, infos_for_item in grouped_by_item
|
||||||
|
if item.pk in my_item_pks
|
||||||
|
}
|
||||||
|
|
||||||
#potential_discount_items = {item.pk for (discount_rule, max_count, i) in potential_discount_infos.keys() for item in discount_rule.benefit_limit_products.all()}
|
|
||||||
return self.items.filter(pk__in=potential_discount_items), potential_discount_items
|
return self.items.filter(pk__in=potential_discount_items), potential_discount_items
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@@ -177,8 +177,6 @@ def apply_discounts(event: Event, sales_channel: str,
|
|||||||
active=True,
|
active=True,
|
||||||
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
||||||
for discount in discount_qs:
|
for discount in discount_qs:
|
||||||
if collect_potential_discounts is not None:
|
|
||||||
print("checking discount",discount)
|
|
||||||
result = discount.apply({
|
result = discount.apply({
|
||||||
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
|
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)
|
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
|
||||||
@@ -186,8 +184,6 @@ def apply_discounts(event: Event, sales_channel: str,
|
|||||||
}, collect_potential_discounts)
|
}, collect_potential_discounts)
|
||||||
for k in result.keys():
|
for k in result.keys():
|
||||||
result[k] = (result[k], discount)
|
result[k] = (result[k], discount)
|
||||||
if collect_potential_discounts is not None:
|
|
||||||
print(" ->",collect_potential_discounts)
|
|
||||||
new_prices.update(result)
|
new_prices.update(result)
|
||||||
|
|
||||||
return [new_prices.get(idx, (p[2], None)) for idx, p in enumerate(positions)]
|
return [new_prices.get(idx, (p[2], None)) for idx, p in enumerate(positions)]
|
||||||
|
|||||||
@@ -81,17 +81,12 @@ class CategoryForm(I18nModelForm):
|
|||||||
'name',
|
'name',
|
||||||
'internal_name',
|
'internal_name',
|
||||||
'description',
|
'description',
|
||||||
#'is_addon'
|
|
||||||
'cross_selling_condition',
|
'cross_selling_condition',
|
||||||
'cross_selling_match_products',
|
'cross_selling_match_products',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'description': I18nMarkdownTextarea,
|
'description': I18nMarkdownTextarea,
|
||||||
'cross_selling_condition': RadioSelect,
|
'cross_selling_condition': RadioSelect,
|
||||||
#'is_addon': BooleanRadio(
|
|
||||||
# 'normal', _('Normal category'),
|
|
||||||
# 'addon', _('Products in this category are add-on products'),
|
|
||||||
#)
|
|
||||||
}
|
}
|
||||||
field_classes = {
|
field_classes = {
|
||||||
'cross_selling_match_products': SafeModelMultipleChoiceField,
|
'cross_selling_match_products': SafeModelMultipleChoiceField,
|
||||||
@@ -99,18 +94,20 @@ class CategoryForm(I18nModelForm):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
#self.fields['is_addon'].label = _('Category type')
|
|
||||||
self.fields['category_type'] = ChoiceField(widget=RadioSelect, choices=(
|
self.fields['category_type'] = ChoiceField(widget=RadioSelect, choices=(
|
||||||
('normal', mark_safe('{} <span class="text-muted">{}</span>'.format(_('Normal category'), _('Products in this category are regular products displayed on the front page.'))),),
|
('normal', mark_safe('{} <span class="text-muted">{}</span>'.format(
|
||||||
('addon', mark_safe('{} <span class="text-muted">{}</span>'.format(_('Add-on product category'), _('Products in this category are add-on products and can only be bought as add-ons.'))),),
|
_('Normal category'), _('Products in this category are regular products displayed on the front page.'))),),
|
||||||
('only', mark_safe('{} <span class="text-muted">{}</span>'.format(_('Cross-selling category'), _('Products in this category are regular products, but are only shown in the cross-selling step, according to the configuration below.'))),),
|
('addon', mark_safe('{} <span class="text-muted">{}</span>'.format(
|
||||||
('both', mark_safe('{} <span class="text-muted">{}</span>'.format(_('Combined category'), _('Products in this category are regular products displayed on the front page, but are additionally shown in the cross-selling step, according to the configuration below.'))),),
|
_('Add-on product category'), _('Products in this category are add-on products and can only be bought as add-ons.'))),),
|
||||||
|
('only', mark_safe('{} <span class="text-muted">{}</span>'.format(
|
||||||
|
_('Cross-selling category'), _('Products in this category are regular products, but are only shown '
|
||||||
|
'in the cross-selling step, according to the configuration below.'))),),
|
||||||
|
('both', mark_safe('{} <span class="text-muted">{}</span>'.format(
|
||||||
|
_('Combined category'), _('Products in this category are regular products displayed on the front page, '
|
||||||
|
'but are additionally shown in the cross-selling step, according to the configuration below.'))),),
|
||||||
))
|
))
|
||||||
self.fields['category_type'].initial = 'addon' if self.instance.is_addon else (self.instance.cross_selling_mode or 'normal')
|
self.fields['category_type'].initial = 'addon' if self.instance.is_addon else (self.instance.cross_selling_mode or 'normal')
|
||||||
#self.fields['show_in_cross_selling'] = BooleanField(widget=CheckboxInput(attrs={
|
|
||||||
# 'data-display-dependency': '#id_category_type_0',
|
|
||||||
#}),
|
|
||||||
# help_text='Products are additionally shown in the cross-selling step, according to the configuration below')
|
|
||||||
self.fields['cross_selling_condition'].widget.attrs['data-display-dependency'] = '#id_category_type_2,#id_category_type_3'
|
self.fields['cross_selling_condition'].widget.attrs['data-display-dependency'] = '#id_category_type_2,#id_category_type_3'
|
||||||
self.fields['cross_selling_condition'].widget.attrs['data-disable-dependent'] = 'true'
|
self.fields['cross_selling_condition'].widget.attrs['data-disable-dependent'] = 'true'
|
||||||
self.fields['cross_selling_condition'].widget.choices = self.fields['cross_selling_condition'].widget.choices[1:]
|
self.fields['cross_selling_condition'].widget.choices = self.fields['cross_selling_condition'].widget.choices[1:]
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ from django.utils.translation import (
|
|||||||
from django.views.generic.base import TemplateResponseMixin
|
from django.views.generic.base import TemplateResponseMixin
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from pretix.base.decimal import round_decimal
|
|
||||||
from pretix.base.models import Customer, Membership, Order
|
from pretix.base.models import Customer, Membership, Order
|
||||||
from pretix.base.models.items import Question
|
from pretix.base.models.items import Question
|
||||||
from pretix.base.models.orders import (
|
from pretix.base.models.orders import (
|
||||||
@@ -94,7 +93,8 @@ from pretix.presale.views import (
|
|||||||
CartMixin, get_cart, get_cart_is_free, get_cart_total,
|
CartMixin, get_cart, get_cart_is_free, get_cart_total,
|
||||||
)
|
)
|
||||||
from pretix.presale.views.cart import (
|
from pretix.presale.views.cart import (
|
||||||
cart_session, create_empty_cart_id, get_or_create_cart_id, _items_from_post_data,
|
_items_from_post_data, cart_session, create_empty_cart_id,
|
||||||
|
get_or_create_cart_id,
|
||||||
)
|
)
|
||||||
from pretix.presale.views.event import get_grouped_items
|
from pretix.presale.views.event import get_grouped_items
|
||||||
from pretix.presale.views.questions import QuestionsViewMixin
|
from pretix.presale.views.questions import QuestionsViewMixin
|
||||||
@@ -491,8 +491,10 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
|||||||
if not hasattr(request, '_checkoutflow_addons_applicable'):
|
if not hasattr(request, '_checkoutflow_addons_applicable'):
|
||||||
cart = get_cart(request)
|
cart = get_cart(request)
|
||||||
self.request = request
|
self.request = request
|
||||||
request._checkoutflow_addons_applicable =('/addons/' in request.path_info or cart.filter(item__addons__isnull=False).exists()
|
request._checkoutflow_addons_applicable = (
|
||||||
or any(self.cross_selling_applicable_categories))
|
'/addons/' in request.path_info
|
||||||
|
or cart.filter(item__addons__isnull=False).exists()
|
||||||
|
or any(self.cross_selling_applicable_categories))
|
||||||
return request._checkoutflow_addons_applicable
|
return request._checkoutflow_addons_applicable
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
@@ -651,7 +653,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
|||||||
allow_cross_sell=True,
|
allow_cross_sell=True,
|
||||||
memberships=(
|
memberships=(
|
||||||
self.request.customer.usable_memberships(
|
self.request.customer.usable_memberships(
|
||||||
for_event=self.request.event, #p.subevent or self.request.event,
|
for_event=subevent or self.request.event,
|
||||||
testmode=self.request.event.testmode
|
testmode=self.request.event.testmode
|
||||||
)
|
)
|
||||||
if self.request.customer else None
|
if self.request.customer else None
|
||||||
@@ -659,7 +661,6 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
print("-> discount info: ",item, discount_info.get(item.pk))
|
|
||||||
if item.pk in discount_info:
|
if item.pk in discount_info:
|
||||||
(max_count, discount_rule) = discount_info[item.pk]
|
(max_count, discount_rule) = discount_info[item.pk]
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_r
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise CartError(_('Please enter numbers only.'))
|
raise CartError(_('Please enter numbers only.'))
|
||||||
|
|
||||||
|
|
||||||
def _items_from_post_data(request, warn_if_empty=True):
|
def _items_from_post_data(request, warn_if_empty=True):
|
||||||
"""
|
"""
|
||||||
Parses the POST data and returns a list of dictionaries
|
Parses the POST data and returns a list of dictionaries
|
||||||
@@ -247,7 +248,7 @@ def _items_from_post_data(request, warn_if_empty=True):
|
|||||||
for value in values:
|
for value in values:
|
||||||
try:
|
try:
|
||||||
item = _item_from_post_value(request, key, value, request.POST.get('_voucher_code'),
|
item = _item_from_post_value(request, key, value, request.POST.get('_voucher_code'),
|
||||||
voucher_ignore_if_redeemed=request.POST.get('_voucher_ignore_if_redeemed') == 'on')
|
voucher_ignore_if_redeemed=request.POST.get('_voucher_ignore_if_redeemed') == 'on')
|
||||||
except CartError as e:
|
except CartError as e:
|
||||||
messages.error(request, str(e))
|
messages.error(request, str(e))
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user