From 8bba1a2ea620b2d04d1781ff5df43aba13b3566c Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 1 Feb 2023 13:20:06 +0100 Subject: [PATCH] Fix #1251 -- Event list/calendar: Show "event almost sold out" state (#3063) Co-authored-by: Richard Schreiber --- src/pretix/base/models/event.py | 123 ++++++++++++------ src/pretix/base/settings.py | 19 +++ src/pretix/control/forms/event.py | 1 + .../pretixcontrol/event/settings.html | 3 +- .../event/fragment_subevent_list.html | 8 +- .../pretixpresale/fragment_calendar.html | 8 +- .../pretixpresale/fragment_day_calendar.html | 18 ++- .../pretixpresale/fragment_week_calendar.html | 8 +- .../pretixpresale/organizers/index.html | 8 +- src/pretix/presale/views/widget.py | 13 +- .../static/pretixpresale/scss/_calendar.scss | 23 +++- .../static/pretixpresale/scss/_theme.scss | 30 +++++ .../static/pretixpresale/scss/widget.scss | 6 + src/tests/base/test_models.py | 49 ++++++- 14 files changed, 248 insertions(+), 69 deletions(-) diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 46bf9b744a..f2e45e4901 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -36,7 +36,7 @@ import logging import os import string import uuid -from collections import OrderedDict +from collections import Counter, OrderedDict, defaultdict from datetime import datetime, time, timedelta from operator import attrgetter from urllib.parse import urljoin @@ -340,64 +340,104 @@ class EventMixin: ) ) - @cached_property + @property def best_availability_state(self): + return self.best_availability[0] + + @property + def best_availability_is_low(self): + """ + Returns ``True`` if the availability of tickets in this event is lower than the percentage + given in setting ``low_availability_percentage``. + """ + if not self.settings.low_availability_percentage: + return False + ba = self.best_availability + if ba[1] is None or not ba[2]: + return False + + percentage = ba[1] / ba[2] * 100 + return percentage < self.settings.low_availability_percentage + + @cached_property + def best_availability(self): + """ + Returns a 3-tuple of + + - The availability state of this event (one of the ``Quota.AVAILABILITY_*`` constants) + - The number of tickets currently available (or ``None``) + - The number of tickets "originally" available (or ``None``) + + This can only be called on objects obtained through a queryset that has been passed through ``.annotated()``. + """ from .items import Quota if not hasattr(self, 'active_quotas'): raise TypeError("Call this only if you fetched the subevents via Event/SubEvent.annotated()") - items_available = set() - vars_available = set() - items_reserved = set() - vars_reserved = set() - items_gone = set() - vars_gone = set() - items_disabled = set() - vars_disabled = set() if hasattr(self, 'disabled_items'): # SubEventItem items_disabled = set(self.disabled_items.split(",")) + else: + items_disabled = set() if hasattr(self, 'disabled_vars'): # SubEventItemVariation vars_disabled = set(self.disabled_vars.split(",")) + else: + vars_disabled = set() + # Compute the availability of all quotas and build a item→quotas mapping with all non-disabled items r = getattr(self, '_quota_cache', {}) + quotas_for_item = defaultdict(list) + quotas_for_variation = defaultdict(list) for q in self.active_quotas: - res = r[q] if q in r else q.availability(allow_cache=True) + if q not in r: + r[q] = q.availability(allow_cache=True) - if res[0] == Quota.AVAILABILITY_OK: - if q.active_items: - items_available.update(q.active_items.split(",")) - if q.active_variations: - vars_available.update(q.active_variations.split(",")) - elif res[0] == Quota.AVAILABILITY_RESERVED: - if q.active_items: - items_reserved.update(q.active_items.split(",")) - if q.active_variations: - vars_reserved.update(q.active_variations.split(",")) - elif res[0] < Quota.AVAILABILITY_RESERVED: - if q.active_items: - items_gone.update(q.active_items.split(",")) - if q.active_variations: - vars_gone.update(q.active_variations.split(",")) + if q.active_items: + for item_id in q.active_items.split(","): + if item_id not in items_disabled: + quotas_for_item[item_id].append(q) + if q.active_variations: + for var_id in q.active_variations.split(","): + if var_id not in vars_disabled: + quotas_for_variation[var_id].append(q) - items_available -= items_disabled - items_reserved -= items_disabled - items_gone -= items_disabled - vars_available -= vars_disabled - vars_reserved -= vars_disabled - vars_gone -= vars_disabled + if not self.active_quotas or (not quotas_for_item and not quotas_for_variation): + # No item is enabled for this event, treat the event as "unknown" + return None, None, None - if not self.active_quotas or ( - not items_available and not items_reserved and not items_gone and not vars_gone and not vars_available and not vars_reserved - ): - return None + # We iterate over all items and variations and keep track of + # - `best_state_found` - the best availability state we have seen so far. If one item is available, the event is available! + # - `num_tickets_found` - the number of tickets currently available in total. We sum up all the items and variations, but keep + # track of them per-quota in `quota_used_for_found_tickets` to make sure we don't count the same tickets twice if two or more + # items share the same quota + # - `num_tickets_possible` - basically the same thing, just with the total size of quotas instead of their currently availability + # since we need that for the percentage calculation + best_state_found = Quota.AVAILABILITY_GONE + num_tickets_found = 0 + num_tickets_possible = 0 + quota_used_for_found_tickets = Counter() + quota_used_for_possible_tickets = Counter() + for quota_list in list(quotas_for_item.values()) + list(quotas_for_variation.values()): + worst_state_for_ticket = min(r[q][0] for q in quota_list) + quotas_that_are_not_unlimited = [q for q in quota_list if q.size is not None] + if not quotas_that_are_not_unlimited: + # We found an unlimited ticket, no more need to do anything else + return Quota.AVAILABILITY_OK, None, None - if items_available - items_reserved - items_gone or vars_available - vars_reserved - vars_gone: - return Quota.AVAILABILITY_OK - if items_reserved - items_gone or vars_reserved - vars_gone: - return Quota.AVAILABILITY_RESERVED - return Quota.AVAILABILITY_GONE + if worst_state_for_ticket == Quota.AVAILABILITY_OK: + availability_of_this = min(max(0, r[q][1] - quota_used_for_found_tickets[q]) for q in quotas_that_are_not_unlimited) + num_tickets_found += availability_of_this + for q in quota_list: + quota_used_for_found_tickets[q] += availability_of_this + + possible_of_this = min(max(0, q.size - quota_used_for_possible_tickets[q]) for q in quotas_that_are_not_unlimited) + num_tickets_possible += possible_of_this + for q in quota_list: + quota_used_for_possible_tickets[q] += possible_of_this + + best_state_found = max(best_state_found, worst_state_for_ticket) + return best_state_found, num_tickets_found, num_tickets_possible def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False): qs_annotated = self._seats(ignore_voucher=ignore_voucher) @@ -591,6 +631,7 @@ class Event(EventMixin, LoggedModel): self.settings.invoice_email_attachment = True self.settings.name_scheme = 'given_family' self.settings.payment_banktransfer_invoice_immediately = True + self.settings.low_availability_percentage = 10 @property def social_image(self): diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 96d6d5a816..98946961a4 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1308,6 +1308,25 @@ DEFAULTS = { "the email. Does not affect orders performed through other sales channels."), ) }, + 'low_availability_percentage': { + 'default': None, + 'type': int, + 'serializer_class': serializers.IntegerField, + 'form_class': forms.IntegerField, + 'serializer_kwargs': dict( + min_value=0, + max_value=100, + ), + 'form_kwargs': dict( + label=_('Low availability threshold'), + help_text=_('If the availability of tickets falls below this percentage, the event (or a date, if it is an ' + 'event series) will be highlighted to have low availability in the event list or calendar. If ' + 'you keep this option empty, low availability will not be shown publicly.'), + min_value=0, + max_value=100, + required=False + ) + }, 'event_list_availability': { 'default': 'True', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index d53e5e9898..c59eac6420 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -507,6 +507,7 @@ class EventSettingsForm(SettingsForm): 'meta_noindex', 'redirect_to_checkout_directly', 'frontpage_subevent_ordering', + 'low_availability_percentage', 'event_list_type', 'event_list_available_only', 'frontpage_text', diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index c877e51adb..db0490f56c 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -314,7 +314,8 @@ {% if sform.event_list_available_only %} {% bootstrap_field sform.event_list_available_only layout="control" %} {% endif %} - + {% bootstrap_field sform.low_availability_percentage layout="control" %} + {% url "control:organizer.edit" organizer=request.organizer.slug as org_url %} {% propagated request.event org_url "meta_noindex" %} {% bootstrap_field sform.meta_noindex layout="control" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html b/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html index a7606048ab..ceba05e879 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html @@ -21,11 +21,15 @@
{% if subev.presale_is_running and event.settings.event_list_availability %} {% if subev.best_availability_state == 100 %} - {% trans "Book now" %} + {% if subev.best_availability_is_low %} + {% trans "Few tickets left" %} + {% else %} + {% trans "Book now" %} + {% endif %} {% elif event.settings.waiting_list_enabled and subev.best_availability_state >= 0 %} {% trans "Waiting list" %} {% elif subev.best_availability_state == 20 %} - {% trans "Reserved" %} + {% trans "Reserved" %} {% elif subev.best_availability_state < 20 %} {% if subev.has_paid_item %} {% trans "Sold out" %} diff --git a/src/pretix/presale/templates/pretixpresale/fragment_calendar.html b/src/pretix/presale/templates/pretixpresale/fragment_calendar.html index 37ccd5570e..88bb189069 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_calendar.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_calendar.html @@ -27,7 +27,7 @@
  • {% if event.event.presale_is_running and show_avail %} {% if event.event.best_availability_state == 100 %} - {% trans "Book now" %} + {% if event.event.best_availability_is_low %} + {% trans "Few tickets left" %} + {% else %} + {% trans "Book now" %} + {% endif %} {% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %} {% trans "Waiting list" %} {% elif event.event.best_availability_state == 20 %} diff --git a/src/pretix/presale/templates/pretixpresale/fragment_day_calendar.html b/src/pretix/presale/templates/pretixpresale/fragment_day_calendar.html index 4799d22e6a..92097b9ae8 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_day_calendar.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_day_calendar.html @@ -40,7 +40,7 @@ {% if event.event.presale_is_running and show_avail %} {% if event.event.best_availability_state == 100 %} - {% trans "Book now" %} - {% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %} + {% if event.event.best_availability_is_low %} + {% trans "Few tickets left" %} + {% else %} + {% trans "Book now" %} + {% endif %} + {% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %} {% trans "Waiting list" %} - {% elif event.event.best_availability_state == 20 %} + {% elif event.event.best_availability_state == 20 %} {% trans "Reserved" %} - {% elif event.event.best_availability_state < 20 %} + {% elif event.event.best_availability_state < 20 %} {% if event.event.has_paid_item %} {% trans "Sold out" %} {% else %} @@ -104,9 +108,9 @@ {% endif %} {% elif event.event.presale_is_running %} {% trans "Book now" %} - {% elif event.event.presale_has_ended %} + {% elif event.event.presale_has_ended %} {% trans "Sale over" %} - {% elif event.event.settings.presale_start_show_date and event.event.presale_start %} + {% elif event.event.settings.presale_start_show_date and event.event.presale_start %} {% blocktrans with start_date=event.event.presale_start|date:"SHORT_DATE_FORMAT" %} from {{ start_date }} diff --git a/src/pretix/presale/templates/pretixpresale/fragment_week_calendar.html b/src/pretix/presale/templates/pretixpresale/fragment_week_calendar.html index 47b5ab0e92..573822142a 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_week_calendar.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_week_calendar.html @@ -12,7 +12,7 @@
  • {% if event.event.presale_is_running and show_avail %} {% if event.event.best_availability_state == 100 %} - {% trans "Book now" %} + {% if event.event.best_availability_is_low %} + {% trans "Few tickets left" %} + {% else %} + {% trans "Book now" %} + {% endif %} {% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %} {% trans "Waiting list" %} {% elif event.event.best_availability_state == 20 %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/index.html b/src/pretix/presale/templates/pretixpresale/organizers/index.html index afde023908..588d765b0e 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/index.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/index.html @@ -81,11 +81,15 @@ {% trans "Event series" %} {% elif e.presale_is_running and request.organizer.settings.event_list_availability %} {% if e.best_availability_state == 100 %} - {% trans "Book now" %} + {% if e.best_availability_is_low %} + {% trans "Few tickets left" %} + {% else %} + {% trans "Book now" %} + {% endif %} {% elif e.settings.waiting_list_enabled and e.best_availability_state >= 0 %} {% trans "Waiting list" %} {% elif e.best_availability_state == 20 %} - {% trans "Reserved" %} + {% trans "Reserved" %} {% elif e.best_availability_state < 20 %} {% if e.has_paid_item %} {% trans "Sold out" %} diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 1e51abd05f..287a4ae8b9 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -368,15 +368,20 @@ class WidgetAPIProductList(EventListMixin, View): availability = {} if ev.presale_is_running and event.settings.event_list_availability: if ev.best_availability_state == Quota.AVAILABILITY_OK: - availability['color'] = 'green' - availability['text'] = gettext('Book now') - availability['reason'] = 'ok' + if ev.best_availability_is_low: + availability['color'] = 'green' + availability['text'] = gettext('Few tickets left') + availability['reason'] = 'low' + else: + availability['color'] = 'green' + availability['text'] = gettext('Book now') + availability['reason'] = 'ok' elif event.settings.waiting_list_enabled and (ev.best_availability_state is not None and ev.best_availability_state >= 0): availability['color'] = 'orange' availability['text'] = gettext('Waiting list') availability['reason'] = 'waitinglist' elif ev.best_availability_state == Quota.AVAILABILITY_RESERVED: - availability['color'] = 'orange' + availability['color'] = 'red' availability['text'] = gettext('Reserved') availability['reason'] = 'reserved' elif ev.best_availability_state is not None and ev.best_availability_state < Quota.AVAILABILITY_RESERVED: diff --git a/src/pretix/static/pretixpresale/scss/_calendar.scss b/src/pretix/static/pretixpresale/scss/_calendar.scss index e44676f9f9..bb17635f05 100644 --- a/src/pretix/static/pretixpresale/scss/_calendar.scss +++ b/src/pretix/static/pretixpresale/scss/_calendar.scss @@ -58,15 +58,35 @@ } } - &.available, { + &.available { background: lighten($brand-success, 48%); border-color: lighten($brand-success, 30%); border-left-color: $brand-success; color: darken($brand-success, 12%); + &.low { + border-left-color: lighten($brand-warning, 12%); + } + &:hover { background: lighten($brand-success, 50%); border-color: $brand-success; + + &.low { + border-left-color: $brand-warning; + } + } + } + + &.waitinglist { + background: lighten($brand-warning, 41%); + border-color: lighten($brand-warning, 30%); + border-left-color: lighten($brand-warning, 12%); + color: #963; + + &:hover { + background: lighten($brand-warning, 43%); + border-color: $brand-warning; } } @@ -82,7 +102,6 @@ } } - &.available > *:first-child, &.continued > *:first-child, &.soon > *:first-child { diff --git a/src/pretix/static/pretixpresale/scss/_theme.scss b/src/pretix/static/pretixpresale/scss/_theme.scss index f1bc82928e..29dfd50926 100644 --- a/src/pretix/static/pretixpresale/scss/_theme.scss +++ b/src/pretix/static/pretixpresale/scss/_theme.scss @@ -121,3 +121,33 @@ footer { padding-bottom: 0; } } + +.label-success-warning { + @include label-variant($label-success-bg); + + padding-left: 2.5em; + position: relative; + &::before { + font-family: FontAwesome; + text-rendering: auto; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + color: white; + content: $fa-var-exclamation; + background: $label-warning-bg; + + display: block; + + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 2em; + + padding-top: .5em; + text-align: center; + + border-top-left-radius: .25em; + border-bottom-left-radius: .25em; + } +} \ No newline at end of file diff --git a/src/pretix/static/pretixpresale/scss/widget.scss b/src/pretix/static/pretixpresale/scss/widget.scss index 95d2296dda..8b939930f5 100644 --- a/src/pretix/static/pretixpresale/scss/widget.scss +++ b/src/pretix/static/pretixpresale/scss/widget.scss @@ -495,6 +495,12 @@ .pretix-widget-event-availability-red.pretix-widget-event-calendar-event { background-color: $brand-danger; } + .pretix-widget-event-availability-low .pretix-widget-event-list-entry-availability span { + border-left: 10px solid $brand-warning; + } + .pretix-widget-event-availability-low.pretix-widget-event-calendar-event { + border-right: 10px solid $brand-warning; + } .pretix-widget-event-calendar { padding-top: 10px; diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index eb621ad9f6..4522c8feef 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -2295,24 +2295,61 @@ class SubEventTest(TestCase): @classscope(attr='organizer') def test_best_availability(self): - q = Quota.objects.create(event=self.event, name='Quota', size=0, - subevent=self.se) item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True) + o = Order.objects.create( + code='FOO', event=self.event, email='dummy@dummy.test', + status=Order.STATUS_PAID, + datetime=now(), expires=now() + timedelta(days=10), + total=Decimal("30"), locale='en' + ) + OrderPosition.objects.create( + order=o, + item=item, + subevent=self.se, + variation=None, + price=Decimal("12"), + ) + self.event.settings.low_availability_percentage = 60 + + # 1 quota - 1 item + q = Quota.objects.create(event=self.event, name='Quota', size=1, + subevent=self.se) q.items.add(item) obj = SubEvent.annotated(SubEvent.objects).first() assert len(obj.active_quotas) == 1 - assert obj.best_availability_state == Quota.AVAILABILITY_GONE - q2 = Quota.objects.create(event=self.event, name='Quota 2', size=1, + assert obj.best_availability == (Quota.AVAILABILITY_GONE, 0, 1) + + # 2 quotas - 1 item. Lowest quota wins. + q2 = Quota.objects.create(event=self.event, name='Quota 2', size=2, subevent=self.se) q2.items.add(item) obj = SubEvent.annotated(SubEvent.objects).first() assert len(obj.active_quotas) == 2 - assert obj.best_availability_state == Quota.AVAILABILITY_GONE + assert obj.best_availability == (Quota.AVAILABILITY_GONE, 0, 1) + + # 2 quotas - 2 items. Higher quota wins since second item is only connected to second quota. item2 = Item.objects.create(event=self.event, name='Regular ticket', default_price=10, active=True) q2.items.add(item2) obj = SubEvent.annotated(SubEvent.objects).first() assert len(obj.active_quotas) == 2 - assert obj.best_availability_state == Quota.AVAILABILITY_OK + assert obj.best_availability == (Quota.AVAILABILITY_OK, 1, 2) + assert obj.best_availability_is_low + + # 1 quota - 2 items. Quota is not counted twice! + q.size = 10 + q.save() + q2.delete() + obj = SubEvent.annotated(SubEvent.objects).first() + assert len(obj.active_quotas) == 1 + assert obj.best_availability == (Quota.AVAILABILITY_OK, 9, 10) + assert not obj.best_availability_is_low + + # Unlimited quota + q.size = None + q.save() + obj = SubEvent.annotated(SubEvent.objects).first() + assert obj.best_availability == (Quota.AVAILABILITY_OK, None, None) + assert not obj.best_availability_is_low class CachedFileTestCase(TestCase):