forked from CGM_Public/pretix_original
Add waiting list
This commit is contained in:
42
src/pretix/base/migrations/0051_auto_20170206_2027.py
Normal file
42
src/pretix/base/migrations/0051_auto_20170206_2027.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-02-06 20:27
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0050_orderposition_positionid_squashed_0061_event_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WaitingListEntry',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='E-mail address')),
|
||||
('locale', models.CharField(default='en', max_length=190)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
|
||||
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),
|
||||
('variation', models.ForeignKey(blank=True, help_text='The variation of the product selected above.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.ItemVariation', verbose_name='Product variation')),
|
||||
('voucher', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher', verbose_name='Assigned voucher')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created'],
|
||||
'verbose_name': 'Waiting list entry',
|
||||
'verbose_name_plural': 'Waiting list entries',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cachedcombinedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True),
|
||||
),
|
||||
]
|
||||
@@ -19,3 +19,4 @@ from .orders import (
|
||||
)
|
||||
from .organizer import Organizer, OrganizerPermission, OrganizerSetting
|
||||
from .vouchers import Voucher
|
||||
from .waitinglist import WaitingListEntry
|
||||
|
||||
@@ -231,7 +231,7 @@ class Item(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, _cache=None):
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None):
|
||||
"""
|
||||
This method is used to determine whether this Item is currently available
|
||||
for sale.
|
||||
@@ -253,7 +253,7 @@ class Item(LoggedModel):
|
||||
if self.variations.count() > 0: # NOQA
|
||||
raise ValueError('Do not call this directly on items which have variations '
|
||||
'but call this on their ItemVariation objects')
|
||||
return min([q.availability(_cache=_cache) for q in check_quotas],
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
@cached_property
|
||||
@@ -315,7 +315,7 @@ class ItemVariation(models.Model):
|
||||
if self.item:
|
||||
self.item.event.get_cache().clear()
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, _cache=None) -> Tuple[int, int]:
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]:
|
||||
"""
|
||||
This method is used to determine whether this ItemVariation is currently
|
||||
available for sale in terms of quotas.
|
||||
@@ -324,6 +324,7 @@ class ItemVariation(models.Model):
|
||||
quotas will be ignored in the calculation. If this leads
|
||||
to no quotas being checked at all, this method will return
|
||||
unlimited availability.
|
||||
:param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation.
|
||||
:returns: any of the return codes of :py:meth:`Quota.availability()`.
|
||||
"""
|
||||
check_quotas = set(self.quotas.all())
|
||||
@@ -331,7 +332,7 @@ class ItemVariation(models.Model):
|
||||
check_quotas -= set(ignored_quotas)
|
||||
if not check_quotas:
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize
|
||||
return min([q.availability(_cache=_cache) for q in check_quotas],
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
def __lt__(self, other):
|
||||
@@ -534,7 +535,7 @@ class Quota(LoggedModel):
|
||||
if self.event:
|
||||
self.event.get_cache().clear()
|
||||
|
||||
def availability(self, now_dt: datetime=None, _cache=None) -> Tuple[int, int]:
|
||||
def availability(self, now_dt: datetime=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]:
|
||||
"""
|
||||
This method is used to determine whether Items or ItemVariations belonging
|
||||
to this quota should currently be available for sale.
|
||||
@@ -542,14 +543,18 @@ 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.
|
||||
"""
|
||||
if _cache and count_waitinglist is not _cache.get('_count_waitinglist', True):
|
||||
_cache.clear()
|
||||
|
||||
if _cache is not None and self.pk in _cache:
|
||||
return _cache[self.pk]
|
||||
res = self._availability(now_dt)
|
||||
res = self._availability(now_dt, count_waitinglist)
|
||||
if _cache is not None:
|
||||
_cache[self.pk] = res
|
||||
_cache['_count_waitinglist'] = count_waitinglist
|
||||
return res
|
||||
|
||||
def _availability(self, now_dt: datetime=None):
|
||||
def _availability(self, now_dt: datetime=None, count_waitinglist=True):
|
||||
now_dt = now_dt or now()
|
||||
size_left = self.size
|
||||
if size_left is None:
|
||||
@@ -572,6 +577,11 @@ class Quota(LoggedModel):
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
if count_waitinglist:
|
||||
size_left -= self.count_waiting_list_pending()
|
||||
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:
|
||||
@@ -591,6 +601,13 @@ class Quota(LoggedModel):
|
||||
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) &
|
||||
self._position_lookup
|
||||
).distinct().count()
|
||||
|
||||
def count_in_cart(self, now_dt: datetime=None) -> int:
|
||||
from pretix.base.models import CartPosition
|
||||
|
||||
|
||||
130
src/pretix/base/models/waitinglist.py
Normal file
130
src/pretix/base/models/waitinglist.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Voucher
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
from .base import LoggedModel
|
||||
from .event import Event
|
||||
from .items import Item, ItemVariation
|
||||
|
||||
|
||||
class WaitingListException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WaitingListEntry(LoggedModel):
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="waitinglistentries",
|
||||
verbose_name=_("Event"),
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_("On waiting list since"),
|
||||
auto_now_add=True
|
||||
)
|
||||
email = models.EmailField(
|
||||
verbose_name=_("E-mail address")
|
||||
)
|
||||
voucher = models.ForeignKey(
|
||||
'Voucher',
|
||||
verbose_name=_("Assigned voucher"),
|
||||
null=True, blank=True
|
||||
)
|
||||
item = models.ForeignKey(
|
||||
Item, related_name='waitinglistentries',
|
||||
verbose_name=_("Product"),
|
||||
help_text=_(
|
||||
"The product the user waits for."
|
||||
)
|
||||
)
|
||||
variation = models.ForeignKey(
|
||||
ItemVariation, related_name='waitinglistentries',
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Product variation"),
|
||||
help_text=_(
|
||||
"The variation of the product selected above."
|
||||
)
|
||||
)
|
||||
locale = models.CharField(
|
||||
max_length=190,
|
||||
default='en'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Waiting list entry")
|
||||
verbose_name_plural = _("Waiting list entries")
|
||||
ordering = ['created']
|
||||
|
||||
def __str__(self):
|
||||
return '%s waits for %s' % (str(self.email), str(self.item))
|
||||
|
||||
def clean(self):
|
||||
if WaitingListEntry.objects.filter(
|
||||
item=self.item, variation=self.variation, email=self.email, voucher__isnull=True
|
||||
).exclude(pk=self.pk).exists():
|
||||
raise ValidationError(_('You are already on this waiting list! We will notify '
|
||||
'you as soon as we have a ticket available for you.'))
|
||||
if not self.variation and self.item.has_variations:
|
||||
raise ValidationError(_('Please select a specific variation of this product.'))
|
||||
|
||||
def send_voucher(self, quota_cache=None):
|
||||
availability = (
|
||||
self.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
if self.variation
|
||||
else self.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
)
|
||||
if availability[1] < 1:
|
||||
raise WaitingListException(_('This product is currently not available.'))
|
||||
if self.voucher:
|
||||
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
||||
|
||||
with transaction.atomic():
|
||||
v = Voucher.objects.create(
|
||||
event=self.event,
|
||||
max_usages=1,
|
||||
valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours),
|
||||
item=self.item,
|
||||
variation=self.variation,
|
||||
tag='waiting-list',
|
||||
comment=_('Automatically created from waiting list entry for {email}').format(
|
||||
email=self.email
|
||||
),
|
||||
block_quota=True,
|
||||
)
|
||||
v.log_action('pretix.voucher.added.waitinglist', {
|
||||
'item': self.item.pk,
|
||||
'variation': self.variation.pk if self.variation else None,
|
||||
'tag': 'waiting-list',
|
||||
'block_quota': True,
|
||||
'valid_until': v.valid_until.isoformat(),
|
||||
'max_usages': 1,
|
||||
'email': self.email,
|
||||
'waitinglistentry': self.pk
|
||||
})
|
||||
self.log_action('pretix.waitinglist.voucher')
|
||||
self.voucher = v
|
||||
self.save()
|
||||
|
||||
with language(self.locale):
|
||||
mail(
|
||||
self.email,
|
||||
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
{
|
||||
'event': self.event.name,
|
||||
'url': build_absolute_uri(self.event, 'presale:event.redeem') + '?voucher=' + self.voucher.code,
|
||||
'code': self.voucher.code,
|
||||
'product': str(self.item) + (' - ' + str(self.variation) if self.variation else ''),
|
||||
'hours': self.event.settings.waiting_list_hours,
|
||||
},
|
||||
self.event,
|
||||
locale=self.locale
|
||||
)
|
||||
56
src/pretix/base/services/waitinglist.py
Normal file
56
src/pretix/base/services/waitinglist.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.models import Event, WaitingListEntry
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def assign_automatically(event_id: int):
|
||||
event = Event.objects.get(id=event_id)
|
||||
|
||||
quota_cache = {}
|
||||
gone = set()
|
||||
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=event, voucher__isnull=True
|
||||
).select_related('item', 'variation').prefetch_related('item__quotas', 'variation__quotas').order_by('created')
|
||||
sent = 0
|
||||
|
||||
for wle in qs:
|
||||
if (wle.item, wle.variation) in gone:
|
||||
continue
|
||||
|
||||
quotas = wle.variation.quotas.all() if wle.variation else wle.item.quotas.all()
|
||||
availability = (
|
||||
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
)
|
||||
if availability[1] > 0:
|
||||
try:
|
||||
wle.send_voucher(quota_cache)
|
||||
sent += 1
|
||||
except WaitingListException: # noqa
|
||||
continue
|
||||
|
||||
# Reduce affected quotas in cache
|
||||
for q in quotas:
|
||||
quota_cache[q.pk] = (
|
||||
quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0,
|
||||
quota_cache[q.pk][1] - 1
|
||||
)
|
||||
else:
|
||||
gone.add((wle.item, wle.variation))
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def process_waitinglist(sender, **kwargs):
|
||||
qs = Event.objects.prefetch_related('setting_objects', 'organizer__setting_objects').select_related('organizer')
|
||||
for e in qs:
|
||||
if e.settings.waiting_list_enabled and e.settings.waiting_list_auto:
|
||||
assign_automatically.apply_async(args=(e.pk,))
|
||||
@@ -132,6 +132,18 @@ DEFAULTS = {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'waiting_list_enabled': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'waiting_list_auto': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'waiting_list_hours': {
|
||||
'default': '48',
|
||||
'type': int
|
||||
},
|
||||
'ticket_download': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
@@ -258,6 +270,29 @@ your payment before {expire_date}.
|
||||
You can view the payment information and the status of your order at
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_text_waiting_list': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||
|
||||
you submitted yourself to the waiting list for {event},
|
||||
for the product {product}.
|
||||
|
||||
We now have a ticket ready for you! You can redeem it in our ticket shop
|
||||
within the next {hours} hours by entering the following voucher code:
|
||||
|
||||
{code}
|
||||
|
||||
Alternatively, you can just click on the following link:
|
||||
|
||||
{url}
|
||||
|
||||
Please note that this link is only valid within the next {hours} hours!
|
||||
We will reassign the ticket to the next person on the list if you do not
|
||||
redeem the voucher within that timeframe.
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user