implement cross_selling_visibility "always" and "products"

This commit is contained in:
Mira Weller
2024-05-28 18:50:08 +02:00
parent 2cd5d87da4
commit a0d865cf4f
8 changed files with 197 additions and 116 deletions

View File

@@ -441,7 +441,11 @@ class ItemCategorySerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = ItemCategory model = ItemCategory
fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon', 'cross_selling_mode', 'cross_selling_condition') fields = (
'id', 'name', 'internal_name', 'description', 'position',
'is_addon', 'cross_selling_mode',
'cross_selling_condition', 'cross_selling_match_products'
)
class QuestionOptionSerializer(I18nAwareModelSerializer): class QuestionOptionSerializer(I18nAwareModelSerializer):

View File

@@ -142,6 +142,18 @@ class ItemCategory(LoggedModel):
verbose_name_plural = _("Product categories") verbose_name_plural = _("Product categories")
ordering = ('position', 'id') ordering = ('position', 'id')
def cross_sell_visible(self, cart_positions):
if self.cross_selling_mode is None:
return False
if self.cross_selling_condition == 'always':
return True
if self.cross_selling_condition == 'products':
match = set(match.pk for match in self.cross_selling_match_products.only('pk')) # TODO prefetch this
return any(pos.item.pk in match for pos in cart_positions)
if self.cross_selling_condition == 'discounts':
# TODO not sure how to do this yet
return False
def __str__(self): def __str__(self):
name = self.internal_name or self.name name = self.internal_name or self.name
if self.is_addon: if self.is_addon:
@@ -295,7 +307,7 @@ class SubEventItemVariation(models.Model):
return True return True
def filter_available(qs, channel='web', voucher=None, allow_addons=False): def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
# Channel can currently be a SalesChannel or a str, since we need that compatibility, but a SalesChannel # Channel can currently be a SalesChannel or a str, since we need that compatibility, but a SalesChannel
# makes the query SIGNIFICANTLY faster # makes the query SIGNIFICANTLY faster
from .organizer import SalesChannel from .organizer import SalesChannel
@@ -316,6 +328,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
if not allow_addons: if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False)) q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
if not allow_cross_sell:
q &= Q(Q(category__isnull=True) | ~Q(category__cross_selling_mode='only'))
if voucher: if voucher:
if voucher.item_id: if voucher.item_id:
@@ -329,8 +343,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
class ItemQuerySet(models.QuerySet): class ItemQuerySet(models.QuerySet):
def filter_available(self, channel='web', voucher=None, allow_addons=False): def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
return filter_available(self, channel, voucher, allow_addons) return filter_available(self, channel, voucher, allow_addons, allow_cross_sell)
class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__): class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__):
@@ -338,8 +352,8 @@ class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__)
super().__init__() super().__init__()
self._queryset_class = ItemQuerySet self._queryset_class = ItemQuerySet
def filter_available(self, channel='web', voucher=None, allow_addons=False): def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
return filter_available(self.get_queryset(), channel, voucher, allow_addons) return filter_available(self.get_queryset(), channel, voucher, allow_addons, allow_cross_sell)
class Item(LoggedModel): class Item(LoggedModel):

View File

@@ -1610,7 +1610,7 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en', def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None: invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
""" """
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to
@@ -1635,6 +1635,7 @@ def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, l
try: try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
cm.set_addons(addons) cm.set_addons(addons)
cm.add_new_items(add_to_cart_items)
cm.commit() cm.commit()
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()

View File

@@ -40,7 +40,8 @@ from urllib.parse import urlencode
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Max from django.core.files.uploadedfile import UploadedFile
from django.db.models import Max, Q
from django.forms import ChoiceField, RadioSelect from django.forms import ChoiceField, RadioSelect
from django.forms.formsets import DELETION_FIELD_NAME from django.forms.formsets import DELETION_FIELD_NAME
from django.urls import reverse from django.urls import reverse
@@ -121,7 +122,12 @@ class CategoryForm(I18nModelForm):
'data-display-dependency': '#id_cross_selling_condition_1' 'data-display-dependency': '#id_cross_selling_condition_1'
} }
) )
self.fields['cross_selling_match_products'].queryset = self.event.items.all() self.fields['cross_selling_match_products'].queryset = self.event.items.filter(
# don't show products which are only visible in addon/cross-sell step themselves
Q(category__isnull=True) | Q(
Q(category__is_addon=False) & Q(Q(category__cross_selling_mode='both') | Q(category__cross_selling_mode__isnull=True))
)
)
def clean(self): def clean(self):
d = super().clean() d = super().clean()

View File

