diff --git a/doc/development/concepts.rst b/doc/development/concepts.rst index 6b05199ebb..74186e7fbf 100644 --- a/doc/development/concepts.rst +++ b/doc/development/concepts.rst @@ -125,21 +125,21 @@ special care in the implementation to never sell more tickets than allowed, even * There is a concept of **quotas**. A quota is basically a number of items combined with information about how many of them are still available. -* Every time a user places a item in the cart, a **lock** object is created, reducing the number of +* Every time a user places a item in the cart, a **cart lock** is created, reducing the number of available items in the pool by one. The lock is valid for a fixed time (e.g. 30 minutes), but not instantly deleted afther those 30 minutes (we'll get to that). * Every time a user places a binding order, the lock object is replaced by an **order** which behaves much the same as the lock. It reduces the number of available item and is valid for a fixed time, this time for the configured payment term (e.g. 14 days). * If the order is being paid, the **order** becomes permanent. -* Once there are no available tickets left and a user wants to buy a ticket, a lock which is in place - for more than the allowed time frame is being removed in favor of the new buyer. If there are no - abandoned locks available, an unpaid order being older than the configured payment term is being - removed. If there are none of them as well, this quota is sold out. -* The same quota can apply to multiple items and one item can be affected by multiple quotas, to - enable both of the following features at the same time: - - * You'll want to make sure you never have more than X people at your event, so you'll create a quota - applying to all ticket items. - * You want to reduce the first Y tickets in price, so you'll create a restriction which is bound by - a quota of Y and reduces the price. +* Once there are no available tickets left and user A wants to buy a ticket, he can do so, as long as + there are *expired* cart locks in the system. In this case, user A gets a new cart lock, so that there + are more cart locks than available tickets and therefore have to remove one of the expired cart locks. + However, we do not choose one by random, but keep the surplus in a way that leads to the deletion + of the cart lock of the user who tries *last* to use his lock. +* The same goes for orders which are not paid within the specified timeframe. This policy allows to + sell as much items as possible, guarantees you to get your item if you checkout within the validity + period of your lock or pay within the validity period of your order. It does not guarantee you anything + any longer, but it tries to be *as tolerant as possible* to users who are paying after their payment + period or click checkout after the expiry of their lock. +* The same quota can apply to multiple items and one item can be affected by multiple quotas diff --git a/src/pretixbase/migrations/0003_auto_20150211_2042.py b/src/pretixbase/migrations/0003_auto_20150211_2042.py new file mode 100644 index 0000000000..a3e6d57dae --- /dev/null +++ b/src/pretixbase/migrations/0003_auto_20150211_2042.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import versions.models +import pretixbase.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0002_auto_20150211_2031'), + ] + + operations = [ + migrations.RemoveField( + model_name='quota', + name='lock_cache', + ), + migrations.RemoveField( + model_name='quota', + name='order_cache', + ), + ] diff --git a/src/pretixbase/models.py b/src/pretixbase/models.py index dfecc4b2eb..13ce7f63f1 100644 --- a/src/pretixbase/models.py +++ b/src/pretixbase/models.py @@ -5,6 +5,8 @@ import uuid from django.db import models from django.conf import settings from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.db.models import Q +from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from django.template.defaultfilters import date as _date from django.core.validators import RegexValidator @@ -704,6 +706,13 @@ class Item(Versionable): return result def get_all_available_variations(self): + """ + This method returns a list of all variations which are theoretically + possible for sale. It DOES call all activated restriction plugins, but it + DOES NOT take into account quotas. Use is_available on the ItemVariation + objects (or the Item it self, if it does not have variations) to determine + availability by the terms of quotas. + """ from .signals import determine_availability variations = self.get_all_variations() @@ -732,6 +741,16 @@ class Item(Versionable): return variations + def availability(self): + """ + This method is used to determine whether this Item is currently available + for sale. It may return any of the return codes of Quota.availability() + """ + if self.properties.exist(): + raise ValueError('Do not call this directly on items which have properties ' + 'but call this on their ItemVariation objects') + return max([q.availability() for q in self.quotas.all()]) + class ItemVariation(Versionable): """ @@ -784,6 +803,13 @@ class ItemVariation(Versionable): if self.item: self.item.event.get_cache().clear() + def availability(self): + """ + This method is used to determine whether this Item is currently available + for sale. It may return any of the return codes of Quota.availability() + """ + return max([q.availability() for q in self.quotas.all()]) + class VariationsField(VersionedManyToManyField): """ @@ -867,10 +893,35 @@ class Quota(Versionable): speficied, the quota applies to all of them, and if there are variations specified, the quota applies to those. - This object holds two fields, "order_cache" and "lock_cache", which are - implementation specific and are considered private. It is planned that they - are being used as a fallback solution if redis is not available. + Please read the documentation section on quotas carefully before doing + anything with quotas. This might confuse you otherwise. + http://docs.pretix.eu/en/latest/development/concepts.html#restriction-by-number + + The AVAILABILITY_* constants represent varios states of an quota allowing + its items/variations being for sale. + + AVAILABILITY_OK + This item is available for sale. + + AVAILABILITY_RESERVED + This item is currently not available for sale, because all available + items are in people's shopping carts. It might become available + again if those people do not proceed with checkout. + + AVAILABILITY_ORDERED + This item is currently not availalbe for sale, because all available + items are ordered. It might become available again if those people + do not pay. + + AVAILABILITY_GONE + This item is completely sold out. """ + + AVAILABILITY_GONE = 30 + AVAILABILITY_ORDERED = 20 + AVAILABILITY_RESERVED = 10 + AVAILABILITY_OK = 0 + event = VersionedForeignKey( Event, on_delete=models.CASCADE, @@ -896,14 +947,6 @@ class Quota(Versionable): blank=True, verbose_name=_("Variations") ) - order_cache = models.ManyToManyField( - 'OrderPosition', - blank=True - ) - lock_cache = models.ManyToManyField( - 'CartPosition', - blank=True - ) class Meta: verbose_name = _("Quota") @@ -912,6 +955,62 @@ class Quota(Versionable): def __str__(self): return self.name + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.event: + self.event.get_cache().clear() + + def availability(self): + """ + This method is used to determine whether Items or ItemVariations belonging + to this quota should currently be available for sale. It returns one of the + Quota.AVAILABILITY_ constants. 0 is returned if the item is available, a + positive number depending on the reason, if not. + """ + # TODO: These lookups are highly inefficient. However, we'll wait with optimizing + # until Django 1.8 is released, as the following feature might make it a + # lot easier: + # https://docs.djangoproject.com/en/1.8/ref/models/conditional-expressions/ + # TODO: Test for interference with old versions of Item-Quota-relations, etc. + # TODO: Prevent corner-cases like people having ordered an item before it got + # its first variationsadded + quotalookup = ( + ( # Orders for items which do not have any variations + Q(variation__isnull=True) + & Q(item__quotas__in=[self]) + ) | ( # Orders for items which do have any variations + Q(variation__quotas__in=[self]) + ) + ) + paid_orders = OrderPosition.objects.current.filter( + Q(order__status=Order.STATUS_PAID) + & quotalookup + ) + if paid_orders >= self.size: + return Quota.AVAILABILITY_GONE + + pending_valid_orders = OrderPosition.objects.current.filter( + Q(order__status=Order.STATUS_PENDING) + & Q(order__expires__gte=now()) + & quotalookup + ) + if (paid_orders + pending_valid_orders) >= self.size: + return Quota.AVAILABILITY_ORDERED + + valid_cart_positions = CartPosition.objects.current.filter( + Q(order__expires__lt=now()) + & quotalookup + ) + if (paid_orders + pending_valid_orders + valid_cart_positions) >= self.size: + return Quota.AVAILABILITY_RESERVED + + return Quota.AVAILABILITY_OK + class Order(Versionable): """