From b52f2f5a9e48a18dbbda0a8c981fdafc28d19b35 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sun, 19 Mar 2017 14:33:45 +0100 Subject: [PATCH] Improve add-on products --- src/pretix/base/exporters/json.py | 1 + ...318_1748.py => 0056_auto_20170414_1044.py} | 21 +- src/pretix/base/models/orders.py | 10 +- src/pretix/base/services/cart.py | 31 +- src/pretix/base/services/invoices.py | 2 + src/pretix/base/services/orders.py | 12 + src/pretix/base/settings.py | 4 + src/pretix/base/ticketoutput.py | 5 + src/pretix/control/forms/event.py | 7 +- src/pretix/control/forms/item.py | 4 + src/pretix/control/forms/vouchers.py | 3 + .../pretixcontrol/event/tickets.html | 1 + .../templates/pretixcontrol/order/change.html | 12 + .../templates/pretixcontrol/order/index.html | 6 +- src/pretix/plugins/checkinlists/exporters.py | 9 +- src/pretix/plugins/pretixdroid/views.py | 6 +- .../plugins/ticketoutputpdf/ticketoutput.py | 2 + src/pretix/presale/checkoutflow.py | 13 +- src/pretix/presale/forms/checkout.py | 80 ++-- .../pretixpresale/event/checkout_addons.html | 4 + .../event/checkout_questions.html | 20 +- .../pretixpresale/event/fragment_cart.html | 44 +- .../pretixpresale/event/order_modify.html | 23 +- src/pretix/presale/urls.py | 2 +- src/pretix/presale/views/__init__.py | 28 +- src/pretix/presale/views/cart.py | 1 + src/pretix/presale/views/event.py | 1 + src/pretix/presale/views/order.py | 48 ++- src/pretix/presale/views/questions.py | 25 +- .../static/pretixbase/js/asyncdownload.js | 5 +- .../static/pretixcontrol/scss/main.scss | 7 +- .../static/pretixpresale/scss/_cart.scss | 7 +- src/tests/control/test_items.py | 44 ++ src/tests/presale/test_cart.py | 375 +++++++++++++++++- src/tests/presale/test_checkout.py | 57 +++ src/tests/presale/test_checkoutflow.py | 13 +- 36 files changed, 802 insertions(+), 131 deletions(-) rename src/pretix/base/migrations/{0052_auto_20170318_1748.py => 0056_auto_20170414_1044.py} (65%) diff --git a/src/pretix/base/exporters/json.py b/src/pretix/base/exporters/json.py index 168a3e331..066cc9a45 100644 --- a/src/pretix/base/exporters/json.py +++ b/src/pretix/base/exporters/json.py @@ -70,6 +70,7 @@ class JSONExporter(BaseExporter): 'attendee_name': position.attendee_name, 'attendee_email': position.attendee_email, 'secret': position.secret, + 'addon_to': position.addon_to_id, 'answers': [ { 'question': answer.question_id, diff --git a/src/pretix/base/migrations/0052_auto_20170318_1748.py b/src/pretix/base/migrations/0056_auto_20170414_1044.py similarity index 65% rename from src/pretix/base/migrations/0052_auto_20170318_1748.py rename to src/pretix/base/migrations/0056_auto_20170414_1044.py index c4b127d54..a3e7b5a9b 100644 --- a/src/pretix/base/migrations/0052_auto_20170318_1748.py +++ b/src/pretix/base/migrations/0056_auto_20170414_1044.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.6 on 2017-03-18 17:48 +# Generated by Django 1.10.7 on 2017-04-14 10:44 from __future__ import unicode_literals import django.db.models.deletion @@ -9,7 +9,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('pretixbase', '0051_auto_20170206_2027'), + ('pretixbase', '0055_auto_20170413_1537'), ] operations = [ @@ -24,7 +24,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='cartposition', name='addon_to', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CartPosition'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.CartPosition'), ), migrations.AddField( model_name='itemcategory', @@ -34,17 +34,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='orderposition', name='addon_to', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.OrderPosition'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.OrderPosition'), ), migrations.AlterField( model_name='item', - name='allow_cancel', - field=models.BooleanField(default=True, help_text='If this is active and the general event settings allo wit, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting', verbose_name='Allow product to be canceled'), - ), - migrations.AlterField( - model_name='item', - name='default_price', - field=models.DecimalField(decimal_places=2, help_text='If this product has multiple variations, you can set different prices for each of the variations. If a variation does not have a special price or if you do not have variations, this price will be used.', max_digits=7, null=True, verbose_name='Default price'), + name='free_price', + field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event. This is currently not supported for products that are bought as an add-on to other products.', verbose_name='Free price input'), ), migrations.AddField( model_name='itemaddon', @@ -56,4 +51,8 @@ class Migration(migrations.Migration): name='base_item', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'), ), + migrations.AlterUniqueTogether( + name='itemaddon', + unique_together=set([('base_item', 'addon_category')]), + ), ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 435319483..cc58ce5d8 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -496,13 +496,19 @@ class OrderPosition(AbstractPosition): from . import Voucher ops = [] - for i, cartpos in enumerate(cp): + cp_mapping = {} + # The sorting key ensures that all addons come directly after the position they refer to + for i, cartpos in enumerate(sorted(cp, key=lambda c: (c.addon_to_id or c.pk, c.addon_to_id or 0))): op = OrderPosition(order=order) for f in AbstractPosition._meta.fields: - setattr(op, f.name, getattr(cartpos, f.name)) + if f.name == 'addon_to': + setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id)) + else: + setattr(op, f.name, getattr(cartpos, f.name)) op._calculate_tax() op.positionid = i + 1 op.save() + cp_mapping[cartpos.pk] = op for answ in cartpos.answers.all(): answ.orderposition = op answ.cartposition = None diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 67a831694..03ff311de 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -54,6 +54,7 @@ error_messages = { 'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'), '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.'), } @@ -127,9 +128,10 @@ class CartManager: ) def _check_max_cart_size(self): - cartsize = self.positions.count() - cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation)]) - cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation)]) + cartsize = self.positions.filter(addon_to__isnull=True).count() + cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to]) + cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if + not op.position.addon_to_id]) if cartsize > int(self.event.settings.max_items_per_order): # TODO: i18n plurals raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,)) @@ -149,6 +151,9 @@ class CartManager: raise CartError(error_messages['voucher_invalid_item']) if isinstance(op, self.AddOperation): + if op.item.category and op.item.category.is_addon and not op.addon_to: + raise CartError(error_messages['addon_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]) + @@ -297,19 +302,21 @@ class CartManager: [a['variation'] for a in addons], ) - current_addons = defaultdict(dict) - input_addons = defaultdict(set) - selected_addons = defaultdict(set) - cpcache = {} - quota_diff = Counter() + # Prepare various containers to hold data later + current_addons = defaultdict(dict) # CartPos -> currently attached add-ons + input_addons = defaultdict(set) # CartPos -> add-ons according to input + selected_addons = defaultdict(set) # CartPos -> final desired set of add-ons + cpcache = {} # CartPos.pk -> CartPos + quota_diff = Counter() # Quota -> Number of usages operations = [] - available_categories = defaultdict(set) + available_categories = defaultdict(set) # CartPos -> Category IDs to choose from toplevel_cp = self.positions.filter( addon_to__isnull=True ).prefetch_related( 'addons', 'item__addons', 'item__addons__addon_category' ).select_related('item', 'variation') + # Prefill some of the cache containers for cp in toplevel_cp: available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()} cpcache[cp.pk] = cp @@ -318,6 +325,7 @@ class CartManager: for a in cp.addons.all() } + # Create operations, perform various checks for a in addons: # Check whether the specified items are part of what we just fetched from the database # If they are not, the user supplied item IDs which either do not exist or belong to @@ -325,6 +333,7 @@ class CartManager: if a['item'] not in self._items_cache or (a['variation'] and a['variation'] not in self._variations_cache): raise CartError(error_messages['not_for_sale']) + # Only attach addons to things that are actually in this user's cart if a['addon_to'] not in cpcache: raise CartError(error_messages['addon_invalid_base']) @@ -340,6 +349,7 @@ class CartManager: if not quotas: raise CartError(error_messages['unavailable']) + # Every item can be attached to very CartPosition at most once if a['item'] in ([_a[0] for _a in input_addons[cp.id]]): raise CartError(error_messages['addon_duplicate_item']) @@ -347,6 +357,7 @@ class CartManager: selected_addons[cp.id, item.category_id].add((a['item'], a['variation'])) if (a['item'], a['variation']) not in current_addons[cp]: + # This add-on is new, add it to the cart for quota in quotas: quota_diff[quota] += 1 @@ -359,6 +370,7 @@ class CartManager: self._check_item_constraints(op) operations.append(op) + # Check constraints on the add-on combinations for cp in toplevel_cp: item = cp.item for iao in item.addons.all(): @@ -386,6 +398,7 @@ class CartManager: } ) + # Detect removed add-ons and create RemoveOperations for cp, al in current_addons.items(): for k, v in al.items(): if k not in input_addons[cp.id]: diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index e17fc42ae..5e9007b44 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -72,6 +72,8 @@ def build_invoice(invoice: Invoice) -> Invoice: desc = str(p.item.name) if p.variation: desc += " - " + str(p.variation.value) + if p.addon_to_id: + desc = " + " + desc InvoiceLine.objects.create( invoice=invoice, description=desc, gross_value=p.price, tax_value=p.tax_value, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 035114019..5095630e2 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -562,6 +562,7 @@ class OrderChangeManager: 'new_item': op.item.pk, 'new_variation': op.variation.pk if op.variation else None, 'old_price': op.position.price, + 'addon_to': op.position.addon_to_id, 'new_price': op.price }) op.position.item = op.item @@ -574,18 +575,29 @@ class OrderChangeManager: 'position': op.position.pk, 'positionid': op.position.positionid, 'old_price': op.position.price, + 'addon_to': op.position.addon_to_id, 'new_price': op.price }) op.position.price = op.price op.position._calculate_tax() op.position.save() elif isinstance(op, self.CancelOperation): + for opa in op.position.addons.all(): + self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={ + 'position': opa.pk, + 'positionid': opa.positionid, + 'old_item': opa.item.pk, + 'old_variation': opa.variation.pk if opa.variation else None, + 'addon_to': opa.addon_to_id, + 'old_price': opa.price, + }) self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={ 'position': op.position.pk, 'positionid': op.position.positionid, 'old_item': op.position.item.pk, 'old_variation': op.position.variation.pk if op.position.variation else None, 'old_price': op.position.price, + 'addon_to': None, }) op.position.delete() diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 60f8f2447..f54ec52bc 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -159,6 +159,10 @@ DEFAULTS = { 'default': None, 'type': datetime }, + 'ticket_download_addons': { + 'default': 'False', + 'type': bool + }, 'last_order_modification_date': { 'default': None, 'type': datetime diff --git a/src/pretix/base/ticketoutput.py b/src/pretix/base/ticketoutput.py index fa605bf1b..b16ed5aee 100644 --- a/src/pretix/base/ticketoutput.py +++ b/src/pretix/base/ticketoutput.py @@ -51,10 +51,15 @@ class BaseTicketOutput: This method should generate a download file and return a tuple consisting of a filename, a file type and file content. The extension will be taken from the filename which is otherwise ignored. + + If you override this method, make sure that positions that are addons (i.e. ``addon_to`` + is set) are only outputted if the event setting ``ticket_download_addons`` is active. """ with tempfile.TemporaryDirectory() as d: with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf: for pos in order.positions.all(): + if pos.addon_to_id and not self.event.settings.ticket_download_addons: + continue fname, __, content = self.generate(pos) zipf.writestr('{}-{}{}'.format( order.code, pos.positionid, os.path.splitext(fname)[1] diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index e69bfb140..d6e349734 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -257,7 +257,8 @@ class EventSettingsForm(SettingsForm): ) max_items_per_order = forms.IntegerField( min_value=1, - label=_("Maximum number of items per order") + label=_("Maximum number of items per order"), + help_text=_("Add-on products will not be counted.") ) reservation_time = forms.IntegerField( min_value=0, @@ -607,6 +608,10 @@ class TicketSettingsForm(SettingsForm): required=True, widget=forms.DateTimeInput(attrs={'class': 'datetimepicker'}) ) + ticket_download_addons = forms.BooleanField( + label=_("Offer to download tickets separately for add-on products"), + required=False + ) def prepare_fields(self): # See clean() diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 323cc5f26..70abbb315 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -265,3 +265,7 @@ class ItemAddOnForm(I18nModelForm): 'min_count', 'max_count', ] + help_texts = { + 'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all ' + 'available add-ons are sold out.') + } diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index 453773706..f1a8dacdf 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -76,6 +76,9 @@ class VoucherForm(I18nModelForm): else: self.instance.variation = None self.instance.quota = None + + if self.instance.item.category and self.instance.item.category.is_addon: + raise ValidationError(_('It is currently not possible to create vouchers for add-on products.')) else: self.instance.quota = Quota.objects.get(pk=quotaid, event=self.instance.event) self.instance.item = None diff --git a/src/pretix/control/templates/pretixcontrol/event/tickets.html b/src/pretix/control/templates/pretixcontrol/event/tickets.html index 690bdd10a..206e6d6db 100644 --- a/src/pretix/control/templates/pretixcontrol/event/tickets.html +++ b/src/pretix/control/templates/pretixcontrol/event/tickets.html @@ -9,6 +9,7 @@ {% bootstrap_form_errors form %} {% bootstrap_field form.ticket_download layout="horizontal" %} {% bootstrap_field form.ticket_download_date layout="horizontal" %} + {% bootstrap_field form.ticket_download_addons layout="horizontal" %} {% for provider in providers %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index b67cc104e..9e288f391 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -48,6 +48,13 @@ {% if position.variation %} – {{ position.variation }} {% endif %} + {% if position.addon_to %} + – + {% blocktrans trimmed with posid=position.addon_to.positionid %} + Add-On to position #{{ posid }} + {% endblocktrans %} + + {% endif %}
@@ -89,6 +96,11 @@ {% trans "Remove from order" %} + {% if position.addons.exists %} + + {% trans "Removing this position will also remove all add-ons to this position." %} + + {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 0f37a1e3e..098f69d00 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -166,7 +166,11 @@ {% for line in items.positions %}
- #{{ line.positionid }} – + {% if line.addon_to %} + + + {% else %} + #{{ line.positionid }} – + {% endif %} {{ line.item.name }} {% if line.variation %} – {{ line.variation }} diff --git a/src/pretix/plugins/checkinlists/exporters.py b/src/pretix/plugins/checkinlists/exporters.py index e428f1085..304a591c1 100644 --- a/src/pretix/plugins/checkinlists/exporters.py +++ b/src/pretix/plugins/checkinlists/exporters.py @@ -3,6 +3,7 @@ import io from collections import OrderedDict from django import forms +from django.db.models.functions import Coalesce from django.utils.translation import ugettext as _, ugettext_lazy from pretix.base.exporter import BaseExporter @@ -70,10 +71,10 @@ class CSVCheckinList(BaseCheckinList): order__event=self.event, item_id__in=form_data['items'] ).prefetch_related( 'answers', 'answers__question' - ).select_related('order', 'item', 'variation') + ).select_related('order', 'item', 'variation', 'addon_to') if form_data['sort'] == 'name': - qs = qs.order_by('attendee_name') + qs = qs.order_by(Coalesce('attendee_name', 'addon_to__attendee_name')) elif form_data['sort'] == 'code': qs = qs.order_by('order__code') @@ -100,7 +101,7 @@ class CSVCheckinList(BaseCheckinList): for op in qs: row = [ op.order.code, - op.attendee_name, + op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''), str(op.item.name) + (" – " + str(op.variation.value) if op.variation else ""), op.price, ] @@ -109,7 +110,7 @@ class CSVCheckinList(BaseCheckinList): if form_data['secrets']: row.append(op.secret) if self.event.settings.attendee_emails_asked: - row.append(op.attendee_email) + row.append(op.attendee_email or (op.addon_to.attendee_name if op.addon_to else '')) acache = {} for a in op.answers.all(): acache[a.question_id] = str(a) diff --git a/src/pretix/plugins/pretixdroid/views.py b/src/pretix/plugins/pretixdroid/views.py index 8d398f823..ac5ec0a47 100644 --- a/src/pretix/plugins/pretixdroid/views.py +++ b/src/pretix/plugins/pretixdroid/views.py @@ -74,7 +74,7 @@ class ApiRedeemView(ApiView): try: with transaction.atomic(): created = False - op = OrderPosition.objects.select_related('item', 'variation', 'order').get( + op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get( order__event=self.event, secret=secret ) if op.order.status == Order.STATUS_PAID: @@ -105,7 +105,7 @@ class ApiRedeemView(ApiView): 'order': op.order.code, 'item': str(op.item), 'variation': str(op.variation) if op.variation else None, - 'attendee_name': op.attendee_name + 'attendee_name': op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''), } except OrderPosition.DoesNotExist: @@ -136,7 +136,7 @@ class ApiSearchView(ApiView): 'order': op.order.code, 'item': str(op.item), 'variation': str(op.variation) if op.variation else None, - 'attendee_name': op.attendee_name, + 'attendee_name': op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''), 'redeemed': bool(op.checkins.all()), 'paid': op.order.status == Order.STATUS_PAID, } for op in ops diff --git a/src/pretix/plugins/ticketoutputpdf/ticketoutput.py b/src/pretix/plugins/ticketoutputpdf/ticketoutput.py index ebe19e066..9028563a7 100644 --- a/src/pretix/plugins/ticketoutputpdf/ticketoutput.py +++ b/src/pretix/plugins/ticketoutputpdf/ticketoutput.py @@ -91,6 +91,8 @@ class PdfTicketOutput(BaseTicketOutput): buffer = BytesIO() p = self._create_canvas(buffer) for op in order.positions.all(): + if op.addon_to_id and not self.event.settings.ticket_download_addons: + continue self._draw_page(p, op, order) p.save() outbuffer = self._render_with_background(buffer) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 416a580c0..4fb46f231 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -153,11 +153,12 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): @cached_property def forms(self): """ - A list of forms with one form for each cart position that has questions - the user can answer. All forms have a custom prefix, so that they can all be - submitted at once. + A list of forms with one form for each cart position that can have add-ons. + All forms have a custom prefix, so that they can all be submitted at once. """ formset = [] + quota_cache = {} + item_cache = {} for cartpos in get_cart(self.request).filter(addon_to__isnull=True).prefetch_related( 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation' ): @@ -180,7 +181,9 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): prefix='{}_{}'.format(cartpos.pk, iao.addon_category.pk), category=iao.addon_category, initial=current_addon_products, - data=(self.request.POST if self.request.method == 'POST' else None) + data=(self.request.POST if self.request.method == 'POST' else None), + quota_cache=quota_cache, + item_cache=item_cache ) } @@ -324,7 +327,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['forms'] = self.forms + ctx['formgroups'] = self.formdict.items() ctx['contact_form'] = self.contact_form ctx['invoice_form'] = self.invoice_form return ctx diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index 33db2edd2..9e9b96416 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -59,8 +59,8 @@ class QuestionsForm(forms.Form): :param cartpos: The cart position the form should be for :param event: The event this belongs to """ - cartpos = kwargs.pop('cartpos', None) - orderpos = kwargs.pop('orderpos', None) + cartpos = self.cartpos = kwargs.pop('cartpos', None) + orderpos = self.orderpos = kwargs.pop('orderpos', None) item = cartpos.item if cartpos else orderpos.item questions = list(item.questions.all()) event = kwargs.pop('event') @@ -151,7 +151,7 @@ class AddOnsForm(forms.Form): This form class is responsible for selecting add-ons to a product in the cart. """ - def _label(self, event, item_or_variation): + def _label(self, event, item_or_variation, avail): if isinstance(item_or_variation, ItemVariation): variation = item_or_variation item = item_or_variation.item @@ -165,69 +165,89 @@ class AddOnsForm(forms.Form): label = item.name if not item.tax_rate or not price: - return '{name} (+ {currency} {price})'.format( + n = '{name} (+ {currency} {price})'.format( name=label, currency=event.currency, price=number_format(price) ) elif event.settings.display_net_prices: - return '{name} (+ {currency} {price} plus {taxes}% taxes)'.format( + n = '{name} (+ {currency} {price} plus {taxes}% taxes)'.format( name=label, currency=event.currency, price=number_format(price_net), taxes=number_format(item.tax_rate) ) else: - return '{name} (+ {currency} {price} incl. {taxes}% taxes)'.format( + n = '{name} (+ {currency} {price} incl. {taxes}% taxes)'.format( name=label, currency=event.currency, price=number_format(price), taxes=number_format(item.tax_rate) ) + if avail[0] < 20: + n += ' – {}'.format(_('SOLD OUT')) + elif avail[0] < 100: + n += ' – {}'.format(_('Currently unavailable')) + + return n + def __init__(self, *args, **kwargs): """ Takes additional keyword arguments: :param category: The category to choose from :param event: The event this belongs to + :param initial: The current set of add-ons + :param quota_cache: A shared dictionary for quota caching + :param item_cache: A shared dictionary for item/category caching """ category = kwargs.pop('category') event = kwargs.pop('event') current_addons = kwargs.pop('initial') + quota_cache = kwargs.pop('quota_cache') + item_cache = kwargs.pop('item_cache') super().__init__(*args, **kwargs) - items = category.items.filter( - 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(hide_without_voucher=False) - ).prefetch_related( - 'variations__quotas', # for .availability() - Prefetch('quotas', queryset=event.quotas.all()), - Prefetch('variations', to_attr='available_variations', - queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).distinct()), - ).annotate( - quotac=Count('quotas'), - has_variations=Count('variations') - ).filter( - quotac__gt=0 - ).order_by('category__position', 'category_id', 'position', 'name') + if category.pk not in item_cache: + # Get all items to possibly show + items = category.items.filter( + 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(hide_without_voucher=False) + ).prefetch_related( + 'variations__quotas', # for .availability() + Prefetch('quotas', queryset=event.quotas.all()), + Prefetch('variations', to_attr='available_variations', + queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).distinct()), + ).annotate( + quotac=Count('quotas'), + has_variations=Count('variations') + ).filter( + quotac__gt=0 + ).order_by('category__position', 'category_id', 'position', 'name') + item_cache[category.pk] = items + else: + items = item_cache[category.pk] for i in items: if i.has_variations: + choices = [('', '–')] + for v in i.available_variations: + cached_availability = v.check_quotas(_cache=quota_cache) + choices.append((v.pk, self._label(event, v, cached_availability))) + field = forms.ChoiceField( - choices=[('', '–')] + [ - ( - v.pk, - self._label(event, v) - ) for v in i.available_variations - ], + choices=choices, label=i.name, required=False, widget=forms.RadioSelect, + help_text=i.description, initial=current_addons.get(i.pk) ) else: + cached_availability = i.check_quotas(_cache=quota_cache) field = forms.BooleanField( - label=self._label(event, i), + label=self._label(event, i, cached_availability), required=False, - initial=i.pk in current_addons + initial=i.pk in current_addons, + help_text=i.description ) self.fields['item_%s' % i.pk] = field diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html index ecf221c8f..40cf7bd10 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html @@ -33,6 +33,10 @@ {% blocktrans trimmed with min_count=c.min_count %} You need to choose {{ min_count }} options from this category. {% endblocktrans %} + {% elif c.min_count == 0 %} + {% blocktrans trimmed with max_count=c.max_count %} + You can to choose up to {{ max_count }} options from this category. + {% endblocktrans %} {% else %} {% blocktrans trimmed with min_count=c.min_count max_count=c.max_count %} You can choose between {{ min_count }} and {{ max_count }} options from diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html index 754914a61..ee6b26939 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html @@ -42,14 +42,14 @@
{% endif %} - {% for form in forms %} + {% for pos, forms in formgroups %}
-
diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index faaf25087..61a6dab46 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -3,6 +3,9 @@ {% for line in cart.positions %}
+ {% if line.addon_to %} + + + {% endif %} {{ line.item.name }} {% if line.variation %} – {{ line.variation }} @@ -32,12 +35,23 @@ {% if download %}
- {% for b in download_buttons %} - - {{ b.text }} - - {% endfor %} + {% if not line.addon_to or event.settings.ticket_download_addons %} + {% for b in download_buttons %} + + {{ b.text }} + + {% endfor %} + {% endif %} +
+ {% elif line.addon_to %} +
 
+
+ {% if event.settings.display_net_prices %} + {{ event.currency }} {{ line.net_price|floatformat:2 }} + {% else %} + {{ event.currency }} {{ line.price|floatformat:2 }} + {% endif %}
{% else %}
@@ -67,7 +81,9 @@ {% endif %} - + {% endif %}
@@ -98,12 +114,14 @@
{% if download %}
- {% for b in download_buttons %} - - {{ b.text }} - - {% endfor %} + {% if not line.addon_to or event.settings.ticket_download_addons %} + {% for b in download_buttons %} + + {{ b.text }} + + {% endfor %} + {% endif %}
{% endif %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/order_modify.html b/src/pretix/presale/templates/pretixpresale/event/order_modify.html index 3b5a98fbd..a91f5fb21 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order_modify.html +++ b/src/pretix/presale/templates/pretixpresale/event/order_modify.html @@ -36,23 +36,28 @@
{% endif %} - {% for form in forms %} + {% for pos, forms in formgroups %}
- diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index c4049e071..1b707072c 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -18,7 +18,7 @@ event_patterns = [ url(r'^cart/clear$', pretix.presale.views.cart.CartClear.as_view(), name='event.cart.clear'), url(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'), url(r'^checkout/start$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout.start'), - url(r'^redeem$', pretix.presale.views.cart.RedeemView.as_view(), + url(r'^redeem/?$', pretix.presale.views.cart.RedeemView.as_view(), name='event.redeem'), url(r'^checkout/(?P[^/]+)/$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout'), diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 706fa658d..38896a8d4 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -29,35 +29,45 @@ class CartMixin: cartpos = queryset.order_by( 'item', 'variation' ).select_related( - 'item', 'variation' + 'item', 'variation', 'addon_to' ).prefetch_related( *prefetch ) else: cartpos = self.positions + lcp = list(cartpos) + has_addons = {cp.addon_to.pk for cp in lcp if cp.addon_to} + # Group items of the same variation # We do this by list manipulations instead of a GROUP BY query, as # Django is unable to join related models in a .values() query def keyfunc(pos): if isinstance(pos, OrderPosition): - i = pos.positionid + if pos.addon_to: + i = pos.addon_to.positionid + else: + i = pos.positionid else: - i = pos.pk - if downloads: - return i, pos.pk, 0, 0, 0, 0, + if pos.addon_to: + i = pos.addon_to.pk + else: + i = pos.pk has_attendee_data = pos.item.admission and ( self.request.event.settings.attendee_names_asked or self.request.event.settings.attendee_emails_asked ) - + addon_penalty = 1 if pos.addon_to else 0 + if downloads or pos.pk in has_addons or pos.addon_to: + return i, addon_penalty, pos.pk, 0, 0, 0, 0, if answers and (has_attendee_data or pos.item.questions.all()): - return i, pos.pk, 0, 0, 0, 0, - return 0, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0) + return i, addon_penalty, pos.pk, 0, 0, 0, 0, + + return 0, addon_penalty, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0) positions = [] - for k, g in groupby(sorted(list(cartpos), key=keyfunc), key=keyfunc): + for k, g in groupby(sorted(lcp, key=keyfunc), key=keyfunc): g = list(g) group = g[0] group.count = len(g) diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index f209b7281..e095e1be2 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -171,6 +171,7 @@ class RedeemView(EventViewMixin, TemplateView): 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(category__is_addon=True) ) vouchq = Q(hide_without_voucher=False) diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index d224771d9..9725ebafd 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -46,6 +46,7 @@ def get_grouped_items(event): & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) & Q(hide_without_voucher=False) + & ~Q(category__is_addon=True) ).select_related( 'category', # for re-grouping ).prefetch_related( diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 79380081a..c00b020d2 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -3,7 +3,7 @@ from datetime import timedelta from django.contrib import messages from django.db import transaction from django.db.models import Sum -from django.http import FileResponse, Http404, HttpResponse +from django.http import FileResponse, Http404, JsonResponse from django.shortcuts import redirect, render from django.utils.functional import cached_property from django.utils.timezone import now @@ -392,9 +392,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template @cached_property def positions(self): - return list(self.order.positions.order_by( - 'item', 'variation' - ).select_related( + return list(self.order.positions.select_related( 'item', 'variation' ).prefetch_related( 'variation', 'item__questions', 'answers' @@ -445,7 +443,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['order'] = self.order - ctx['forms'] = self.forms + ctx['formgroups'] = self.formdict.items() ctx['invoice_form'] = self.invoice_form return ctx @@ -501,6 +499,12 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View): class OrderDownload(EventViewMixin, OrderDetailMixin, View): + def get_self_url(self): + return eventreverse(self.request.event, + 'presale:event.order.download' if 'position' in self.kwargs + else 'presale:event.order.download.combined', + kwargs=self.kwargs) + @cached_property def output(self): responses = register_ticket_outputs.send(self.request.event) @@ -516,20 +520,30 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View): except OrderPosition.DoesNotExist: return None + def error(self, msg): + messages.error(self.request, msg) + if "ajax" in self.request.POST or "ajax" in self.request.GET: + return JsonResponse({ + 'ready': True, + 'success': False, + 'redirect': self.get_order_url(), + 'message': msg, + }) + return redirect(self.get_order_url()) + def get(self, request, *args, **kwargs): if not self.output or not self.output.is_enabled: - messages.error(request, _('You requested an invalid ticket output type.')) - return redirect(self.get_order_url()) + return self.error(_('You requested an invalid ticket output type.')) if not self.order or ('position' in kwargs and not self.order_position): raise Http404(_('Unknown order code or not authorized to access this order.')) if self.order.status != Order.STATUS_PAID: - messages.error(request, _('Order is not paid.')) - return redirect(self.get_order_url()) + return self.error(_('Order is not paid.')) if (not self.request.event.settings.ticket_download or (self.request.event.settings.ticket_download_date is not None and now() < self.request.event.settings.ticket_download_date)): - messages.error(request, _('Ticket download is not (yet) enabled.')) - return redirect(self.get_order_url()) + return self.error(_('Ticket download is not (yet) enabled.')) + if 'position' in kwargs and (self.order_position.addon_to and not self.request.event.settings.ticket_download_addons): + return self.error(_('Ticket download is not enabled for add-on products.')) if 'position' in kwargs: return self._download_position() @@ -555,7 +569,11 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View): generate_order.apply_async(args=(self.order.id, self.output.identifier)) if 'ajax' in self.request.GET: - return HttpResponse('1' if ct and ct.file else '0') + return JsonResponse({ + 'ready': bool(ct and ct.file), + 'success': False, + 'redirect': self.get_self_url() + }) elif not ct.file: return render(self.request, "pretixbase/cachedfiles/pending.html", {}) else: @@ -584,7 +602,11 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View): generate.apply_async(args=(self.order_position.id, self.output.identifier)) if 'ajax' in self.request.GET: - return HttpResponse('1' if ct and ct.file else '0') + return JsonResponse({ + 'ready': bool(ct and ct.file), + 'success': False, + 'redirect': self.get_self_url() + }) elif not ct.file: return render(self.request, "pretixbase/cachedfiles/pending.html", {}) else: diff --git a/src/pretix/presale/views/questions.py b/src/pretix/presale/views/questions.py index 385adc05c..69b72cf4d 100644 --- a/src/pretix/presale/views/questions.py +++ b/src/pretix/presale/views/questions.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django import forms from django.utils.functional import cached_property @@ -8,8 +10,18 @@ from pretix.presale.views import get_cart class QuestionsViewMixin: + @staticmethod + def _keyfunc(pos): + # Sort addons after the item they are an addon to + if isinstance(pos, OrderPosition): + i = pos.addon_to.positionid if pos.addon_to else pos.positionid + else: + i = pos.addon_to.pk if pos.addon_to else pos.pk + addon_penalty = 1 if pos.addon_to else 0 + return i, addon_penalty, pos.pk + def _positions_for_questions(self): - return get_cart(self.request) + return sorted(get_cart(self.request), key=self._keyfunc) @cached_property def forms(self): @@ -32,6 +44,17 @@ class QuestionsViewMixin: formlist.append(form) return formlist + @cached_property + def formdict(self): + storage = defaultdict(list) + for f in self.forms: + pos = f.cartpos or f.orderpos + if pos.addon_to_id: + storage[pos.addon_to].append(f) + else: + storage[pos].append(f) + return storage + def save(self): failed = False for form in self.forms: diff --git a/src/pretix/static/pretixbase/js/asyncdownload.js b/src/pretix/static/pretixbase/js/asyncdownload.js index fe09e9967..2f3a3b164 100644 --- a/src/pretix/static/pretixbase/js/asyncdownload.js +++ b/src/pretix/static/pretixbase/js/asyncdownload.js @@ -11,15 +11,16 @@ function async_dl_check() { 'success': async_dl_check_callback, 'error': async_dl_check_error, 'context': this, + 'dataType': 'json' } ); } function async_dl_check_callback(data, jqXHR, status) { "use strict"; - if (data == 1) { + if (data.ready && data.redirect) { $("body").data('ajaxing', false); - location.href = async_dl_url; + location.href = data.redirect; waitingDialog.hide(); return; } diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index 4dd6eb9d8..cb59b762f 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -66,6 +66,12 @@ nav.navbar .danger, nav.navbar .danger:hover, nav.navbar .danger:active { padding-left: 20px; } } + + .addon-signifier { + display: inline-block; + padding-left: 10px; + font-weight: bold; + } } h1 .btn-sm { @@ -220,4 +226,3 @@ body.loading #wrapper { .action-col-2 { min-width: 95px; } - diff --git a/src/pretix/static/pretixpresale/scss/_cart.scss b/src/pretix/static/pretixpresale/scss/_cart.scss index 3c3ff2250..2f313a1c9 100644 --- a/src/pretix/static/pretixpresale/scss/_cart.scss +++ b/src/pretix/static/pretixpresale/scss/_cart.scss @@ -58,6 +58,11 @@ &.has-downloads .product { width: percentage((5 / $grid-columns)); } + .addon-signifier { + display: inline-block; + padding-left: 10px; + font-weight: bold; + } } @media(max-width: $screen-sm-max) { @@ -75,4 +80,4 @@ clear: both; } } -} \ No newline at end of file +} diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index 38b82e593..0d0bfd15d 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -264,6 +264,7 @@ class ItemsTest(ItemFormTest): require_voucher=True, allow_cancel=False) self.var1 = ItemVariation.objects.create(item=self.item2, value="Silver") self.var2 = ItemVariation.objects.create(item=self.item2, value="Gold") + self.addoncat = ItemCategory.objects.create(event=self.event1, name="Item category") def test_move(self): self.client.post('/control/event/%s/%s/items/%s/down' % (self.orga1.slug, self.event1.slug, self.item1.id),) @@ -295,6 +296,49 @@ class ItemsTest(ItemFormTest): self.item1.refresh_from_db() assert self.item1.default_price == Decimal('23.00') + def test_manipulate_addons(self): + self.client.post('/control/event/%s/%s/items/%d/addons' % (self.orga1.slug, self.event1.slug, self.item2.id), { + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '0', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + 'form-0-id': '', + 'form-0-addon_category': str(self.addoncat.pk), + 'form-0-min_count': '1', + 'form-0-max_count': '2', + }) + assert self.item2.addons.exists() + assert self.item2.addons.first().addon_category == self.addoncat + self.client.post('/control/event/%s/%s/items/%d/addons' % (self.orga1.slug, self.event1.slug, self.item2.id), { + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '1', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + 'form-0-id': str(self.item2.addons.first().pk), + 'form-0-addon_category': str(self.addoncat.pk), + 'form-0-min_count': '1', + 'form-0-max_count': '2', + 'form-0-DELETE': 'on', + }) + assert not self.item2.addons.exists() + + # Do not allow duplicates + self.client.post('/control/event/%s/%s/items/%d/addons' % (self.orga1.slug, self.event1.slug, self.item2.id), { + 'form-TOTAL_FORMS': '2', + 'form-INITIAL_FORMS': '0', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + 'form-0-id': '', + 'form-0-addon_category': str(self.addoncat.pk), + 'form-0-min_count': '1', + 'form-0-max_count': '2', + 'form-1-id': '', + 'form-1-addon_category': str(self.addoncat.pk), + 'form-1-min_count': '1', + 'form-1-max_count': '2', + }) + assert not self.item2.addons.exists() + def test_update_variations(self): self.client.post('/control/event/%s/%s/items/%d/variations' % (self.orga1.slug, self.event1.slug, self.item2.id), { 'form-TOTAL_FORMS': '2', diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index dfd76bed6..cf8bd5485 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -11,6 +11,8 @@ from pretix.base.models import ( CartPosition, Event, Item, ItemCategory, ItemVariation, Organizer, Question, QuestionAnswer, Quota, Voucher, ) +from pretix.base.models.items import ItemAddOn +from pretix.base.services.cart import CartError, CartManager class CartTestMixin: @@ -513,12 +515,12 @@ class CartTest(CartTestMixin, TestCase): event=self.event, cart_id=self.session_key, item=self.ticket, price=23, expires=now() + timedelta(minutes=10) ) - CartPosition.objects.create( + cp = CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, price=23, expires=now() + timedelta(minutes=10) ) response = self.client.post('/%s/%s/cart/remove' % (self.orga.slug, self.event.slug), { - 'item_%d' % self.ticket.id: '1', + 'id': cp.pk }, follow=True) doc = BeautifulSoup(response.rendered_content, "lxml") self.assertIn('less than', doc.select('.alert-danger')[0].text) @@ -536,6 +538,17 @@ class CartTest(CartTestMixin, TestCase): self.assertIn('empty', doc.select('.alert-success')[0].text) self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists()) + def test_remove_invalid(self): + cp = CartPosition.objects.create( + event=self.event, cart_id='invalid', item=self.shirt, variation=self.shirt_red, + price=14, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/cart/remove' % (self.orga.slug, self.event.slug), { + 'id': cp.pk + }, follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + assert doc.select('.alert-danger') + def test_remove_one_of_multiple(self): cp = CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, @@ -995,3 +1008,361 @@ class CartTest(CartTestMixin, TestCase): }, follow=True) positions = CartPosition.objects.filter(cart_id=self.session_key, event=self.event) assert positions.count() == 1 + + +class CartAddonTest(CartTestMixin, TestCase): + def setUp(self): + super().setUp() + self.workshopcat = ItemCategory.objects.create(name="Workshops", is_addon=True, event=self.event) + self.workshopquota = Quota.objects.create(event=self.event, name='Workshop 1', size=5) + self.workshop1 = Item.objects.create(event=self.event, name='Workshop 1', + category=self.workshopcat, default_price=12) + self.workshop2 = Item.objects.create(event=self.event, name='Workshop 2', + category=self.workshopcat, default_price=12) + self.workshop3 = Item.objects.create(event=self.event, name='Workshop 3', + category=self.workshopcat, default_price=12) + self.workshop3a = ItemVariation.objects.create(item=self.workshop3, value='3a') + self.workshop3b = ItemVariation.objects.create(item=self.workshop3, value='3b') + self.workshopquota.items.add(self.workshop1) + self.workshopquota.items.add(self.workshop2) + self.workshopquota.items.add(self.workshop3) + self.workshopquota.variations.add(self.workshop3a) + self.workshopquota.variations.add(self.workshop3b) + self.addon1 = ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat) + self.cm = CartManager(event=self.event, cart_id=self.session_key) + + def test_cart_set_simple_addon(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + self.cm.commit() + cp2 = cp1.addons.first() + assert cp2.item == self.workshop1 + + def test_wrong_category(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.workshop1.category = self.category + self.workshop1.save() + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + + def test_invalid_parent(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id='other' + ) + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + + def test_no_quota_for_addon(self): + self.workshopquota.delete() + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + + def test_unknown_addon_item(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': 99999, + 'variation': None + } + ]) + + def test_duplicate_items_for_other_cp(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + cp2 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + self.cm.set_addons([ + { + 'addon_to': cp2.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + self.cm.commit() + + def test_no_duplicate_items_for_same_cp(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.addon1.max_count = 2 + self.addon1.save() + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + }, + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3a.pk + }, + { + 'addon_to': cp1.pk, + 'item': self.workshop3.pk, + 'variation': self.workshop3b.pk + } + ]) + + def test_addon_max_count(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + }, + { + 'addon_to': cp1.pk, + 'item': self.workshop2.pk, + 'variation': None + } + ]) + + self.addon1.max_count = 2 + self.addon1.save() + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + }, + { + 'addon_to': cp1.pk, + 'item': self.workshop2.pk, + 'variation': None + } + ]) + + def test_addon_min_count(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.addon1.min_count = 2 + self.addon1.max_count = 9 + self.addon1.save() + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop2.pk, + 'variation': None + } + ]) + + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + }, + { + 'addon_to': cp1.pk, + 'item': self.workshop2.pk, + 'variation': None + } + ]) + + def test_remove_addons(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + cp2 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.workshop1, price=Decimal('12.00'), + event=self.event, cart_id=self.session_key, addon_to=cp1 + ) + self.cm.set_addons([]) + self.cm.commit() + assert not CartPosition.objects.filter(pk=cp2.pk).exists() + + def test_remove_addons_below_min(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + cp2 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.workshop1, price=Decimal('12.00'), + event=self.event, cart_id=self.session_key, addon_to=cp1 + ) + self.addon1.min_count = 1 + self.addon1.save() + with self.assertRaises(CartError): + self.cm.set_addons([]) + self.cm.commit() + assert CartPosition.objects.filter(pk=cp2.pk).exists() + + def test_change_product(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.workshop1, price=Decimal('12.00'), + event=self.event, cart_id=self.session_key, addon_to=cp1 + ) + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop2.pk, + 'variation': None + } + ]) + self.cm.commit() + cp1.refresh_from_db() + assert cp1.addons.count() == 1 + assert cp1.addons.first().item == self.workshop2 + + def test_unchanged(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.workshop1, price=Decimal('12.00'), + event=self.event, cart_id=self.session_key, addon_to=cp1 + ) + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + assert not self.cm._operations + + def test_exceed_max(self): + self.event.settings.max_items_per_order = 1 + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + self.cm.commit() + + def test_sold_out(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + self.workshopquota.size = 0 + self.workshopquota.save() + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + with self.assertRaises(CartError): + self.cm.commit() + + def test_sold_out_unchanged(self): + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.workshop1, price=Decimal('12.00'), + event=self.event, cart_id=self.session_key, addon_to=cp1 + ) + self.workshopquota.size = 0 + self.workshopquota.save() + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + self.cm.commit() + + def test_expand_expired(self): + cp1 = CartPosition.objects.create( + expires=now() - timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key + ) + cp2 = CartPosition.objects.create( + expires=now() - timedelta(minutes=10), item=self.workshop1, price=Decimal('12.00'), + event=self.event, cart_id=self.session_key, addon_to=cp1 + ) + self.cm.extend_expired_positions() + self.cm.commit() + cp1.refresh_from_db() + cp2.refresh_from_db() + assert cp1.expires > now() + assert cp2.expires > now() + assert cp2.addon_to_id == cp1.pk diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index b17067af8..b2850e4ae 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -11,6 +11,7 @@ from pretix.base.models import ( CartPosition, Event, Item, ItemCategory, Order, OrderPosition, Organizer, Question, Quota, Voucher, ) +from pretix.base.models.items import ItemAddOn, ItemVariation class CheckoutTestCase(TestCase): @@ -35,6 +36,19 @@ class CheckoutTestCase(TestCase): self.session_key = self.client.cookies.get(settings.SESSION_COOKIE_NAME).value self._set_session('email', 'admin@localhost') + self.workshopcat = ItemCategory.objects.create(name="Workshops", is_addon=True, event=self.event) + self.workshopquota = Quota.objects.create(event=self.event, name='Workshop 1', size=5) + self.workshop1 = Item.objects.create(event=self.event, name='Workshop 1', + category=self.workshopcat, default_price=12) + self.workshop2 = Item.objects.create(event=self.event, name='Workshop 2', + category=self.workshopcat, default_price=12) + self.workshop2a = ItemVariation.objects.create(item=self.workshop2, value='A') + self.workshop2b = ItemVariation.objects.create(item=self.workshop2, value='B') + self.workshopquota.items.add(self.workshop1) + self.workshopquota.items.add(self.workshop2) + self.workshopquota.variations.add(self.workshop2a) + self.workshopquota.variations.add(self.workshop2b) + def test_empty_cart(self): response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), @@ -766,3 +780,46 @@ class CheckoutTestCase(TestCase): response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) doc = BeautifulSoup(response.rendered_content, "lxml") self.assertEqual(len(doc.select(".thank-you")), 1) + + def test_addons_as_first_step(self): + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + + response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), + target_status_code=200) + + def test_set_addons_item_and_variation(self): + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat) + cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + cp2 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + + response = self.client.post('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), { + '{}_{}-item_{}'.format(cp1.pk, self.workshopcat.pk, self.workshop1.pk): 'on', + '{}_{}-item_{}'.format(cp2.pk, self.workshopcat.pk, self.workshop2.pk): self.workshop2a.pk, + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert cp1.addons.first().item == self.workshop1 + assert cp2.addons.first().item == self.workshop2 + assert cp2.addons.first().variation == self.workshop2a + + def test_set_addons_required(self): + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + + response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug)) + self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), + target_status_code=200) diff --git a/src/tests/presale/test_checkoutflow.py b/src/tests/presale/test_checkoutflow.py index 78da12cf7..7ec1ca870 100644 --- a/src/tests/presale/test_checkoutflow.py +++ b/src/tests/presale/test_checkoutflow.py @@ -76,10 +76,11 @@ def test_plugin_in_order(event, mocker): priority = 100 flow = with_mocked_step(mocker, MockingStep, event) - assert isinstance(flow[0], checkoutflow.QuestionsStep) - assert isinstance(flow[1], MockingStep) - assert isinstance(flow[2], checkoutflow.PaymentStep) - assert isinstance(flow[3], checkoutflow.ConfirmStep) + assert isinstance(flow[0], checkoutflow.AddOnsStep) + assert isinstance(flow[1], checkoutflow.QuestionsStep) + assert isinstance(flow[2], MockingStep) + assert isinstance(flow[3], checkoutflow.PaymentStep) + assert isinstance(flow[4], checkoutflow.ConfirmStep) @pytest.mark.django_db @@ -93,8 +94,8 @@ def test_step_ignored(event, mocker, req_with_session): flow = with_mocked_step(mocker, MockingStep, event) req_with_session.event = event - assert flow[0].get_next_applicable(req_with_session) is flow[2] - assert flow[0] is flow[2].get_prev_applicable(req_with_session) + assert flow[1].get_next_applicable(req_with_session) is flow[3] + assert flow[1] is flow[3].get_prev_applicable(req_with_session) @pytest.mark.django_db