@@ -93,7 +93,7 @@ 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, cart_session, create_empty_cart_id, get_or_create_cart_id, _items_from_post_data,
) )
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
@@ -488,9 +488,17 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
def is_applicable(self, request): def is_applicable(self, request):
if not hasattr(request, '_checkoutflow_addons_applicable'): if not hasattr(request, '_checkoutflow_addons_applicable'):
request._checkoutflow_addons_applicable = get_cart(request).filter(item__addons__isnull=False).exists() cart = get_cart(request)
self.request = request
request._checkoutflow_addons_applicable = (cart.filter(item__addons__isnull=False).exists()
or any(self.cross_selling_applicable_rules))
return request._checkoutflow_addons_applicable return request._checkoutflow_addons_applicable
@cached_property
def cross_selling_applicable_rules(self):
cart = self.get_cart(self.request)
return [c for c in self.request.event.categories.filter(cross_selling_mode__isnull=False) if c.cross_sell_visible(cart['positions'])]
def is_completed(self, request, warn=False): def is_completed(self, request, warn=False):
if getattr(self, '_completed', None) is not None: if getattr(self, '_completed', None) is not None:
return self._completed return self._completed
@@ -605,9 +613,48 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
formset.append(formsetentry) formset.append(formsetentry)
return formset return formset
def get_cross_selling_data(self, ctx):
class DummyCategory:
def __init__(self, rule, subevent=None):
self.id = rule.id
self.name = rule.name + (f" ({subevent})" if subevent else "")
self.description = rule.description
if self.event.has_subevents:
return [
(DummyCategory(rule, subevent), self._items_from_rule(rule, subevent), f'subevent_{subevent.pk}_')
for rule in self.cross_selling_applicable_rules
for subevent in set(pos.subevent for pos in ctx['cart']['positions'])
]
else:
return [
(rule, self._items_from_rule(rule, None))
for rule in self.cross_selling_applicable_rules
]
def _items_from_rule(self, rule, subevent):
items, _btn = get_grouped_items(
self.request.event,
subevent=subevent,
voucher=None,
channel=self.request.sales_channel.identifier,
base_qs=rule.items.all(),
allow_addons=True,
allow_cross_sell=True,
memberships=(
self.request.customer.usable_memberships(
for_event=self.request.event, #p.subevent or self.request.event,
testmode=self.request.event.testmode
)
if self.request.customer else None
),
)
return items
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['forms'] = self.forms ctx['forms'] = self.forms
ctx['cross_selling_data'] = self.get_cross_selling_data(ctx)
ctx['cart'] = self.get_cart() ctx['cart'] = self.get_cart()
return ctx return ctx
@@ -687,7 +734,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.request = request self.request = request
data = [] addons = []
for f in self.forms: for f in self.forms:
for c in f['categories']: for c in f['categories']:
try: try:
@@ -697,7 +744,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
return self.get(request, *args, **kwargs) return self.get(request, *args, **kwargs)
for (i, v), (c, price) in selected.items(): for (i, v), (c, price) in selected.items():
data.append({ addons.append({
'addon_to': f['pos'].pk, 'addon_to': f['pos'].pk,
'item': i.pk, 'item': i.pk,
'variation': v.pk if v else None, 'variation': v.pk if v else None,
@@ -705,7 +752,9 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
'price': price, 'price': price,
}) })
return self.do(self.request.event.id, data, get_or_create_cart_id(self.request), add_to_cart_items = _items_from_post_data(self.request)
return self.do(self.request.event.id, addons, add_to_cart_items, get_or_create_cart_id(self.request),
invoice_address=self.invoice_address.pk, locale=get_language(), invoice_address=self.invoice_address.pk, locale=get_language(),
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None)) sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))

View File

@@ -46,6 +46,9 @@
</details> </details>
{% endfor %} {% endfor %}
</div> </div>
{% include "pretixpresale/event/fragment_product_list.html" with items_by_category=cross_selling_data ev=event %}
<div class="row checkout-button-row"> <div class="row checkout-button-row">
<div class="col-md-4 col-sm-6"> <div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg" <a class="btn btn-block btn-default btn-lg"

View File

