diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index d61304807..02edb5b0b 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -44,6 +44,9 @@ available_from datetime The first date (or ``null``). available_until datetime The last date time at which this item can be bought (or ``null``). +hidden_if_available integer The internal ID of a quota object, or ``null``. If + set, this item won't be shown publicly as long as this + quota is available. require_voucher boolean If ``true``, this item can only be bought using a voucher that is specifically assigned to this item. hide_without_voucher boolean If ``true``, this item is only shown during the voucher @@ -146,7 +149,7 @@ bundles list of objects Definition of b .. versionchanged:: 3.0 - The ``show_quota_left`` attribute has been added. + The ``show_quota_left`` and ``hidden_if_available`` attributes have been added. Notes ----- @@ -205,6 +208,7 @@ Endpoints "picture": null, "available_from": null, "available_until": null, + "hidden_if_available": null, "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, @@ -297,6 +301,7 @@ Endpoints "picture": null, "available_from": null, "available_until": null, + "hidden_if_available": null, "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, @@ -370,6 +375,7 @@ Endpoints "picture": null, "available_from": null, "available_until": null, + "hidden_if_available": null, "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, @@ -430,6 +436,7 @@ Endpoints "picture": null, "available_from": null, "available_until": null, + "hidden_if_available": null, "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, @@ -522,6 +529,7 @@ Endpoints "picture": null, "available_from": null, "available_until": null, + "hidden_if_available": null, "require_voucher": false, "hide_without_voucher": false, "generate_tickets": null, diff --git a/doc/user/events/structureguide.rst b/doc/user/events/structureguide.rst index afd3fa74e..d89cd3b3f 100644 --- a/doc/user/events/structureguide.rst +++ b/doc/user/events/structureguide.rst @@ -45,8 +45,8 @@ In addition, you will need quotas. If you do not care how many of your tickets a If you want to limit the number of student tickets to 50 to ensure a certain minimum revenue, but do not want to limit the number of regular tickets artificially, we suggest you to create the same quota of 200 that is linked to both products, and then create a **second quota** of 50 that is only linked to the student ticket. This way, the system will reduce both quotas whenever a student ticket is sold and only the larger quota when a regular ticket is sold. -Use case: Early-bird tiers --------------------------- +Use case: Early-bird tiers based on dates +----------------------------------------- Let's say you run a conference that has the following pricing scheme: @@ -58,9 +58,53 @@ Of course, you could just set up one product and change its price at the given d Create three products (e.g. "super early bird", "early bird", "regular ticket") with the respective prices and one shared quota of your total event capacity. Then, set the **available from** and **available until** configuration fields of the products to automatically turn them on and off based on the current date. -.. note:: +Use case: Early-bird tiers based on ticket numbers +-------------------------------------------------- - pretix currently can't do early-bird tiers based on **ticket number** instead of time. We're planning this feature for later in 2019. For now, you'll need to monitor that manually. +Let's say you run a conference with 400 tickets that has the following pricing scheme: + +* First 100 tickets ("super early bird"): € 450 +* Next 100 tickets ("early bird"): € 550 +* Remaining tickets ("regular"): € 650 + +First of all, create three products: + +* "Super early bird ticket" +* "Early bird ticket" +* "Regular ticket" + +Then, create three quotas: + +* "Super early bird" with a **size of 100** and the "Super early bird ticket" product selected. At "Advanced options", + select the box "Close this quota permanently once it is sold out". + +* "Early bird and lower" with a **size of 200** and both of the "Super early bird ticket" and "Early bird ticket" + products selected. At "Advanced options", select the box "Close this quota permanently once it is sold out". + +* "All participants" with a **size of 400**, all three products selected and **no additional options**. + +Next, modify the product "Regular ticket". In the section "Availability", you should look for the option "Only show +after sellout of" and select your quota "Early bird and lower". Do the same for the "Early bird ticket" with the quota +"Super early bird ticket". + +This will ensure the following things: + +* Each ticket level is only visible after the previous level is sold out. + +* As soon as one level is really sold out, it's not coming back, because the quota "closes", i.e. locks in place. + +* By creating a total quota of 400 with all tickets included, you can still make sure to sell the maximum number of + tickets, even if e.g. early-bird tickets are canceled. + +Optionally, if you want to hide the early bird prices once they are sold out, go to "Settings", then "Display" and +select "Hide all products that are sold out". Of course, it might be a nice idea to keep showing the prices to remind +people to buy earlier next time ;) + +Please note that there might be short time intervals where the prices switch back and forth: When the last early bird +tickets are in someone's cart (but not yet sold!), the early bird tickets will show as "Reserved" and the regular +tickets start showing up. However, if the customers holding the reservations do not complete their order, +the early bird tickets will become available again. This is not avoidable if we want to prevent malicious users +from blocking all the cheap tickets without an actual sale happening. Use case: Up-selling of ticket extras ------------------------------------- diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 2a4d35a9c..187cdc165 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -119,7 +119,7 @@ class ItemSerializer(I18nAwareModelSerializer): 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', - 'show_quota_left') + 'show_quota_left', 'hidden_if_available') read_only_fields = ('has_variations', 'picture') def get_serializer_context(self): diff --git a/src/pretix/base/migrations/0129_auto_20190724_1548.py b/src/pretix/base/migrations/0129_auto_20190724_1548.py new file mode 100644 index 000000000..00ce50099 --- /dev/null +++ b/src/pretix/base/migrations/0129_auto_20190724_1548.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.1 on 2019-07-24 15:48 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0128_auto_20190715_1510'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='hidden_if_available', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='pretixbase.Quota'), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index cb025b73f..61ffbe9e8 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -516,6 +516,7 @@ class Event(EventMixin, LoggedModel): for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'): items = list(q.items.all()) vars = list(q.variations.all()) + oldid = q.pk q.pk = None q.event = self q.cached_availability_state = None @@ -529,6 +530,7 @@ class Event(EventMixin, LoggedModel): q.items.add(item_map[i.pk]) for v in vars: q.variations.add(variation_map[v.pk]) + self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q) question_map = {} for q in Question.objects.filter(event=other).prefetch_related('items', 'options'): diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 74488e994..ff60adc35 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -334,6 +334,17 @@ class Item(LoggedModel): null=True, blank=True, help_text=_('This product will not be sold after the given date.') ) + hidden_if_available = models.ForeignKey( + 'Quota', + null=True, blank=True, + on_delete=models.SET_NULL, + verbose_name=_("Only show after sellout of"), + help_text=_("If you select a quota here, this product will only be shown when that quota is " + "unavailable. If combined with the option to hide sold-out products, this allows you to " + "swap out products for more expensive ones once they are sold out. There might be a short period " + "in which both products are visible while all tickets in the referenced quota are reserved, " + "but not yet sold.") + ) require_voucher = models.BooleanField( verbose_name=_('This product can only be bought using a voucher.'), default=False, diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 9abb6435b..51087c871 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -402,6 +402,19 @@ class ItemUpdateForm(I18nModelForm): widget=forms.CheckboxSelectMultiple ) change_decimal_field(self.fields['default_price'], self.event.currency) + self.fields['hidden_if_available'].queryset = self.event.quotas.all() + self.fields['hidden_if_available'].widget = Select2( + attrs={ + 'data-model-select2': 'generic', + 'data-select2-url': reverse('control:event.items.quotas.select2', kwargs={ + 'event': self.event.slug, + 'organizer': self.event.organizer.slug, + }), + 'data-placeholder': _('Quota') + } + ) + self.fields['hidden_if_available'].widget.choices = self.fields['hidden_if_available'].choices + self.fields['hidden_if_available'].required = False class Meta: model = Item @@ -430,11 +443,13 @@ class ItemUpdateForm(I18nModelForm): 'generate_tickets', 'original_price', 'require_bundling', - 'show_quota_left' + 'show_quota_left', + 'hidden_if_available', ] field_classes = { 'available_from': SplitDateTimeField, 'available_until': SplitDateTimeField, + 'hidden_if_available': SafeModelChoiceField, } widgets = { 'available_from': SplitDateTimePickerWidget(), diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index f5093bea5..a24ffe3e5 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -31,6 +31,7 @@ {% bootstrap_field form.available_until layout="control" %} {% bootstrap_field form.max_per_order layout="control" %} {% bootstrap_field form.min_per_order layout="control" %} + {% bootstrap_field form.hidden_if_available layout="control" %} {% bootstrap_field form.require_voucher layout="control" %} {% bootstrap_field form.hide_without_voucher layout="control" %} {% bootstrap_field form.require_bundling layout="control" %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 0917ce1bb..49e1860a7 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -181,6 +181,7 @@ urlpatterns = [ url(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'), url(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'), url(r'^quotas/(?P\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'), + url(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'), url(r'^quotas/(?P\d+)/change$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'), url(r'^quotas/(?P\d+)/delete$', item.QuotaDelete.as_view(), name='event.items.quotas.delete'), diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py index 1cf7b34ab..a9a8d65ba 100644 --- a/src/pretix/control/views/typeahead.py +++ b/src/pretix/control/views/typeahead.py @@ -309,6 +309,53 @@ def subevent_select2(request, **kwargs): return JsonResponse(doc) +@event_permission_required(None) +def quotas_select2(request, **kwargs): + query = request.GET.get('query', '') + try: + page = int(request.GET.get('page', '1')) + except ValueError: + page = 1 + + qf = Q(name__icontains=query) | Q(subevent__name__icontains=i18ncomp(query)) + tz = request.event.timezone + + dt = None + for f in get_format('DATE_INPUT_FORMATS'): + try: + dt = datetime.strptime(query, f) + break + except (ValueError, TypeError): + continue + + if dt and request.event.has_subevents: + dt_start = make_aware(datetime.combine(dt.date(), time(hour=0, minute=0, second=0)), tz) + dt_end = make_aware(datetime.combine(dt.date(), time(hour=23, minute=59, second=59)), tz) + qf |= Q(subevent__date_from__gte=dt_start) & Q(subevent__date_from__lte=dt_end) + + qs = request.event.quotas.filter( + qf + ).order_by('-subevent__date_from', 'name') + + total = qs.count() + pagesize = 20 + offset = (page - 1) * pagesize + doc = { + 'results': [ + { + 'id': q.pk, + 'name': str(q.name), + 'text': q.name + } + for q in qs[offset:offset + pagesize] + ], + 'pagination': { + "more": total >= (offset + pagesize) + } + } + return JsonResponse(doc) + + @event_permission_required(None) def checkinlist_select2(request, **kwargs): query = request.GET.get('query', '') diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index 9cdf5b5b0..e4932f97f 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -254,6 +254,11 @@ class AddOnsForm(forms.Form): self.vars_cache = {} for i in items: + if i.hidden_if_available: + q = i.hidden_if_available.availability(_cache=quota_cache) + if q[0] == Quota.AVAILABILITY_OK: + continue + if i.has_variations: choices = [('', _('no selection'), '')] for v in i.available_variations: diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index a967bacdb..24f50f11f 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -52,6 +52,7 @@ def item_group_by_category(items): def get_grouped_items(event, subevent=None, voucher=None, channel='web', require_seat=0): items = event.items.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).select_related( 'category', 'tax_rule', # for re-grouping + 'hidden_if_available', ).prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', @@ -119,6 +120,12 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require max_per_order = item.max_per_order or int(event.settings.max_items_per_order) + if item.hidden_if_available: + q = item.hidden_if_available.availability(_cache=quota_cache) + if q[0] == Quota.AVAILABILITY_OK: + item._remove = True + continue + if not item.has_variations: item._remove = False if not bool(item._subevent_quotas): diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 821a99fc2..fabdf6bbf 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -234,6 +234,7 @@ TEST_ITEM_RES = { "allow_cancel": True, "min_per_order": None, "max_per_order": None, + "hidden_if_available": None, "checkin_attention": False, "has_variations": False, "require_approval": False, diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index 6c561237a..d0faca686 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -660,10 +660,14 @@ class EventsTest(SoupTest): tr = self.event1.tax_rules.create( rate=19, name="VAT" ) + q1 = self.event1.quotas.create( + name='Foo', + size=0, + ) self.event1.items.create( name='Early-bird ticket', category=None, default_price=23, tax_rule=tr, - admission=True + admission=True, hidden_if_available=q1 ) self.event1.settings.tax_rate_default = tr doc = self.get_doc('/control/events/add') @@ -724,6 +728,10 @@ class EventsTest(SoupTest): assert ev.presale_end == berlin_tz.localize(datetime.datetime(2016, 11, 30, 18, 0, 0)).astimezone(pytz.utc) assert ev.tax_rules.filter(rate=Decimal('19.00')).count() == 1 + i = ev.items.get() + assert i.hidden_if_available.name == "Foo" + assert i.hidden_if_available.event == ev + assert i.hidden_if_available.pk != q1.pk def test_create_event_clone_success(self): with scopes_disabled(): diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 8faae3431..f02bd2cb3 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -1781,6 +1781,53 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): assert 'Workshop 1' in response.rendered_content assert '€12.00' not in response.rendered_content + def test_set_addons_hide_sold_out(self): + with scopes_disabled(): + self.workshopquota.size = 0 + self.workshopquota.save() + + 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), 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 + self.event.settings.hide_sold_out = True + + response = self.client.get('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), follow=True) + assert 'Workshop 1' not in response.rendered_content + + def test_set_addons_hidden_if_available(self): + with scopes_disabled(): + self.workshopquota2 = Quota.objects.create(event=self.event, name='Workshop 1', size=5) + self.workshopquota2.items.add(self.workshop2) + self.workshopquota2.variations.add(self.workshop2a) + self.workshop2.hidden_if_available = self.workshopquota + self.workshop2.save() + + 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), 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 'Workshop 2' not in response.rendered_content + + self.workshopquota.size = 0 + self.workshopquota.save() + + response = self.client.get('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), follow=True) + assert 'Workshop 1' in response.rendered_content + assert 'Workshop 2' in response.rendered_content + def test_set_addons_subevent(self): with scopes_disabled(): self.event.has_subevents = True diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 9660ab5af..6f2450c78 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -364,6 +364,30 @@ class ItemDisplayTest(EventTestMixin, SoupTest): self.assertNotIn("Early-bird", doc.select("section:nth-of-type(1) div:nth-of-type(1)")[0].text) self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text) + def test_hidden_if_available(self): + with scopes_disabled(): + q = Quota.objects.create(event=self.event, name='Early-bird', size=10) + q2 = Quota.objects.create(event=self.event, name='Late-bird', size=10) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=12) + item2 = Item.objects.create(event=self.event, name='Late-bird ticket', default_price=12, + hidden_if_available=q) + q.items.add(item) + q2.items.add(item2) + self.event.settings.hide_sold_out = True + + doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text) + self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text) + self.assertNotIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text) + + q.size = 0 + q.save() + + doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertNotIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text) + self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text) + self.assertIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text) + def test_bundle_sold_out(self): with scopes_disabled(): q = Quota.objects.create(event=self.event, name='Quota', size=2)