From d267dfc682000dba84b0d3c5b9895faa34d8451a Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 11 Dec 2018 13:59:49 +0100 Subject: [PATCH] Fix #785 -- Show availability in (sub)event list (#1112) --- doc/spelling_wordlist.txt | 1 + src/pretix/base/models/event.py | 84 ++++++++- src/pretix/base/models/items.py | 50 ++++- src/pretix/base/services/quotas.py | 6 +- src/pretix/base/settings.py | 4 + src/pretix/control/forms/organizer.py | 7 + .../pretixcontrol/organizers/display.html | 1 + src/pretix/helpers/database.py | 19 ++ src/pretix/presale/forms/checkout.py | 12 +- .../event/fragment_subevent_calendar.html | 2 +- .../event/fragment_subevent_list.html | 14 +- .../pretixpresale/fragment_calendar.html | 12 +- .../pretixpresale/organizers/calendar.html | 2 +- .../pretixpresale/organizers/index.html | 35 +++- src/pretix/presale/views/event.py | 31 ++- src/pretix/presale/views/organizer.py | 8 +- src/tests/base/test_models.py | 178 ++++++++++++++++++ 17 files changed, 421 insertions(+), 45 deletions(-) diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index fed998c2b9..03adb3267b 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -6,6 +6,7 @@ api auditability auth autobuild +availabilities backend backends banktransfer diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 9047712f22..1e06c65884 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -11,7 +11,7 @@ from django.core.files.storage import default_storage from django.core.mail import get_connection from django.core.validators import RegexValidator from django.db import models -from django.db.models import Exists, OuterRef, Q +from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery from django.template.defaultfilters import date as _date from django.utils.crypto import get_random_string from django.utils.functional import cached_property @@ -22,6 +22,7 @@ from i18nfield.fields import I18nCharField, I18nTextField from pretix.base.models.base import LoggedModel from pretix.base.reldate import RelativeDateWrapper from pretix.base.validators import EventSlugBlacklistValidator +from pretix.helpers.database import GroupConcat from pretix.helpers.daterange import daterange from pretix.helpers.json import safe_string @@ -159,6 +160,79 @@ class EventMixin: return safe_string(json.dumps(eventdict)) + @classmethod + def annotated(cls, qs, channel='web'): + from pretix.base.models import Item, ItemVariation, Quota + + sq_active_item = Item.objects.filter_available(channel=channel).filter( + Q(variations__isnull=True) + & Q(quotas__pk=OuterRef('pk')) + ).order_by().values_list('quotas__pk').annotate( + items=GroupConcat('pk', delimiter=',') + ).values('items') + sq_active_variation = ItemVariation.objects.filter( + Q(active=True) + & Q(item__active=True) + & Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now())) + & Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now())) + & Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False)) + & Q(item__sales_channels__contains=channel) + & Q(item__hide_without_voucher=False) # TODO: does this make sense? + & Q(quotas__pk=OuterRef('pk')) + ).order_by().values_list('quotas__pk').annotate( + items=GroupConcat('pk', delimiter=',') + ).values('items') + return qs.prefetch_related( + Prefetch( + 'quotas', + to_attr='active_quotas', + queryset=Quota.objects.annotate( + active_items=Subquery(sq_active_item, output_field=models.TextField()), + active_variations=Subquery(sq_active_variation, output_field=models.TextField()), + ).exclude( + Q(active_items="") & Q(active_variations="") + ) + ) + ) + + @cached_property + def best_availability_state(self): + 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() + for q in self.active_quotas: + res = 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_available.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 not self.active_quotas: + return 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 + @settings_hierarkey.add(parent_field='organizer', cache_namespace='event') class Event(EventMixin, LoggedModel): @@ -572,8 +646,10 @@ class Event(EventMixin, LoggedModel): ) ).order_by('date_from', 'name') - @property - def subevent_list_subevents(self): + def subevents_annotated(self, channel): + return SubEvent.annotated(self.subevents, channel) + + def subevents_sorted(self, queryset): ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str) orderfields = { 'date_ascending': ('date_from', 'name'), @@ -581,7 +657,7 @@ class Event(EventMixin, LoggedModel): 'name_ascending': ('name', 'date_from'), 'name_descending': ('-name', 'date_from'), }[ordering] - subevs = self.subevents.filter( + subevs = queryset.filter( Q(active=True) & ( Q(Q(date_to__isnull=True) & Q(date_from__gte=now())) | Q(date_to__gte=now()) diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 6386217757..1d9d6d0c87 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -153,6 +153,30 @@ class SubEventItemVariation(models.Model): self.subevent.event.cache.clear() +class ItemQuerySet(models.QuerySet): + def filter_available(self, channel='web', voucher=None, allow_addons=False): + q = ( + # IMPORTANT: If this is updated, also update the ItemVariation query + # in models/event.py: EventMixin.annotated() + Q(active=True) + & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) + & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) + & Q(sales_channels__contains=channel) + ) + if not allow_addons: + q &= Q(Q(category__isnull=True) | Q(category__is_addon=False)) + qs = self.filter(q) + + vouchq = Q(hide_without_voucher=False) + if voucher: + if voucher.item_id: + vouchq |= Q(pk=voucher.item_id) + qs = qs.filter(pk=voucher.item_id) + elif voucher.quota_id: + qs = qs.filter(quotas__in=[voucher.quota_id]) + return qs.filter(vouchq) + + class Item(LoggedModel): """ An item is a thing which can be sold. It belongs to an event and may or may not belong to a category. @@ -200,6 +224,8 @@ class Item(LoggedModel): :type sales_channels: bool """ + objects = ItemQuerySet.as_manager() + event = models.ForeignKey( Event, on_delete=models.PROTECT, @@ -930,6 +956,16 @@ class Quota(LoggedModel): :type size: int :param items: The set of :py:class:`Item` objects this quota applies to :param variations: The set of :py:class:`ItemVariation` objects this quota applies to + + This model keeps a cache of the quota availability that is used in places where up-to-date + data is not important. This cache might be out of date even though a more recent quota was + calculated. This is intentional to keep database writes low. Currently, the cached values + are written whenever the quota is being calculated throughout the system and the cache is + at least 120 seconds old or if the new value is qualitatively "better" than the cached one + (i.e. more free quota). + + There's also a cronjob that refreshes the cache of every quota if there is any log entry in + the event that is newer than the quota's cached time. """ AVAILABILITY_GONE = 0 @@ -1012,6 +1048,15 @@ class Quota(LoggedModel): This method is used to determine whether Items or ItemVariations belonging to this quota should currently be available for sale. + :param count_waitinglist: Whether or not take waiting list reservations into account. Defaults + to ``True``. + :param _cache: A dictionary mapping quota IDs to availabilities. If this quota is already + contained in that dictionary, this value will be used. Otherwise, the dict + will be populated accordingly. + :param allow_cache: Allow for values to be returned from the longer-term cache, see also + the documentation of this model class. Only works if ``count_waitinglist`` is + set to ``True``. + :returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants and the second is the number of available tickets. """ @@ -1027,7 +1072,10 @@ class Quota(LoggedModel): res = self._availability(now_dt, count_waitinglist) self.event.cache.delete('item_quota_cache') - if count_waitinglist and not self.cache_is_hot(now_dt): + rewrite_cache = count_waitinglist and ( + not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state + ) + if rewrite_cache: self.cached_availability_state = res[0] self.cached_availability_number = res[1] self.cached_availability_time = now_dt diff --git a/src/pretix/base/services/quotas.py b/src/pretix/base/services/quotas.py index 6c90058ecb..99798e0c74 100644 --- a/src/pretix/base/services/quotas.py +++ b/src/pretix/base/services/quotas.py @@ -1,6 +1,9 @@ +from datetime import timedelta + from django.db import models from django.db.models import F, Max, OuterRef, Q, Subquery from django.dispatch import receiver +from django.utils.timezone import now from pretix.base.models import LogEntry, Quota from pretix.celery_app import app @@ -26,7 +29,8 @@ def refresh_quota_caches(): last_activity=Subquery(last_activity, output_field=models.DateTimeField()) ).filter( Q(cached_availability_time__isnull=True) | - Q(cached_availability_time__lt=F('last_activity')) + Q(cached_availability_time__lt=F('last_activity')) | + Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7)) ) for q in quotas: q.availability() diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 9c452529b5..7edd2522e2 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -204,6 +204,10 @@ DEFAULTS = { 'default': 'True', 'type': bool }, + 'event_list_availability': { + 'default': 'True', + 'type': bool + }, 'event_list_type': { 'default': 'list', 'type': str diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 46b776ba3c..d3983d0fa3 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -238,6 +238,13 @@ class OrganizerDisplaySettingsForm(SettingsForm): ('calendar', _('Calendar')) ) ) + event_list_availability = forms.BooleanField( + label=_('Show availability in event overviews'), + help_text=_('If checked, the list of events will show if events are sold out. This might ' + 'make for longer page loading times if you have lots of events and the shown status might be out ' + 'of date for up to two minutes.'), + required=False + ) organizer_link_back = forms.BooleanField( label=_('Link back to organizer overview on all event pages'), required=False diff --git a/src/pretix/control/templates/pretixcontrol/organizers/display.html b/src/pretix/control/templates/pretixcontrol/organizers/display.html index aebd0c8ee2..d141e3e1a1 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/display.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/display.html @@ -12,6 +12,7 @@ {% bootstrap_field form.organizer_logo_image layout="control" %} {% bootstrap_field form.organizer_homepage_text layout="control" %} {% bootstrap_field form.event_list_type layout="control" %} + {% bootstrap_field form.event_list_availability layout="control" %} {% bootstrap_field form.organizer_link_back layout="control" %}
diff --git a/src/pretix/helpers/database.py b/src/pretix/helpers/database.py index e8af8bff2c..ed01881987 100644 --- a/src/pretix/helpers/database.py +++ b/src/pretix/helpers/database.py @@ -1,6 +1,7 @@ import contextlib from django.db import transaction +from django.db.models import Aggregate from django.db.models.expressions import OrderBy @@ -57,3 +58,21 @@ class FixedOrderBy(OrderBy): template = template or self.template params = params * template.count('%(expression)s') return (template % placeholders).rstrip(), params + + +class GroupConcat(Aggregate): + function = 'group_concat' + template = '%(function)s(%(field)s, "%(separator)s")' + + def __init__(self, *expressions, **extra): + if 'separator' not in extra: + # For PostgreSQL separator is an obligatory + extra.update({'separator': ','}) + super().__init__(*expressions, **extra) + + def as_postgresql(self, compiler, connection): + return super().as_sql( + compiler, connection, + function='string_agg', + template="%(function)s(%(field)s::text, '%(separator)s')", + ) diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index da010a7e46..101c49f465 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -2,10 +2,9 @@ from itertools import chain from django import forms from django.core.exceptions import ValidationError -from django.db.models import Count, Prefetch, Q +from django.db.models import Count, Prefetch from django.utils.encoding import force_text from django.utils.formats import number_format -from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from pretix.base.forms.questions import ( @@ -204,12 +203,9 @@ class AddOnsForm(forms.Form): ckey = '{}-{}'.format(subevent.pk if subevent else 0, category.pk) if ckey not in item_cache: # Get all items to possibly show - items = category.items.filter( - Q(active=True) - & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) - & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) - & Q(hide_without_voucher=False) - & Q(sales_channels__contains=self.sales_channel) + items = category.items.filter_available( + channel=self.sales_channel, + allow_addons=True ).select_related('tax_rule').prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_calendar.html b/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_calendar.html index a06a1d5fbb..469997387a 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_calendar.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_calendar.html @@ -38,4 +38,4 @@ -{% include "pretixpresale/fragment_calendar.html" %} +{% include "pretixpresale/fragment_calendar.html" with show_avail=event.settings.event_list_availability %} 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 172cf619e7..d93cc75b43 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html @@ -1,6 +1,6 @@ {% load i18n %} {% load eventurl %} -{% for subev in event.subevent_list_subevents %} +{% for subev in subevent_list %}
@@ -16,7 +16,17 @@ {% endif %}
- {% if subev.presale_is_running %} + {% if subev.presale_is_running and event.settings.event_list_availability %} + {% if subev.best_availability_state == 100 %} + {% trans "Tickets on sale" %} + {% elif event.settings.waiting_list_enabled and subev.best_availability_state >= 0 %} + {% trans "Waiting list" %} + {% elif subev.best_availability_state == 20 %} + {% trans "Reserved" %} + {% elif subev.best_availability_state < 20 %} + {% trans "Sold out" %} + {% endif %} + {% elif subev.presale_is_running %} {% trans "Tickets on sale" %} {% elif subev.presale_has_ended %} {% trans "Sale over" %} diff --git a/src/pretix/presale/templates/pretixpresale/fragment_calendar.html b/src/pretix/presale/templates/pretixpresale/fragment_calendar.html index 1a4da7106a..a6c26710e8 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_calendar.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_calendar.html @@ -38,7 +38,17 @@ {% endif %} - {% if event.event.presale_is_running %} + {% if event.event.presale_is_running and show_avail %} + {% if event.event.best_availability_state == 100 %} + {% trans "Tickets on sale" %} + {% elif event.event.settings.waiting_list_enabled and event.event.best_availability_state >= 0 %} + {% trans "Waiting list" %} + {% elif event.event.best_availability_state == 20 %} + {% trans "Reserved" %} + {% elif event.event.best_availability_state < 20 %} + {% trans "Sold out" %} + {% endif %} + {% elif event.event.presale_is_running %} {% trans "Tickets on sale" %} {% elif event.event.presale_has_ended %} {% trans "Sale over" %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html index 26a722cdb3..0141560d8b 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html @@ -64,7 +64,7 @@
- {% include "pretixpresale/fragment_calendar.html" %} + {% include "pretixpresale/fragment_calendar.html" with show_avail=request.organizer.settings.event_list_availability %} {% if multiple_timezones %}
diff --git a/src/pretix/presale/templates/pretixpresale/organizers/index.html b/src/pretix/presale/templates/pretixpresale/organizers/index.html index 85c62aca18..d6eda6fca4 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/index.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/index.html @@ -45,19 +45,50 @@ {% trans "Name" %} {% trans "Date" %} + {% trans "Status" %} {% for e in events %}{% eventurl e "presale:event.index" as url %} - {{ e.name }} + + {{ e.name }} + {{ e.daterange|default:e.get_date_range_display }} + + {% if e.has_subevents %} + {% trans "Event series" %} + {% elif e.presale_is_running and request.organizer.settings.event_list_availability %} + {% if e.best_availability_state == 100 %} + {% trans "Tickets on sale" %} + {% elif e.settings.waiting_list_enabled and e.best_availability_state >= 0 %} + {% trans "Waiting list" %} + {% elif e.best_availability_state == 20 %} + {% trans "Reserved" %} + {% elif e.best_availability_state < 20 %} + {% trans "Sold out" %} + {% endif %} + {% elif e.presale_is_running %} + {% trans "Tickets on sale" %} + {% elif e.presale_has_ended %} + {% trans "Sale over" %} + {% elif e.settings.presale_start_show_date %} + + {% blocktrans trimmed with date=subev.presale_start|date:"SHORT_DATE_FORMAT" %} + Sale starts {{ date }} + {% endblocktrans %} + + {% else %} + {% trans "Not yet on sale" %} + {% endif %} + - {% if e.presale_is_running %}{% trans "Buy tickets" %} + {% if e.has_subevents %}{% trans "Buy tickets" %} + {% elif e.presale_is_running and e.best_availability_state == 100 %}{% trans "Buy tickets" %} {% else %}{% trans "More info" %} {% endif %} diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index d7e497073c..7d2f952160 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -7,7 +7,7 @@ from importlib import import_module import pytz from django.conf import settings from django.core.exceptions import PermissionDenied -from django.db.models import Count, Prefetch, Q +from django.db.models import Count, Prefetch from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.decorators import method_decorator @@ -48,23 +48,7 @@ def item_group_by_category(items): def get_grouped_items(event, subevent=None, voucher=None, channel='web'): - items = event.items.all().filter( - Q(active=True) - & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) - & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) - & Q(Q(category__isnull=True) | Q(category__is_addon=False)) - & Q(sales_channels__contains=channel) - ) - - vouchq = Q(hide_without_voucher=False) - if voucher: - if voucher.item_id: - vouchq |= Q(pk=voucher.item_id) - items = items.filter(pk=voucher.item_id) - elif voucher.quota_id: - items = items.filter(quotas__in=[voucher.quota_id]) - - items = items.filter(vouchq).select_related( + items = event.items.filter_available(channel=channel, voucher=voucher).select_related( 'category', 'tax_rule', # for re-grouping ).prefetch_related( Prefetch('quotas', @@ -291,12 +275,19 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView): context['after'] = after ebd = defaultdict(list) - add_subevents_for_days(self.request.event.subevents.all(), before, after, ebd, set(), self.request.event, - kwargs.get('cart_namespace')) + add_subevents_for_days( + self.request.event.subevents_annotated(self.request.sales_channel), + before, after, ebd, set(), self.request.event, + kwargs.get('cart_namespace') + ) context['weeks'] = weeks_for_template(ebd, self.year, self.month) context['months'] = [date(self.year, i + 1, 1) for i in range(12)] context['years'] = range(now().year - 2, now().year + 3) + else: + context['subevent_list'] = self.request.event.subevents_sorted( + self.request.event.subevents_annotated(self.request.sales_channel) + ) context['show_cart'] = ( context['cart']['positions'] and ( diff --git a/src/pretix/presale/views/organizer.py b/src/pretix/presale/views/organizer.py index 6076061450..0129fb88fb 100644 --- a/src/pretix/presale/views/organizer.py +++ b/src/pretix/presale/views/organizer.py @@ -128,7 +128,7 @@ class OrganizerIndex(OrganizerViewMixin, ListView): ).annotate( order_from=Coalesce('min_from', 'date_from'), ).order_by('order_from') - qs = filter_qs_by_attr(qs, self.request) + qs = Event.annotated(filter_qs_by_attr(qs, self.request)) return qs def get_context_data(self, **kwargs): @@ -314,14 +314,14 @@ class CalendarView(OrganizerViewMixin, TemplateView): def _events_by_day(self, before, after): ebd = defaultdict(list) timezones = set() - add_events_for_days(self.request, self.request.organizer.events, before, after, ebd, timezones) - add_subevents_for_days(filter_qs_by_attr(SubEvent.objects.filter( + add_events_for_days(self.request, Event.annotated(self.request.organizer.events, 'web'), before, after, ebd, timezones) + add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter( event__organizer=self.request.organizer, event__is_public=True, event__live=True, ).prefetch_related( 'event___settings_objects', 'event__organizer___settings_objects' - ), self.request), before, after, ebd, timezones) + )), self.request), before, after, ebd, timezones) self._multiple_timezones = len(timezones) > 1 return ebd diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 0145f4ecbd..0dfd9c8cb0 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -1085,6 +1085,51 @@ class ItemTest(TestCase): i.active = False assert not i.is_available() + def test_availability_filter(self): + i = Item.objects.create( + event=self.event, name="Ticket", default_price=23, + active=True, available_until=now() + timedelta(days=1), + ) + assert Item.objects.filter_available().exists() + assert not Item.objects.filter_available(channel='foo').exists() + + i.available_from = now() - timedelta(days=1) + i.save() + assert Item.objects.filter_available().exists() + i.available_from = now() + timedelta(days=1) + i.available_until = None + i.save() + assert not Item.objects.filter_available().exists() + i.available_from = None + i.available_until = now() - timedelta(days=1) + i.save() + assert not Item.objects.filter_available().exists() + i.available_from = None + i.available_until = None + i.save() + assert Item.objects.filter_available().exists() + i.active = False + i.save() + assert not Item.objects.filter_available().exists() + + cat = ItemCategory.objects.create( + event=self.event, name='Foo', is_addon=True + ) + i.active = True + i.category = cat + i.save() + assert not Item.objects.filter_available().exists() + assert Item.objects.filter_available(allow_addons=True).exists() + + i.category = None + i.hide_without_voucher = True + i.save() + v = Voucher.objects.create( + event=self.event, item=i, + ) + assert not Item.objects.filter_available().exists() + assert Item.objects.filter_available(voucher=v).exists() + class EventTest(TestCase): @classmethod @@ -1207,6 +1252,100 @@ class EventTest(TestCase): assert event.presale_has_ended assert not event.presale_is_running + def test_active_quotas_annotation(self): + event = Event.objects.create( + organizer=self.organizer, name='Download', slug='download', + date_from=now() + ) + q = Quota.objects.create(event=event, name='Quota', size=2) + item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True) + item2 = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=False) + q.items.add(item) + q.items.add(item2) + assert Event.annotated(Event.objects).first().active_quotas == [q] + assert Event.annotated(Event.objects, 'foo').first().active_quotas == [] + + def test_active_quotas_annotation_product_inactive(self): + event = Event.objects.create( + organizer=self.organizer, name='Download', slug='download', + date_from=now() + ) + q = Quota.objects.create(event=event, name='Quota', size=2) + item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=False) + q.items.add(item) + assert Event.annotated(Event.objects).first().active_quotas == [] + + def test_active_quotas_annotation_product_addon(self): + event = Event.objects.create( + organizer=self.organizer, name='Download', slug='download', + date_from=now() + ) + q = Quota.objects.create(event=event, name='Quota', size=2) + item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True) + cat = ItemCategory.objects.create( + event=event, name='Foo', is_addon=True + ) + item.category = cat + item.save() + q.items.add(item) + assert Event.annotated(Event.objects).first().active_quotas == [] + + def test_active_quotas_annotation_product_unavailable(self): + event = Event.objects.create( + organizer=self.organizer, name='Download', slug='download', + date_from=now() + ) + q = Quota.objects.create(event=event, name='Quota', size=2) + item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True, available_until=now() - timedelta(days=1)) + q.items.add(item) + assert Event.annotated(Event.objects).first().active_quotas == [] + + def test_active_quotas_annotation_variation_not_in_quota(self): + event = Event.objects.create( + organizer=self.organizer, name='Download', slug='download', + date_from=now() + ) + q = Quota.objects.create(event=event, name='Quota', size=2) + item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True) + item.variations.create(value="foo") + q.items.add(item) + assert Event.annotated(Event.objects).first().active_quotas == [] + + def test_active_quotas_annotation_variation(self): + event = Event.objects.create( + organizer=self.organizer, name='Download', slug='download', + date_from=now() + ) + q = Quota.objects.create(event=event, name='Quota', size=2) + item = Item.objects.create(event=event, name='Early-bird ticket', default_price=0, active=True) + v = item.variations.create(value="foo") + item.variations.create(value="bar") + q.items.add(item) + q.variations.add(v) + assert Event.annotated(Event.objects).first().active_quotas == [q] + item.available_until = now() - timedelta(days=1) + item.save() + assert Event.annotated(Event.objects).first().active_quotas == [] + item.available_until = None + item.available_from = now() + timedelta(days=1) + item.save() + assert Event.annotated(Event.objects).first().active_quotas == [] + item.available_until = None + item.available_from = None + item.active = False + item.save() + assert Event.annotated(Event.objects).first().active_quotas == [] + item.active = True + item.save() + assert Event.annotated(Event.objects).first().active_quotas == [q] + assert Event.annotated(Event.objects, 'foo').first().active_quotas == [] + v.active = False + v.save() + assert Event.annotated(Event.objects).first().active_quotas == [] + item.hide_without_voucher = True + item.save() + assert Event.annotated(Event.objects).first().active_quotas == [] + class SubEventTest(TestCase): @classmethod @@ -1242,6 +1381,45 @@ class SubEventTest(TestCase): v.pk: Decimal('30.00') } + def test_active_quotas_annotation(self): + q = Quota.objects.create(event=self.event, name='Quota', size=2, + subevent=self.se) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True) + q.items.add(item) + assert SubEvent.annotated(SubEvent.objects).first().active_quotas == [q] + assert SubEvent.annotated(SubEvent.objects, 'foo').first().active_quotas == [] + + def test_active_quotas_annotation_no_interference(self): + se2 = SubEvent.objects.create( + name='Testsub', date_from=now(), event=self.event + ) + q = Quota.objects.create(event=self.event, name='Quota', size=2, + subevent=se2) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True) + q.items.add(item) + assert SubEvent.annotated(SubEvent.objects).filter(pk=self.se.pk).first().active_quotas == [] + assert SubEvent.annotated(SubEvent.objects).filter(pk=se2.pk).first().active_quotas == [q] + + 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) + 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, + 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 + 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 + class CachedFileTestCase(TestCase): def test_file_handling(self):