Fix #1251 -- Event list/calendar: Show "event almost sold out" state (#3063)

Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2023-02-01 13:20:06 +01:00
committed by GitHub
parent aeb5c52bfe
commit 8bba1a2ea6
14 changed files with 248 additions and 69 deletions

View File

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

View File

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

View File

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

View File

@@ -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" %}

View File

@@ -21,11 +21,15 @@
<div class="col-md-2 text-right flip">
{% if subev.presale_is_running and event.settings.event_list_availability %}
{% if subev.best_availability_state == 100 %}
<span class="label label-success">{% trans "Book now" %}</span>
{% if subev.best_availability_is_low %}
<span class="label label-success-warning">{% trans "Few tickets left" %}</span>
{% else %}
<span class="label label-success">{% trans "Book now" %}</span>
{% endif %}
{% elif event.settings.waiting_list_enabled and subev.best_availability_state >= 0 %}
<span class="label label-warning">{% trans "Waiting list" %}</span>
{% elif subev.best_availability_state == 20 %}
<span class="label label-warning">{% trans "Reserved" %}</span>
<span class="label label-danger">{% trans "Reserved" %}</span>
{% elif subev.best_availability_state < 20 %}
{% if subev.has_paid_item %}
<span class="label label-danger">{% trans "Sold out" %}</span>

View File

@@ -27,7 +27,7 @@
<li><a class="event {% if event.continued %}continued{% endif %} {% spaceless %}
{% if event.event.presale_is_running and show_avail %}
{% if event.event.best_availability_state == 100 %}
available
available {% if event.event.best_availability_is_low %} low {% endif %}
{% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %}
waitinglist
{% elif event.event.best_availability_state == 20 %}
@@ -68,7 +68,11 @@
<span class="event-status">
{% if event.event.presale_is_running and show_avail %}
{% if event.event.best_availability_state == 100 %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Book now" %}
{% if event.event.best_availability_is_low %}
<span class="fa fa-exclamation-circle" aria-hidden="true"></span> {% trans "Few tickets left" %}
{% else %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Book now" %}
{% endif %}
{% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Waiting list" %}
{% elif event.event.best_availability_state == 20 %}

View File

@@ -40,7 +40,7 @@
<a class="event {% if event.continued %}continued{% endif %} {% spaceless %}
{% if event.event.presale_is_running and show_avail %}
{% if event.event.best_availability_state == 100 %}
available
available {% if event.event.best_availability_is_low %} low {% endif %}
{% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %}
waitinglist
{% elif event.event.best_availability_state == 20 %}
@@ -89,12 +89,16 @@
<span class="event-status">
{% if event.event.presale_is_running and show_avail %}
{% if event.event.best_availability_state == 100 %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Book now" %}
{% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %}
{% if event.event.best_availability_is_low %}
<span class="fa fa-exclamation-circle" aria-hidden="true"></span> {% trans "Few tickets left" %}
{% else %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Book now" %}
{% endif %}
{% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Waiting list" %}
{% elif event.event.best_availability_state == 20 %}
{% elif event.event.best_availability_state == 20 %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Reserved" %}
{% elif event.event.best_availability_state < 20 %}
{% elif event.event.best_availability_state < 20 %}
{% if event.event.has_paid_item %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Sold out" %}
{% else %}
@@ -104,9 +108,9 @@
{% endif %}
{% elif event.event.presale_is_running %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Book now" %}
{% elif event.event.presale_has_ended %}
{% elif event.event.presale_has_ended %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% 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 %}
<span class="fa fa-ticket" aria-hidden="true"></span>
{% blocktrans with start_date=event.event.presale_start|date:"SHORT_DATE_FORMAT" %}
from {{ start_date }}

View File

@@ -12,7 +12,7 @@
<li><a class="event {% if event.continued %}continued{% endif %} {% spaceless %}
{% if event.event.presale_is_running and show_avail %}
{% if event.event.best_availability_state == 100 %}
available
available {% if event.event.best_availability_is_low %} low {% endif %}
{% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %}
waitinglist
{% elif event.event.best_availability_state == 20 %}
@@ -53,7 +53,11 @@
<span class="event-status">
{% if event.event.presale_is_running and show_avail %}
{% if event.event.best_availability_state == 100 %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Book now" %}
{% if event.event.best_availability_is_low %}
<span class="fa fa-exclamation-circle" aria-hidden="true"></span> {% trans "Few tickets left" %}
{% else %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Book now" %}
{% endif %}
{% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %}
<span class="fa fa-ticket" aria-hidden="true"></span> {% trans "Waiting list" %}
{% elif event.event.best_availability_state == 20 %}

View File

@@ -81,11 +81,15 @@
<span class="label label-default">{% trans "Event series" %}</span>
{% elif e.presale_is_running and request.organizer.settings.event_list_availability %}
{% if e.best_availability_state == 100 %}
<span class="label label-success">{% trans "Book now" %}</span>
{% if e.best_availability_is_low %}
<span class="label label-success-warning">{% trans "Few tickets left" %}</span>
{% else %}
<span class="label label-success">{% trans "Book now" %}</span>
{% endif %}
{% elif e.settings.waiting_list_enabled and e.best_availability_state >= 0 %}
<span class="label label-warning">{% trans "Waiting list" %}</span>
{% elif e.best_availability_state == 20 %}
<span class="label label-warning">{% trans "Reserved" %}</span>
<span class="label label-danger">{% trans "Reserved" %}</span>
{% elif e.best_availability_state < 20 %}
{% if e.has_paid_item %}
<span class="label label-danger">{% trans "Sold out" %}</span>

View File

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

View File

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

View File

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

View File

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

View File

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