diff --git a/src/pretix/base/migrations/0067_auto_20170712_1610.py b/src/pretix/base/migrations/0067_auto_20170712_1610.py new file mode 100644 index 0000000000..81eccde98b --- /dev/null +++ b/src/pretix/base/migrations/0067_auto_20170712_1610.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2017-07-12 16:10 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0066_auto_20170708_2102'), + ] + + operations = [ + migrations.AlterModelOptions( + name='subevent', + options={'ordering': ('date_from', 'name'), 'verbose_name': 'Date in event series', 'verbose_name_plural': 'Dates in event series'}, + ), + migrations.AddField( + model_name='itemaddon', + name='price_included', + field=models.BooleanField(default=False, help_text='If selected, adding add-ons to this ticket is free, even if the add-ons would normally cost money individually.', verbose_name='Add-Ons are included in the price'), + ), + migrations.AlterField( + model_name='cartposition', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Date'), + ), + migrations.AlterField( + model_name='event', + name='has_subevents', + field=models.BooleanField(default=False, verbose_name='Event series'), + ), + migrations.AlterField( + model_name='orderposition', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Date'), + ), + migrations.AlterField( + model_name='quota', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='pretixbase.SubEvent', verbose_name='Date'), + ), + migrations.AlterField( + model_name='subevent', + name='active', + field=models.BooleanField(default=False, help_text='Only with this checkbox enabled, this date is visible in the frontend to users.', verbose_name='Active'), + ), + migrations.AlterField( + model_name='subevent', + name='presale_end', + field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold after this date.', null=True, verbose_name='End of presale'), + ), + migrations.AlterField( + model_name='subevent', + name='presale_start', + field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold before this date.', null=True, verbose_name='Start of presale'), + ), + migrations.AlterField( + model_name='voucher', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Date'), + ), + migrations.AlterField( + model_name='waitinglistentry', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Date'), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 69a5c0baf5..537acd7930 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -477,6 +477,12 @@ class ItemAddOn(models.Model): default=1, verbose_name=_('Maximum number') ) + price_included = models.BooleanField( + default=False, + verbose_name=_('Add-Ons are included in the price'), + help_text=_('If selected, adding add-ons to this ticket is free, even if the add-ons would normally cost ' + 'money individually.') + ) position = models.PositiveIntegerField( default=0, verbose_name=_("Position") diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 99eac4aeee..41694060ea 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -340,6 +340,7 @@ class CartManager: quota_diff = Counter() # Quota -> Number of usages operations = [] available_categories = defaultdict(set) # CartPos -> Category IDs to choose from + price_included = defaultdict(dict) # CartPos -> CategoryID -> bool(price is included) toplevel_cp = self.positions.filter( addon_to__isnull=True ).prefetch_related( @@ -349,6 +350,7 @@ class CartManager: # 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()} + price_included[cp.pk] = {iao.addon_category_id: iao.price_included for iao in cp.item.addons.all()} cpcache[cp.pk] = cp current_addons[cp] = { (a.item_id, a.variation_id): a @@ -392,7 +394,10 @@ class CartManager: for quota in quotas: quota_diff[quota] += 1 - price = self._get_price(item, variation, None, None, cp.subevent) + if price_included[cp.pk].get(item.category_id): + price = Decimal('0.00') + else: + price = self._get_price(item, variation, None, None, cp.subevent) op = self.AddOperation( count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 2b147e26ee..452d94c72a 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -282,7 +282,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio # Other checks are not necessary continue - price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False) + price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False, + addon_to=cp.addon_to) if price is False or len(quotas) == 0: err = err or error_messages['unavailable'] diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 4fe02b6de2..4dc1a23ceb 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -1,13 +1,24 @@ from decimal import Decimal from pretix.base.decimal import round_decimal -from pretix.base.models import Item, ItemVariation, Voucher +from pretix.base.models import ( + AbstractPosition, Item, ItemAddOn, ItemVariation, Voucher, +) from pretix.base.models.event import SubEvent def get_price(item: Item, variation: ItemVariation = None, voucher: Voucher = None, custom_price: Decimal = None, - subevent: SubEvent = None, custom_price_is_net: bool = False): + subevent: SubEvent = None, custom_price_is_net: bool = False, + addon_to: AbstractPosition = None): + if addon_to: + try: + iao = addon_to.item.addons.get(addon_category_id=item.category_id) + if iao.price_included: + return Decimal('0.00') + except ItemAddOn.DoesNotExist: + pass + price = item.default_price if subevent and item.pk in subevent.item_price_overrides: price = subevent.item_price_overrides[item.pk] diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 3c5987df12..03cf209c83 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -302,6 +302,7 @@ class ItemAddOnForm(I18nModelForm): 'addon_category', 'min_count', 'max_count', + 'price_included' ] help_texts = { 'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all ' diff --git a/src/pretix/control/templates/pretixcontrol/item/addons.html b/src/pretix/control/templates/pretixcontrol/item/addons.html index 3e16a79dbf..dd03a74f11 100644 --- a/src/pretix/control/templates/pretixcontrol/item/addons.html +++ b/src/pretix/control/templates/pretixcontrol/item/addons.html @@ -47,6 +47,7 @@ {% bootstrap_field form.addon_category layout='horizontal' %} {% bootstrap_field form.min_count layout='horizontal' %} {% bootstrap_field form.max_count layout='horizontal' %} + {% bootstrap_field form.price_included layout='horizontal' %} {% endfor %} @@ -78,6 +79,7 @@ {% bootstrap_field formset.empty_form.addon_category layout='horizontal' %} {% bootstrap_field formset.empty_form.min_count layout='horizontal' %} {% bootstrap_field formset.empty_form.max_count layout='horizontal' %} + {% bootstrap_field formset.empty_form.price_included layout='horizontal' %} {% endescapescript %} diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index ddccf46468..1fb0ac0279 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -183,6 +183,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): event=self.request.event, prefix='{}_{}'.format(cartpos.pk, iao.addon_category.pk), category=iao.addon_category, + price_included=iao.price_included, initial=current_addon_products, data=(self.request.POST if self.request.method == 'POST' else None), quota_cache=quota_cache, diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index c1ce0e8041..9a429afa72 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -293,6 +293,9 @@ class AddOnsForm(forms.Form): tax_value = round_decimal(price * (1 - 100 / (100 + item.tax_rate))) price_net = price - tax_value + if self.price_included: + price = Decimal('0.00') + if not price: n = '{name}'.format( name=label @@ -336,6 +339,7 @@ class AddOnsForm(forms.Form): current_addons = kwargs.pop('initial') quota_cache = kwargs.pop('quota_cache') item_cache = kwargs.pop('item_cache') + self.price_included = kwargs.pop('price_included') super().__init__(*args, **kwargs) diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 0cfcd2c084..a6b917b870 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -1306,6 +1306,26 @@ class CartAddonTest(CartTestMixin, TestCase): 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_included(self): + self.addon1.price_included = True + self.addon1.save() + 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 + assert cp2.price == 0 + def test_cart_set_simple_addon(self): cp1 = CartPosition.objects.create( expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), @@ -1322,6 +1342,7 @@ class CartAddonTest(CartTestMixin, TestCase): self.cm.commit() cp2 = cp1.addons.first() assert cp2.item == self.workshop1 + assert cp2.price == 12 def test_cart_subevent_set_simple_addon(self): self.event.has_subevents = True @@ -1345,6 +1366,7 @@ class CartAddonTest(CartTestMixin, TestCase): cp2 = cp1.addons.first() assert cp2.item == self.workshop1 assert cp2.subevent == se + assert cp2.value == 12 def test_cart_subevent_set_addon_for_wrong_subevent(self): self.event.has_subevents = True diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index c301516b0b..c9fface515 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -407,6 +407,25 @@ class CheckoutTestCase(TestCase): cr1 = CartPosition.objects.get(id=cr1.id) self.assertEqual(cr1.price, 24) + def test_addon_price_included(self): + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1, + price_included=True) + cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.workshop1, + price=0, expires=now() - timedelta(minutes=10), + addon_to=cp1 + ) + + self._set_session('payment', 'banktransfer') + 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) + self.assertEqual(OrderPosition.objects.filter(item=self.workshop1).last().price, 0) + def test_confirm_price_changed(self): self.ticket.default_price = 24 self.ticket.save() @@ -953,6 +972,23 @@ class CheckoutTestCase(TestCase): 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) + response = self.client.get('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug)) + assert 'Workshop 1' in response.rendered_content + assert 'EUR 12.00' in response.rendered_content + + def test_set_addons_included(self): + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1, + price_included=True) + 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), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert 'Workshop 1' in response.rendered_content + assert 'EUR 12.00' not in response.rendered_content def test_set_addons_subevent(self): self.event.has_subevents = True