Start implementing the Quota magic

This commit is contained in:
Raphael Michel
2015-02-11 22:17:56 +01:00
parent 9f1131023d
commit f6bafd1f5e
3 changed files with 146 additions and 23 deletions
+12 -12
View File
@@ -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
@@ -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',
),
]
+110 -11
View File
@@ -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):
"""