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 @@