diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index f4f443371..8b95058fa 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -20,6 +20,7 @@ from pretix.base.models import ( CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, QuestionOption, Quota, ) +from pretix.base.services.quotas import QuotaAvailability from pretix.helpers.dicts import merge_dicts with scopes_disabled(): @@ -533,14 +534,17 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): def availability(self, request, *args, **kwargs): quota = self.get_object() - avail = quota.availability() + qa = QuotaAvailability() + qa.queue(quota) + qa.compute() + avail = qa.results[quota] data = { - 'paid_orders': quota.count_paid_orders(), - 'pending_orders': quota.count_pending_orders(), - 'blocking_vouchers': quota.count_blocking_vouchers(), - 'cart_positions': quota.count_in_cart(), - 'waiting_list': quota.count_waiting_list_pending(), + 'paid_orders': qa.count_paid_orders[quota], + 'pending_orders': qa.count_pending_orders[quota], + 'blocking_vouchers': qa.count_vouchers[quota], + 'cart_positions': qa.count_cart[quota], + 'waiting_list': qa.count_pending_orders[quota], 'available_number': avail[1], 'available': avail[0] == Quota.AVAILABILITY_OK, 'total_size': quota.size, diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index 8e8026173..11a6e0837 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -14,6 +14,7 @@ from pretix.base.models import ( GiftCard, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question, ) from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund +from pretix.base.services.quotas import QuotaAvailability from pretix.base.settings import PERSON_NAME_SCHEMES from ..exporter import ListExporter, MultiSheetListExporter @@ -516,16 +517,21 @@ class QuotaListExporter(ListExporter): ] yield headers - for quota in self.event.quotas.all(): - avail = quota.availability() + quotas = list(self.event.quotas.all()) + qa = QuotaAvailability(full_results=True) + qa.queue(*quotas) + qa.compute() + + for quota in quotas: + avail = qa.results[quota] row = [ quota.name, _('Infinite') if quota.size is None else quota.size, - quota.count_paid_orders(), - quota.count_pending_orders(), - quota.count_blocking_vouchers(), - quota.count_in_cart(), - quota.count_waiting_list_pending(), + qa.count_paid_orders[quota], + qa.count_pending_orders[quota], + qa.count_vouchers[quota], + qa.count_cart[quota], + qa.count_waitinglist[quota], _('Infinite') if avail[1] is None else avail[1] ] yield row diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 5ef890e08..3785f981b 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -214,8 +214,10 @@ class EventMixin: vars_reserved = set() items_gone = set() vars_gone = set() + + r = getattr(self, '_quota_cache', {}) for q in self.active_quotas: - res = q.availability(allow_cache=True) + res = r[q] if q in r else q.availability(allow_cache=True) if res[0] == Quota.AVAILABILITY_OK: if q.active_items: diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 897b55818..29a0053ee 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -7,11 +7,10 @@ from typing import Tuple import dateutil.parser import pytz -from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import RegexValidator from django.db import models -from django.db.models import F, Func, Q, Sum +from django.db.models import Q from django.utils import formats from django.utils.crypto import get_random_string from django.utils.functional import cached_property @@ -25,7 +24,6 @@ from pretix.base.models import fields from pretix.base.models.base import LoggedModel from pretix.base.models.fields import MultiStringField from pretix.base.models.tax import TaxedPrice -from pretix.base.signals import quota_availability from .event import Event, SubEvent @@ -1350,6 +1348,7 @@ class Quota(LoggedModel): self.event.cache.clear() def save(self, *args, **kwargs): + # This is *not* called when the db-level cache is upated, since we use bulk_update there clear_cache = kwargs.pop('clear_cache', True) super().save(*args, **kwargs) if self.event and clear_cache: @@ -1384,6 +1383,8 @@ class Quota(LoggedModel): :returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants and the second is the number of available tickets. """ + from ..services.quotas import QuotaAvailability + if allow_cache and self.cache_is_hot() and count_waitinglist: return self.cached_availability_state, self.cached_availability_number @@ -1392,141 +1393,16 @@ class Quota(LoggedModel): if _cache is not None and self.pk in _cache: return _cache[self.pk] - now_dt = now_dt or now() - res = self._availability(now_dt, count_waitinglist) - for recv, resp in quota_availability.send(sender=self.event, quota=self, result=res, - count_waitinglist=count_waitinglist): - res = resp - - if res[0] <= Quota.AVAILABILITY_ORDERED and self.close_when_sold_out and not self.closed: - self.closed = True - self.save(update_fields=['closed']) - self.log_action('pretix.event.quota.closed') - - self.event.cache.delete('item_quota_cache') - 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 - if self.size is None: - self.cached_availability_paid_orders = self.count_paid_orders() - self.save( - update_fields=[ - 'cached_availability_state', 'cached_availability_number', 'cached_availability_time', - 'cached_availability_paid_orders' - ], - clear_cache=False, - using='default' - ) + qa = QuotaAvailability(count_waitinglist=count_waitinglist, early_out=False) + qa.queue(self) + qa.compute(now_dt=now_dt) + res = qa.results[self] if _cache is not None: _cache[self.pk] = res _cache['_count_waitinglist'] = count_waitinglist return res - def _availability(self, now_dt: datetime=None, count_waitinglist=True, ignore_closed=False): - now_dt = now_dt or now() - if self.closed and not ignore_closed: - return Quota.AVAILABILITY_ORDERED, 0 - - size_left = self.size - if size_left is None: - return Quota.AVAILABILITY_OK, None - - paid_orders = self.count_paid_orders() - self.cached_availability_paid_orders = paid_orders - size_left -= paid_orders - if size_left <= 0: - return Quota.AVAILABILITY_GONE, 0 - - size_left -= self.count_pending_orders() - if size_left <= 0: - return Quota.AVAILABILITY_ORDERED, 0 - - size_left -= self.count_blocking_vouchers(now_dt) - if size_left <= 0: - return Quota.AVAILABILITY_ORDERED, 0 - - if count_waitinglist: - size_left -= self.count_waiting_list_pending() - if size_left <= 0: - return Quota.AVAILABILITY_ORDERED, 0 - - size_left -= self.count_in_cart(now_dt) - if size_left <= 0: - return Quota.AVAILABILITY_RESERVED, 0 - - return Quota.AVAILABILITY_OK, size_left - - def count_blocking_vouchers(self, now_dt: datetime=None) -> int: - from pretix.base.models import Voucher - - now_dt = now_dt or now() - if 'sqlite3' in settings.DATABASES['default']['ENGINE']: - func = 'MAX' - else: # NOQA - func = 'GREATEST' - - return Voucher.objects.filter( - Q(event=self.event) & Q(subevent=self.subevent) & - Q(block_quota=True) & - Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) & - Q(Q(self._position_lookup) | Q(quota=self)) - ).values('id').aggregate( - free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func)) - )['free'] or 0 - - def count_waiting_list_pending(self) -> int: - from pretix.base.models import WaitingListEntry - return WaitingListEntry.objects.filter( - Q(voucher__isnull=True) & Q(subevent=self.subevent) & - self._position_lookup - ).distinct().count() - - def count_in_cart(self, now_dt: datetime=None) -> int: - from pretix.base.models import CartPosition - - now_dt = now_dt or now() - return CartPosition.objects.filter( - Q(event=self.event) & Q(subevent=self.subevent) & - Q(expires__gte=now_dt) & - Q( - Q(voucher__isnull=True) - | Q(voucher__block_quota=False) - | Q(voucher__valid_until__lt=now_dt) - ) & - self._position_lookup - ).count() - - def count_pending_orders(self) -> dict: - from pretix.base.models import Order, OrderPosition - - # This query has beeen benchmarked against a Count('id', distinct=True) aggregate and won by a small margin. - return OrderPosition.objects.filter( - self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event, subevent=self.subevent - ).count() - - def count_paid_orders(self): - from pretix.base.models import Order, OrderPosition - - return OrderPosition.objects.filter( - self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event, subevent=self.subevent - ).count() - - @cached_property - def _position_lookup(self) -> Q: - return ( - ( # Orders for items which do not have any variations - Q(variation__isnull=True) & - Q(item_id__in=Quota.items.through.objects.filter(quota_id=self.pk).values_list('item_id', flat=True)) - ) | ( # Orders for items which do have any variations - Q(variation__in=Quota.variations.through.objects.filter(quota_id=self.pk).values_list('itemvariation_id', flat=True)) - ) - ) - class QuotaExceededException(Exception): pass @@ -1535,7 +1411,6 @@ class Quota(LoggedModel): for variation in (variations or []): if variation.item not in items: raise ValidationError(_('All variations must belong to an item contained in the items list.')) - break @staticmethod def clean_items(event, items, variations): diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 3bbb55099..771984dc8 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -25,6 +25,7 @@ from pretix.base.reldate import RelativeDateWrapper from pretix.base.services.checkin import _save_answers from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.pricing import get_price +from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.tasks import ProfiledEventTask from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import validate_cart_addons @@ -728,10 +729,14 @@ class CartManager: def _get_quota_availability(self): quotas_ok = defaultdict(int) + qa = QuotaAvailability() + qa.queue(*[k for k, v in self._quota_diff.items() if v > 0]) + qa.compute(now_dt=self.now_dt) for quota, count in self._quota_diff.items(): if count <= 0: quotas_ok[quota] = 0 - avail = quota.availability(self.now_dt) + break + avail = qa.results[quota] if avail[1] is not None and avail[1] < count: quotas_ok[quota] = min(count, avail[1]) else: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 00360f6bb..590d93056 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -44,6 +44,7 @@ from pretix.base.services.invoices import ( from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.mail import SendMailException from pretix.base.services.pricing import get_price +from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask from pretix.base.signals import ( allow_ticket_download, order_approved, order_canceled, order_changed, @@ -1391,10 +1392,13 @@ class OrderChangeManager: raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=v['seat'].name)) def _check_quotas(self): + qa = QuotaAvailability() + qa.queue(*[k for k, v in self._quotadiff.items() if v > 0]) + qa.compute() for quota, diff in self._quotadiff.items(): if diff <= 0: continue - avail = quota.availability() + avail = qa.results[quota] if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff): raise OrderError(self.error_messages['quota'].format(name=quota.name)) diff --git a/src/pretix/base/services/quotas.py b/src/pretix/base/services/quotas.py index 0a182f877..3751d0aae 100644 --- a/src/pretix/base/services/quotas.py +++ b/src/pretix/base/services/quotas.py @@ -1,15 +1,357 @@ +import sys +from collections import Counter, defaultdict from datetime import timedelta from django.conf import settings -from django.db.models import Max, Q +from django.db.models import Count, F, Func, Max, Q, Sum from django.dispatch import receiver from django.utils.timezone import now from django_scopes import scopes_disabled -from pretix.base.models import Event, LogEntry +from pretix.base.models import ( + CartPosition, Event, LogEntry, Order, OrderPosition, Quota, Voucher, + WaitingListEntry, +) from pretix.celery_app import app -from ..signals import periodic_task +from ..signals import periodic_task, quota_availability + + +class QuotaAvailability: + """ + This special object allows so compute the availability of multiple quotas, even across events, and inspect their + results. The maximum number of SQL queries is constant and not dependent on the number of quotas. + + Usage example:: + + qa = QuotaAvailability() + qa.queue(quota1, quota2, …) + qa.compute() + print(qa.results) + + Properties you can access after computation. + + * results (dict mapping quotas to availability tuples) + * count_paid_orders (dict mapping quotas to ints) + * count_paid_orders (dict mapping quotas to ints) + * count_pending_orders (dict mapping quotas to ints) + * count_vouchers (dict mapping quotas to ints) + * count_waitinglist (dict mapping quotas to ints) + * count_cart (dict mapping quotas to ints) + """ + + def __init__(self, count_waitinglist=True, ignore_closed=False, full_results=False, early_out=True): + """ + Initialize a new quota availability calculator + + :param count_waitinglist: If ``True`` (default), the waiting list is considered. If ``False``, it is ignored. + + :param ignore_closed: Quotas have a ``closed`` state that always makes the quota return as sold out. If you set + ``ignore_closed`` to ``True``, we will ignore this completely. Default is ``False``. + + :param full_results: Usually, the computation is as efficient as possible, i.e. if after counting the sold + orders we already see that the quota is sold out, we're not going to count the carts, + since it does not matter. This also means that you will not be able to get that number from + ``.count_cart``. If you want all parts to be calculated (i.e. because you want to show + statistics to the user), pass ``full_results`` and we'll skip that optimization. + items + + :param early_out: Usually, if a quota is ``closed`` or if its ``size`` is ``None`` (i.e. unlimited), we will + not need database access to determine the availability and return it right away. If you set + this to ``False``, however, we will *still* count the number of orders, which is required to + keep the database-level quota cache up to date so backend overviews render quickly. If you + do not care about keeping the cache up to date, you can set this to ``False`` for further + performance improvements. + """ + self._queue = [] + self._count_waitinglist = count_waitinglist + self._ignore_closed = ignore_closed + self._full_results = full_results + self._item_to_quotas = defaultdict(list) + self._var_to_quotas = defaultdict(list) + self._early_out = early_out + self._quota_objects = {} + self.results = {} + self.count_paid_orders = defaultdict(int) + self.count_pending_orders = defaultdict(int) + self.count_vouchers = defaultdict(int) + self.count_waitinglist = defaultdict(int) + self.count_cart = defaultdict(int) + + self.sizes = {} + + def queue(self, *quota): + self._queue += quota + + def compute(self, now_dt=None): + now_dt = now_dt or now() + quotas = list(self._queue) + quotas_original = list(self._queue) + self._queue.clear() + if not quotas: + return + + self._compute(quotas, now_dt) + + for q in quotas_original: + for recv, resp in quota_availability.send(sender=q.event, quota=q, result=self.results[q], + count_waitinglist=self.count_waitinglist): + self.results[q] = resp + + self._close(quotas) + self._write_cache(quotas, now_dt) + + def _write_cache(self, quotas, now_dt): + events = {q.event for q in quotas} + update = [] + for e in events: + e.cache.delete('item_quota_cache') + for q in quotas: + rewrite_cache = self._count_waitinglist and ( + not q.cache_is_hot(now_dt) or self.results[q][0] > q.cached_availability_state + or q.cached_availability_paid_orders is None + ) + if rewrite_cache: + q.cached_availability_state = self.results[q][0] + q.cached_availability_number = self.results[q][1] + q.cached_availability_time = now_dt + if q in self.count_paid_orders: + q.cached_availability_paid_orders = self.count_paid_orders[q] + update.append(q) + if update: + Quota.objects.using('default').bulk_update(update, [ + 'cached_availability_state', 'cached_availability_number', 'cached_availability_time', + 'cached_availability_paid_orders' + ], batch_size=50) + + def _close(self, quotas): + for q in quotas: + if self.results[q][0] <= Quota.AVAILABILITY_ORDERED and q.close_when_sold_out and not q.closed: + q.closed = True + q.save(update_fields=['closed']) + q.log_action('pretix.event.quota.closed') + + def _compute(self, quotas, now_dt): + # Quotas we want to look at now + self.sizes.update({q: q.size for q in quotas}) + + # Some helpful caches + self._quota_objects.update({q.pk: q for q in quotas}) + + # Compute result for closed or unlimited + self._compute_early_outs(quotas) + + if self._early_out: + if not self._full_results: + quotas = [q for q in quotas if q not in self.results] + if not quotas: + return + + size_left = Counter({q: (sys.maxsize if s is None else s) for q, s in self.sizes.items()}) + for q in quotas: + self.count_paid_orders[q] = 0 + self.count_pending_orders[q] = 0 + self.count_cart[q] = 0 + self.count_vouchers[q] = 0 + self.count_waitinglist[q] = 0 + + # Fetch which quotas belong to which items and variations + q_items = Quota.items.through.objects.filter( + quota_id__in=[q.pk for q in quotas] + ).values('quota_id', 'item_id') + for m in q_items: + self._item_to_quotas[m['item_id']].append(self._quota_objects[m['quota_id']]) + + q_vars = Quota.variations.through.objects.filter( + quota_id__in=[q.pk for q in quotas] + ).values('quota_id', 'itemvariation_id') + for m in q_vars: + self._var_to_quotas[m['itemvariation_id']].append(self._quota_objects[m['quota_id']]) + + self._compute_orders(quotas, q_items, q_vars, size_left) + + if not self._full_results: + quotas = [q for q in quotas if q not in self.results] + if not quotas: + return + + self._compute_vouchers(quotas, q_items, q_vars, size_left, now_dt) + + if not self._full_results: + quotas = [q for q in quotas if q not in self.results] + if not quotas: + return + + self._compute_carts(quotas, q_items, q_vars, size_left, now_dt) + + if self._count_waitinglist: + if not self._full_results: + quotas = [q for q in quotas if q not in self.results] + if not quotas: + return + + self._compute_waitinglist(quotas, q_items, q_vars, size_left) + + for q in quotas: + if q not in self.results: + if size_left[q] > 0: + self.results[q] = Quota.AVAILABILITY_OK, size_left[q] + else: + raise ValueError("inconclusive quota") + + def _compute_orders(self, quotas, q_items, q_vars, size_left): + events = {q.event_id for q in quotas} + subevents = {q.subevent_id for q in quotas} + seq = Q(subevent_id__in=subevents) + if None in subevents: + seq |= Q(subevent__isnull=True) + op_lookup = OrderPosition.objects.filter( + order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING], + order__event_id__in=events, + ).filter(seq).filter( + Q( + Q(variation_id__isnull=True) & + Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas}) + ) | Q( + variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas}) + ).order_by().values('order__status', 'item_id', 'subevent_id', 'variation_id').annotate(c=Count('*')) + for line in sorted(op_lookup, key=lambda li: li['order__status'], reverse=True): # p before n + if line['variation_id']: + qs = self._var_to_quotas[line['variation_id']] + else: + qs = self._item_to_quotas[line['item_id']] + for q in qs: + if q.subevent_id == line['subevent_id']: + size_left[q] -= line['c'] + if line['order__status'] == Order.STATUS_PAID: + self.count_paid_orders[q] += line['c'] + q.cached_availability_paid_orders = line['c'] + elif line['order__status'] == Order.STATUS_PENDING: + self.count_pending_orders[q] += line['c'] + if size_left[q] <= 0 and q not in self.results: + if line['order__status'] == Order.STATUS_PAID: + self.results[q] = Quota.AVAILABILITY_GONE, 0 + else: + self.results[q] = Quota.AVAILABILITY_ORDERED, 0 + + def _compute_vouchers(self, quotas, q_items, q_vars, size_left, now_dt): + events = {q.event_id for q in quotas} + if 'sqlite3' in settings.DATABASES['default']['ENGINE']: + func = 'MAX' + else: # NOQA + func = 'GREATEST' + + subevents = {q.subevent_id for q in quotas} + seq = Q(subevent_id__in=subevents) + if None in subevents: + seq |= Q(subevent__isnull=True) + v_lookup = Voucher.objects.filter( + Q(event_id__in=events) & + seq & + Q(block_quota=True) & + Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) & + Q( + Q( + Q(variation_id__isnull=True) & + Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas}) + ) | Q( + variation_id__in={i['itemvariation_id'] for i in q_vars if + self._quota_objects[i['quota_id']] in quotas} + ) | Q( + quota_id__in=[q.pk for q in quotas] + ) + ) + ).order_by().values('subevent_id', 'item_id', 'quota_id', 'variation_id').annotate( + free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func)) + ) + for line in v_lookup: + if line['variation_id']: + qs = self._var_to_quotas[line['variation_id']] + elif line['item_id']: + qs = self._item_to_quotas[line['item_id']] + else: + qs = [self._quota_objects[line['quota_id']]] + for q in qs: + if q.subevent_id == line['subevent_id']: + size_left[q] -= line['free'] + self.count_vouchers[q] += line['free'] + if q not in self.results and size_left[q] <= 0: + self.results[q] = Quota.AVAILABILITY_ORDERED, 0 + + def _compute_carts(self, quotas, q_items, q_vars, size_left, now_dt): + events = {q.event_id for q in quotas} + subevents = {q.subevent_id for q in quotas} + seq = Q(subevent_id__in=subevents) + if None in subevents: + seq |= Q(subevent__isnull=True) + cart_lookup = CartPosition.objects.filter( + Q(event_id__in=events) & + seq & + Q(expires__gte=now_dt) & + Q( + Q(voucher__isnull=True) + | Q(voucher__block_quota=False) + | Q(voucher__valid_until__lt=now_dt) + ) & + Q( + Q( + Q(variation_id__isnull=True) & + Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas}) + ) | Q( + variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas} + ) + ) + ).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*')) + for line in cart_lookup: + if line['variation_id']: + qs = self._var_to_quotas[line['variation_id']] + else: + qs = self._item_to_quotas[line['item_id']] + for q in qs: + if q.subevent_id == line['subevent_id']: + size_left[q] -= line['c'] + self.count_cart[q] += line['c'] + if q not in self.results and size_left[q] <= 0: + self.results[q] = Quota.AVAILABILITY_RESERVED, 0 + + def _compute_waitinglist(self, quotas, q_items, q_vars, size_left): + events = {q.event_id for q in quotas} + subevents = {q.subevent_id for q in quotas} + seq = Q(subevent_id__in=subevents) + if None in subevents: + seq |= Q(subevent__isnull=True) + w_lookup = WaitingListEntry.objects.filter( + Q(event_id__in=events) & + Q(voucher__isnull=True) & + seq & + Q( + Q( + Q(variation_id__isnull=True) & + Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas}) + ) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if + self._quota_objects[i['quota_id']] in quotas}) + ) + ).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*')) + for line in w_lookup: + if line['variation_id']: + qs = self._var_to_quotas[line['variation_id']] + else: + qs = self._item_to_quotas[line['item_id']] + for q in qs: + if q.subevent_id == line['subevent_id']: + size_left[q] -= line['c'] + self.count_waitinglist[q] += line['c'] + if q not in self.results and size_left[q] <= 0: + self.results[q] = Quota.AVAILABILITY_ORDERED, 0 + + def _compute_early_outs(self, quotas): + for q in quotas: + if q.closed and not self._ignore_closed: + self.results[q] = Quota.AVAILABILITY_ORDERED, 0 + elif q.size is None: + self.results[q] = Quota.AVAILABILITY_OK, None + elif q.size == 0: + self.results[q] = Quota.AVAILABILITY_GONE, 0 @receiver(signal=periodic_task) diff --git a/src/pretix/control/templates/pretixcontrol/items/quotas.html b/src/pretix/control/templates/pretixcontrol/items/quotas.html index d0b9c94bd..a75e02600 100644 --- a/src/pretix/control/templates/pretixcontrol/items/quotas.html +++ b/src/pretix/control/templates/pretixcontrol/items/quotas.html @@ -59,7 +59,7 @@