@@ -151,112 +151,113 @@ class CartActionMixin:
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
return InvoiceAddress() return InvoiceAddress()
def _item_from_post_value(self, key, value, voucher=None, voucher_ignore_if_redeemed=False):
if value.strip() == '' or '_' not in key:
return
subevent = None def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_redeemed=False):
if key.startswith('subevent_'): if value.strip() == '' or '_' not in key:
try: return
parts = key.split('_', 2)
subevent = int(parts[1])
key = parts[2]
except ValueError:
pass
elif 'subevent' in self.request.POST:
try:
subevent = int(self.request.POST.get('subevent'))
except ValueError:
pass
if not key.startswith('item_') and not key.startswith('variation_') and not key.startswith('seat_'):
return
parts = key.split("_")
price = self.request.POST.get('price_' + "_".join(parts[1:]), "")
if key.startswith('seat_'):
try:
return {
'item': int(parts[1]),
'variation': int(parts[2]) if len(parts) > 2 else None,
'count': 1,
'seat': value,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
subevent = None
if key.startswith('subevent_'):
try: try:
amount = int(value) parts = key.split('_', 2)
subevent = int(parts[1])
key = parts[2]
except ValueError:
pass
elif 'subevent' in request.POST:
try:
subevent = int(request.POST.get('subevent'))
except ValueError: except ValueError:
raise CartError(_('Please enter numbers only.'))
if amount < 0:
raise CartError(_('Please enter positive numbers only.'))
elif amount == 0:
return
if key.startswith('item_'):
try:
return {
'item': int(parts[1]),
'variation': None,
'count': amount,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
elif key.startswith('variation_'):
try:
return {
'item': int(parts[1]),
'variation': int(parts[2]),
'count': amount,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
def _items_from_post_data(self):
"""
Parses the POST data and returns a list of dictionaries
"""
# Compatibility patch that makes the frontend code a lot easier
req_items = list(self.request.POST.lists())
if '_voucher_item' in self.request.POST and '_voucher_code' in self.request.POST:
req_items.append((
'%s' % self.request.POST['_voucher_item'], ('1',)
))
pass pass
items = [] if not key.startswith('item_') and not key.startswith('variation_') and not key.startswith('seat_'):
if 'raw' in self.request.POST: return
items += json.loads(self.request.POST.get("raw"))
for key, values in req_items:
for value in values:
try:
item = self._item_from_post_value(key, value, self.request.POST.get('_voucher_code'),
voucher_ignore_if_redeemed=self.request.POST.get('_voucher_ignore_if_redeemed') == 'on')
except CartError as e:
messages.error(self.request, str(e))
return
if item:
items.append(item)
if len(items) == 0: parts = key.split("_")
messages.warning(self.request, _('You did not select any products.')) price = request.POST.get('price_' + "_".join(parts[1:]), "")
return []
return items if key.startswith('seat_'):
try:
return {
'item': int(parts[1]),
'variation': int(parts[2]) if len(parts) > 2 else None,
'count': 1,
'seat': value,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
try:
amount = int(value)
except ValueError:
raise CartError(_('Please enter numbers only.'))
if amount < 0:
raise CartError(_('Please enter positive numbers only.'))
elif amount == 0:
return
if key.startswith('item_'):
try:
return {
'item': int(parts[1]),
'variation': None,
'count': amount,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
elif key.startswith('variation_'):
try:
return {
'item': int(parts[1]),
'variation': int(parts[2]),
'count': amount,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
def _items_from_post_data(request):
"""
Parses the POST data and returns a list of dictionaries
"""
# Compatibility patch that makes the frontend code a lot easier
req_items = list(request.POST.lists())
if '_voucher_item' in request.POST and '_voucher_code' in request.POST:
req_items.append((
'%s' % request.POST['_voucher_item'], ('1',)
))
pass
items = []
if 'raw' in request.POST:
items += json.loads(request.POST.get("raw"))
for key, values in req_items:
for value in values:
try:
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')
except CartError as e:
messages.error(request, str(e))
return
if item:
items.append(item)
if len(items) == 0:
messages.warning(request, _('You did not select any products.'))
return []
return items
@scopes_disabled() @scopes_disabled()
@@ -542,7 +543,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
cs = cart_session(request) cs = cart_session(request)
widget_data = cs.get('widget_data', {}) widget_data = cs.get('widget_data', {})
items = self._items_from_post_data() items = _items_from_post_data(self.request)
if items: if items:
return self.do(self.request.event.id, items, cart_id, translation.get_language(), return self.do(self.request.event.id, items, cart_id, translation.get_language(),
self.invoice_address.pk, widget_data, self.request.sales_channel.identifier, self.invoice_address.pk, widget_data, self.request.sales_channel.identifier,

View File

@@ -109,7 +109,8 @@ def item_group_by_category(items):
) )
def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None, allow_addons=False, def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None,
allow_addons=False, allow_cross_sell=False,
quota_cache=None, filter_items=None, filter_categories=None, memberships=None, quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
ignore_hide_sold_out_for_item_ids=None): ignore_hide_sold_out_for_item_ids=None):
base_qs_set = base_qs is not None base_qs_set = base_qs is not None
@@ -191,7 +192,9 @@ def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=No
) )
) )
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(channel=channel.identifier, voucher=voucher, allow_addons=allow_addons).select_related( items = base_qs.using(settings.DATABASE_REPLICA).filter_available(
channel=channel.identifier, voucher=voucher, allow_addons=allow_addons, allow_cross_sell=allow_cross_sell
).select_related(
'category', 'tax_rule', # for re-grouping 'category', 'tax_rule', # for re-grouping
'hidden_if_available', 'hidden_if_available',
).prefetch_related( ).prefetch_related(