diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 1065feb325..82e12a9301 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -911,6 +911,19 @@ class OrderCreateSerializer(I18nAwareModelSerializer): if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota: continue + if pos_data.get('subevent'): + if pos_data.get('item').pk in pos_data['subevent'].item_overrides and pos_data['subevent'].item_overrides[pos_data['item'].pk].disabled: + errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format( + str(pos_data.get('item')) + )] + if ( + pos_data.get('variation') and pos_data['variation'].pk in pos_data['subevent'].var_overrides and + pos_data['subevent'].var_overrides[pos_data['variation'].pk].disabled + ): + errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format( + str(pos_data.get('item')) + )] + new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent')) if pos_data.get('variation') else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent'))) diff --git a/src/pretix/base/migrations/0154_auto_20200620_1633.py b/src/pretix/base/migrations/0154_auto_20200620_1633.py new file mode 100644 index 0000000000..89e461bf7e --- /dev/null +++ b/src/pretix/base/migrations/0154_auto_20200620_1633.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.6 on 2020-06-20 16:33 + +import django_countries.fields +from django.db import migrations, models + +import pretix.helpers.countries + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0153_auto_20200528_1953'), + ] + + operations = [ + migrations.AddField( + model_name='subeventitem', + name='disabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='subeventitemvariation', + name='disabled', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='invoiceaddress', + name='country', + field=django_countries.fields.CountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index c20a40bbf0..52453652b9 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -1061,21 +1061,35 @@ class SubEvent(EventMixin, LoggedModel): return self.event.settings @cached_property - def item_price_overrides(self): + def item_overrides(self): from .items import SubEventItem return { - si.item_id: si.price - for si in SubEventItem.objects.filter(subevent=self, price__isnull=False) + si.item_id: si + for si in SubEventItem.objects.filter(subevent=self) } @cached_property - def var_price_overrides(self): + def var_overrides(self): from .items import SubEventItemVariation + return { + si.variation_id: si + for si in SubEventItemVariation.objects.filter(subevent=self) + } + + @property + def item_price_overrides(self): + return { + si.item_id: si.price + for si in self.item_overrides.values() if si.price is not None + } + + @property + def var_price_overrides(self): return { si.variation_id: si.price - for si in SubEventItemVariation.objects.filter(subevent=self, price__isnull=False) + for si in self.var_overrides.values() if si.price is not None } @property diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 648d8b7adc..d0629ba47d 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -118,6 +118,7 @@ class SubEventItem(models.Model): subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE) item = models.ForeignKey('Item', on_delete=models.CASCADE) price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True) + disabled = models.BooleanField(default=False) def delete(self, *args, **kwargs): super().delete(*args, **kwargs) @@ -145,6 +146,7 @@ class SubEventItemVariation(models.Model): subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE) variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE) price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True) + disabled = models.BooleanField(default=False) def delete(self, *args, **kwargs): super().delete(*args, **kwargs) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 380a91d298..b627decd47 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -253,6 +253,12 @@ class CartManager: if self._sales_channel not in op.item.sales_channels: raise CartError(error_messages['unavailable']) + if op.subevent and op.item.pk in op.subevent.item_overrides and op.subevent.item_overrides[op.item.pk].disabled: + raise CartError(error_messages['not_for_sale']) + + if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and op.subevent.var_overrides[op.variation.pk].disabled: + raise CartError(error_messages['not_for_sale']) + if op.item.has_variations and not op.variation: raise CartError(error_messages['not_for_sale']) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index d15fdd2446..784c6c1c1b 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -641,6 +641,16 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio delete(cp) continue + if cp.subevent and cp.item.pk in cp.subevent.item_overrides and cp.subevent.item_overrides[cp.item.pk].disabled: + err = err or error_messages['unavailable'] + delete(cp) + continue + + if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and cp.subevent.var_overrides[cp.variation.pk].disabled: + err = err or error_messages['unavailable'] + delete(cp) + continue + if cp.voucher: if cp.voucher.valid_until and cp.voucher.valid_until < now_dt: err = err or error_messages['voucher_expired'] diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index 81850ff9e6..389894ec0e 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -99,7 +99,7 @@ class SubEventItemForm(SubEventItemOrVariationFormMixin, forms.ModelForm): class Meta: model = SubEventItem - fields = ['price'] + fields = ['price', 'disabled'] widgets = { 'price': forms.TextInput } @@ -113,7 +113,7 @@ class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelFor class Meta: model = SubEventItem - fields = ['price'] + fields = ['price', 'disabled'] widgets = { 'price': forms.TextInput } diff --git a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html index 59356d9e64..67b87d6aad 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html @@ -461,7 +461,19 @@
{% trans "Item prices" %} {% for f in itemvar_forms %} - {% bootstrap_field f.price addon_after=request.event.currency layout="control" %} +
+ +
+ {% bootstrap_field f.price addon_after=request.event.currency form_group_class="" layout="inline" %} +
+
+ {% bootstrap_field f.disabled layout="inline" form_group_class="" %} +
+
{% endfor %}
diff --git a/src/pretix/control/templates/pretixcontrol/subevents/detail.html b/src/pretix/control/templates/pretixcontrol/subevents/detail.html index 1b5b124795..9f0a6172f7 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/detail.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/detail.html @@ -145,7 +145,19 @@
{% trans "Item prices" %} {% for f in itemvar_forms %} - {% bootstrap_field f.price addon_after=request.event.currency layout="control" %} +
+ +
+ {% bootstrap_field f.price addon_after=request.event.currency form_group_class="" layout="inline" %} +
+
+ {% bootstrap_field f.disabled layout="inline" form_group_class="" %} +
+
{% endfor %}
diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index c3dca2b04b..f32b328493 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -338,11 +338,11 @@ class SubEventEditorMixin(MetaDataEditorMixin): if self.copy_from: se_item_instances = { - sei.item_id: SubEventItem(item=sei.item, price=sei.price) + sei.item_id: SubEventItem(item=sei.item, price=sei.price, disabled=sei.disabled) for sei in SubEventItem.objects.filter(subevent=self.copy_from).select_related('item') } se_var_instances = { - sei.variation_id: SubEventItemVariation(variation=sei.variation, price=sei.price) + sei.variation_id: SubEventItemVariation(variation=sei.variation, price=sei.price, disabled=sei.disabled) for sei in SubEventItemVariation.objects.filter(subevent=self.copy_from).select_related('variation') } diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 1be97daef1..c3fefd0ff6 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -23,7 +23,9 @@ from django.views.generic import TemplateView from pretix.base.channels import get_all_sales_channels from pretix.base.models import ItemVariation, Quota, SeatCategoryMapping from pretix.base.models.event import SubEvent -from pretix.base.models.items import ItemBundle +from pretix.base.models.items import ( + ItemBundle, SubEventItem, SubEventItemVariation, +) from pretix.base.services.quotas import QuotaAvailability from pretix.multidomain.urlreverse import eventreverse from pretix.presale.ical import get_ical @@ -83,7 +85,17 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require )), )), Prefetch('variations', to_attr='available_variations', - queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).filter(active=True, quotas__isnull=False).prefetch_related( + queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate( + subevent_disabled=Exists( + SubEventItemVariation.objects.filter( + variation_id=OuterRef('pk'), + subevent=subevent, + disabled=True, + ) + ), + ).filter( + active=True, quotas__isnull=False, subevent_disabled=False + ).prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)) @@ -91,14 +103,21 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require ).annotate( quotac=Count('quotas'), has_variations=Count('variations'), + subevent_disabled=Exists( + SubEventItem.objects.filter( + item_id=OuterRef('pk'), + subevent=subevent, + disabled=True, + ) + ), requires_seat=Exists( SeatCategoryMapping.objects.filter( product_id=OuterRef('pk'), subevent=subevent ) - ) + ), ).filter( - quotac__gt=0, + quotac__gt=0, subevent_disabled=False, ).order_by('category__position', 'category_id', 'position', 'name') if require_seat: items = items.filter(requires_seat__gt=0) diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 6aad2a8c18..38fc3707d1 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -2276,6 +2276,64 @@ def test_order_create_item_validation(token_client, organizer, event, item, item assert resp.data == {'positions': [{'variation': ['You should specify a variation for this item.']}]} +@pytest.mark.django_db +def test_order_create_subevent_disabled(token_client, organizer, event, item, subevent, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['subevent'] = subevent.pk + s = item.subeventitem_set.create(subevent=subevent, disabled=True) + quota.subevent = subevent + quota.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'item': ['The product "Budget Ticket" is not available on this date.']}]} + + s.delete() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + +@pytest.mark.django_db +def test_order_create_subevent_variation_disabled(token_client, organizer, event, item, subevent, quota, question): + with scopes_disabled(): + item2 = event.items.create(name="Budget Ticket", default_price=23) + var = item2.variations.create(default_price=12, value="XS") + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item2.pk + res['positions'][0]['variation'] = var.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['subevent'] = subevent.pk + s = var.subeventitemvariation_set.create(subevent=subevent, disabled=True) + quota.subevent = subevent + quota.items.add(item2) + quota.variations.add(var) + quota.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'item': ['The product "Budget Ticket" is not available on this date.']}]} + + s.delete() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + @pytest.mark.django_db def test_order_create_positionids_addons(token_client, organizer, event, item, quota): res = copy.deepcopy(ORDER_CREATE_PAYLOAD) diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index b1f95d00f2..f7fc13fba8 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -477,6 +477,22 @@ class CartTest(CartTestMixin, TestCase): objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 0) + def test_subevent_disabled(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + SubEventItem.objects.create(subevent=se, item=self.ticket, price=42, disabled=True) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se.pk + }, follow=False) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + def test_subevent_price(self): self.event.has_subevents = True self.event.save() @@ -613,6 +629,22 @@ class CartTest(CartTestMixin, TestCase): self.assertEqual(objs[0].variation, self.shirt_red) self.assertEqual(objs[0].price, 16) + def test_subevent_variation_disabled(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.variations.add(self.shirt_red) + SubEventItemVariation.objects.create(subevent=se, variation=self.shirt_red, price=42, disabled=True) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1', + 'subevent': se.pk + }, follow=False) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + def test_subevent_variation_price(self): self.event.has_subevents = True self.event.save() diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index cd0ab267dd..0f2ef8c76a 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -22,7 +22,7 @@ from pretix.base.models import ( SeatingPlan, Voucher, ) from pretix.base.models.items import ( - ItemAddOn, ItemBundle, ItemVariation, SubEventItem, + ItemAddOn, ItemBundle, ItemVariation, SubEventItem, SubEventItemVariation, ) from pretix.base.services.orders import OrderError, _perform_order from pretix.testutils.scope import classscope @@ -1465,6 +1465,43 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): cr1 = CartPosition.objects.get(id=cr1.id) self.assertEqual(cr1.price, 24) + def test_subevent_disabled(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se = self.event.subevents.create(name='Foo', date_from=now()) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + SubEventItem.objects.create(subevent=se, item=self.ticket, price=24, disabled=True) + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + self._set_session('payment', 'banktransfer') + + self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + with scopes_disabled(): + assert not CartPosition.objects.filter(id=cr1.id).exists() + + def test_subevent_variation_disabled(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se = self.event.subevents.create(name='Foo', date_from=now()) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.workshop2) + q.variations.add(self.workshop2b) + SubEventItemVariation.objects.create(subevent=se, variation=self.workshop2b, price=24, disabled=True) + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.workshop2, variation=self.workshop2b, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + self._set_session('payment', 'banktransfer') + + self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + with scopes_disabled(): + assert not CartPosition.objects.filter(id=cr1.id).exists() + def test_addon_price_included(self): with scopes_disabled(): ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1, diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 3809b06509..cdc4cb9eb6 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -255,6 +255,24 @@ class ItemDisplayTest(EventTestMixin, SoupTest): resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) self.assertNotIn("Early-bird", resp.rendered_content) + def test_subevent_disabled(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=15) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + q.items.add(item) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se2) + q.items.add(item) + SubEventItem.objects.create(subevent=se1, item=item, price=12, disabled=True) + + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se1.pk)) + self.assertNotIn("Early-bird", resp.rendered_content) + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) + self.assertIn("Early-bird", resp.rendered_content) + def test_subevent_prices(self): self.event.has_subevents = True self.event.save() @@ -300,6 +318,27 @@ class ItemDisplayTest(EventTestMixin, SoupTest): self.assertNotIn("12.00", resp.rendered_content) self.assertNotIn("15.00", resp.rendered_content) + def test_variations_subevent_disabled(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=15) + v = ItemVariation.objects.create(item=item, value='Blue') + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + q.items.add(item) + q.variations.add(v) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se2) + q.items.add(item) + q.variations.add(v) + SubEventItemVariation.objects.create(subevent=se1, variation=v, disabled=True) + + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se1.pk)) + self.assertNotIn("Early-bird", resp.rendered_content) + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) + self.assertIn("Early-bird", resp.rendered_content) + def test_no_variations_in_quota(self): with scopes_disabled(): c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)