diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 2ca07c7305..6070f41d5e 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -20,7 +20,7 @@ Order events There are multiple signals that will be sent out in the ordering cycle: .. automodule:: pretix.base.signals - :members: validate_cart, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download + :members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download Frontend -------- diff --git a/doc/user/events/structureguide.rst b/doc/user/events/structureguide.rst index bb692c0f3a..5e28a28b37 100644 --- a/doc/user/events/structureguide.rst +++ b/doc/user/events/structureguide.rst @@ -85,8 +85,14 @@ Use case: Conference with workshops When running a conference, you might also organize a number of workshops with smaller capacity. To be able to plan, it would be great to know which workshops an attendee plans to attend. +Option A: Questions +""""""""""""""""""" + Your first and simplest option is to just create a multiple-choice question. This has the upside of making it easy for users to change their mind later on, but will not allow you to restrict the number of attendees signing up for a given workshop – or even charge extra for a given workshop. +Option B: Add-on products with fixed time slots +""""""""""""""""""""""""""""""""""""""""""""""" + The usually better option is to go with add-on products. Let's take for example the following conference schedule, in which the lecture can be attended by anyone, but the workshops only have space for 20 persons each: ==================== =================================== =================================== @@ -117,6 +123,28 @@ Assuming you already created one or more products for your general conference ad * One add-on configuration on your base product that allows users to choose between 0 and 2 products from the category "Workshops" +Option C: Add-on products with variable time slots +"""""""""""""""""""""""""""""""""""""""""""""""""" + +The above option only works if your conference uses fixed timeslots and every workshop uses exactly one timeslot. If +your schedule looks like this, it's not going to work great: + ++------------+------------+-----------+ +| Time | Room A | Room B | ++============+============+===========+ +| body row 1 | column 2 | column 3 | ++------------+------------+-----------+ +| body row 2 | Cells may span columns.| ++------------+------------+-----------+ +| body row 3 | Cells may | - Cells | ++------------+ span rows. | - contain | +| body row 4 | | - blocks. | ++------------+------------+-----------+ + +**This option is currently only available on pretix Hosted. If you are interested in using it with pretix Enterprise, + please contact sales@pretix.eu.** + + Use case: Discounted packages ----------------------------- diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 0167df425c..c4596850a0 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -26,6 +26,7 @@ from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.pricing import get_price from pretix.base.services.tasks import ProfiledEventTask from pretix.base.settings import PERSON_NAME_SCHEMES +from pretix.base.signals import validate_cart_addons from pretix.base.templatetags.rich_text import rich_text from pretix.celery_app import app from pretix.presale.signals import ( @@ -643,6 +644,15 @@ class CartManager: 'cat': str(iao.addon_category.name), } ) + validate_cart_addons.send( + sender=self.event, + addons={ + (self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None) + for s in selected + }, + base_position=cp, + iao=iao + ) # Detect removed add-ons and create RemoveOperations for cp, al in current_addons.items(): diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index a13bd706e4..4501d75604 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -265,6 +265,21 @@ appropriate exception message. As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ +validate_cart_addons = EventPluginSignal( + providing_args=["addons", "base_position", "iao"] +) +""" +This signal is sent when a user tries to select a combination of addons. In contrast to + ``validate_cart``, this is executed before the cart is actually modified. You are passed +an argument ``addons`` containing a set of ``(item, variation or None)`` tuples as well +as the ``ItemAddOn`` object as the argument ``iao`` and the base cart position as +``base_position``. +The response of receivers will be ignored, but you can raise a CartError with an +appropriate exception message. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + order_placed = EventPluginSignal( providing_args=["order"] ) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index d654041002..d920605db8 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -239,6 +239,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): 'form': AddOnsForm( event=self.request.event, prefix='{}_{}'.format(cartpos.pk, iao.addon_category.pk), + base_position=cartpos, iao=iao, price_included=iao.price_included, initial=current_addon_products, diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index 98a87b11d5..966b9db7f7 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -14,7 +14,8 @@ from pretix.base.forms.questions import ( ) from pretix.base.models import ItemVariation from pretix.base.models.tax import TAXED_ZERO -from pretix.base.services.cart import error_messages +from pretix.base.services.cart import CartError, error_messages +from pretix.base.signals import validate_cart_addons from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.rich_text import rich_text from pretix.base.validators import EmailBlacklistValidator @@ -205,13 +206,14 @@ class AddOnsForm(forms.Form): """ self.iao = kwargs.pop('iao') category = self.iao.addon_category - event = kwargs.pop('event') + self.event = kwargs.pop('event') subevent = kwargs.pop('subevent') current_addons = kwargs.pop('initial') quota_cache = kwargs.pop('quota_cache') item_cache = kwargs.pop('item_cache') self.price_included = kwargs.pop('price_included') self.sales_channel = kwargs.pop('sales_channel') + self.base_position = kwargs.pop('base_position') super().__init__(*args, **kwargs) @@ -231,12 +233,12 @@ class AddOnsForm(forms.Form): ).select_related('tax_rule').prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', - queryset=event.quotas.filter(subevent=subevent)), + queryset=self.event.quotas.filter(subevent=subevent)), Prefetch('variations', to_attr='available_variations', queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', - queryset=event.quotas.filter(subevent=subevent)) + queryset=self.event.quotas.filter(subevent=subevent)) ).distinct()), 'event' ).annotate( @@ -260,7 +262,7 @@ class AddOnsForm(forms.Form): self.vars_cache[v.pk] = v choices.append( (v.pk, - self._label(event, v, cached_availability, + self._label(self.event, v, cached_availability, override_price=var_price_override.get(v.pk)), v.description) ) @@ -294,7 +296,7 @@ class AddOnsForm(forms.Form): continue cached_availability = i.check_quotas(subevent=subevent, _cache=quota_cache) field = forms.BooleanField( - label=self._label(event, i, cached_availability, + label=self._label(self.event, i, cached_availability, override_price=item_price_override.get(i.pk)), required=False, initial=i.pk in current_addons, @@ -333,3 +335,8 @@ class AddOnsForm(forms.Form): 'cat': str(self.iao.addon_category.name), } ) + try: + validate_cart_addons.send(sender=self.event, addons=selected, base_position=self.base_position, + iao=self.iao) + except CartError as e: + raise ValidationError(str(e))