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"""))
|
||||
},
|
||||
|
||||
@@ -187,6 +187,27 @@ class EventSettingsForm(SettingsForm):
|
||||
help_text=_("Publicly show how many tickets of a certain type are still available."),
|
||||
required=False
|
||||
)
|
||||
waiting_list_enabled = forms.BooleanField(
|
||||
label=_("Enable waiting list"),
|
||||
help_text=_("Once a ticket is sold out, people can add themselves to a waiting list. As soon as a ticket "
|
||||
"becomes available again, it will be reserved for the first person on the waiting list and this "
|
||||
"person will receive an email notification with a voucher that can be used to buy a ticket."),
|
||||
required=False
|
||||
)
|
||||
waiting_list_hours = forms.IntegerField(
|
||||
label=_("Waiting list response time"),
|
||||
min_value=6,
|
||||
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
|
||||
"number of hours until it expires and can be re-assigned to the next person on the list."),
|
||||
required=False
|
||||
)
|
||||
waiting_list_auto = forms.BooleanField(
|
||||
label=_("Automatic waiting list assignments"),
|
||||
help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person "
|
||||
"on the waiting list for that product. If this is not active, mails will not be send automatically "
|
||||
"but you can send them manually via the control panel."),
|
||||
required=False
|
||||
)
|
||||
attendee_names_asked = forms.BooleanField(
|
||||
label=_("Ask for attendee names"),
|
||||
help_text=_("Ask for a name for all tickets which include admission to the event."),
|
||||
@@ -433,6 +454,12 @@ class MailSettingsForm(SettingsForm):
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}")
|
||||
)
|
||||
mail_text_waiting_list = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}")
|
||||
)
|
||||
smtp_use_custom = forms.BooleanField(
|
||||
label=_("Use custom SMTP server"),
|
||||
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
|
||||
|
||||
@@ -85,6 +85,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
|
||||
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
|
||||
'pretix.voucher.added': _('The voucher has been created.'),
|
||||
'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'),
|
||||
'pretix.voucher.changed': _('The voucher has been modified.'),
|
||||
'pretix.voucher.deleted': _('The voucher has been deleted.'),
|
||||
'pretix.voucher.redeemed': _('The voucher has been redeemed in order {order_code}.'),
|
||||
@@ -117,6 +118,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
|
||||
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
|
||||
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
|
||||
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.')
|
||||
}
|
||||
|
||||
data = json.loads(logentry.data)
|
||||
|
||||
@@ -84,6 +84,12 @@
|
||||
{% trans "Export" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'control:event.orders.waitinglist' organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
{% if url_name == "event.orders.waitinglist" %}class="active"{% endif %}>
|
||||
{% trans "Waiting list" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
@@ -99,6 +99,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#waiting_list">
|
||||
<strong>{% trans "Waiting list notification" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="waiting_list" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_waiting_list layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
{% bootstrap_field sform.attendee_names_required layout="horizontal" %}
|
||||
{% bootstrap_field sform.cancel_allow_user layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Waiting list" %}</legend>
|
||||
{% bootstrap_field sform.waiting_list_enabled layout="horizontal" %}
|
||||
{% bootstrap_field sform.waiting_list_auto layout="horizontal" %}
|
||||
{% bootstrap_field sform.waiting_list_hours layout="horizontal" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load urlreplace %}
|
||||
{% block title %}{% trans "Waiting list" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Waiting list" %}</h1>
|
||||
{% if not request.event.settings.waiting_list_enabled %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "The waiting list is disabled, so if the event is sold out, people cannot add themselves to this list. If you want to enable it, go to the event settings." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
{% if request.eventperm.can_change_orders %}
|
||||
<form method="post" class="col-md-6"
|
||||
action="{% url "control:event.orders.waitinglist.auto" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
data-asynctask>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% trans "Send vouchers" %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% csrf_token %}
|
||||
{% if request.event.settings.waiting_list_auto %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You have configured that vouchers will automatically be sent to the persons on this list who waited
|
||||
the longest as soon as capacity becomes available. It might take up to half an hour for the
|
||||
vouchers to be sent after the capacity is available, so don't worry if entries do not disappear
|
||||
here immediately. If you want, you can also send them out manually right now.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You have configured that vouchers will <strong>not</strong> be sent automatically.
|
||||
You can either send them one-by-one in an order of your choice by clicking the
|
||||
buttons next to a line in this table (if sufficient quota is available) or you can
|
||||
press the big button below this text to send out as many vouchers as currently
|
||||
possible to the persons who waitet longest.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<button class="btn btn-large btn-primary" type="submit">
|
||||
{% trans "Send as many vouchers as possible" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="{% if request.eventperm.can_change_orders %}col-md-6{% else %}col-md-12{% endif %}">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% trans "Sales estimate" %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% blocktrans trimmed with amount=estimate|floatformat:2 currency=request.event.currency %}
|
||||
If you can make enough room at your event to fit all the persons on the waiting list in, you
|
||||
could sell tickets worth an additional <strong>{{ amount }} {{ currency }}</strong>.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<select name="status" class="form-control">
|
||||
<option value="a"
|
||||
{% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "All entries" %}</option>
|
||||
<option value="w"
|
||||
{% if request.GET.status == "w" or not request.GET.status %}selected="selected"{% endif %}>{% trans "Waiting" %}</option>
|
||||
<option value="s"
|
||||
{% if request.GET.status == "s" %}selected="selected"{% endif %}>{% trans "Voucher assigned" %}</option>
|
||||
</select>
|
||||
<select name="item" class="form-control">
|
||||
<option value="">{% trans "All products" %}</option>
|
||||
{% for item in items %}
|
||||
<option value="{{ item.id }}"
|
||||
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
|
||||
{{ item.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
</form>
|
||||
</p>
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "User" %}</th>
|
||||
<th>{% trans "Product" %}</th>
|
||||
<th>{% trans "On the list since" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Voucher" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td>{{ e.email }}</td>
|
||||
<td>
|
||||
{{ e.item }}
|
||||
{% if e.variation %}
|
||||
– {{ e.variation }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ e.created|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td>
|
||||
{% if e.voucher %}
|
||||
<span class="label label-success">{% trans "Voucher assigned" %}</span>
|
||||
{% elif e.availability.0 == 100 %}
|
||||
<span class="label label-warning">
|
||||
{% blocktrans with num=e.availability.1 %}
|
||||
Waiting, product {{ num }}x available
|
||||
{% endblocktrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="label label-danger">{% trans "Waiting, product unavailable" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if e.voucher %}
|
||||
<a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=e.voucher.pk %}">
|
||||
{{ e.voucher }}
|
||||
</a>
|
||||
{% elif not e.voucher and e.availability.0 == 100 %}
|
||||
<button name="assign" value="{{ e.pk }}" class="btn btn-default btn-xs">
|
||||
{% trans "Send a voucher" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -2,7 +2,7 @@ from django.conf.urls import include, url
|
||||
|
||||
from pretix.control.views import (
|
||||
auth, dashboards, event, global_settings, help, item, main, orders,
|
||||
organizer, user, vouchers,
|
||||
organizer, user, vouchers, waitinglist,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -121,6 +121,8 @@ urlpatterns = [
|
||||
url(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
|
||||
url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
|
||||
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
|
||||
url(r'^waitinglist/$', waitinglist.WaitingListView.as_view(), name='event.orders.waitinglist'),
|
||||
url(r'^waitinglist/auto_assign$', waitinglist.AutoAssign.as_view(), name='event.orders.waitinglist.auto'),
|
||||
])),
|
||||
url(r'^help/(?P<topic>[a-zA-Z0-9_/]+)$', help.HelpView.as_view(), name='help'),
|
||||
]
|
||||
|
||||
@@ -12,7 +12,9 @@ from django.utils import formats
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Item, Order, OrderPosition, Voucher
|
||||
from pretix.base.models import (
|
||||
Event, Item, Order, OrderPosition, Voucher, WaitingListEntry,
|
||||
)
|
||||
from pretix.control.signals import (
|
||||
event_dashboard_widgets, user_dashboard_widgets,
|
||||
)
|
||||
@@ -85,9 +87,53 @@ def base_widgets(sender, **kwargs):
|
||||
]
|
||||
|
||||
|
||||
@receiver(signal=event_dashboard_widgets)
|
||||
def waitinglist_widgets(sender, **kwargs):
|
||||
widgets = []
|
||||
|
||||
wles = WaitingListEntry.objects.filter(event=sender)
|
||||
if wles.count():
|
||||
quota_cache = {}
|
||||
itemvar_cache = {}
|
||||
happy = 0
|
||||
|
||||
for wle in wles:
|
||||
if (wle.item, wle.variation) not in itemvar_cache:
|
||||
itemvar_cache[(wle.item, wle.variation)] = (
|
||||
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
)
|
||||
row = itemvar_cache.get((wle.item, wle.variation))
|
||||
if row[1] > 0:
|
||||
itemvar_cache[(wle.item, wle.variation)] = (row[0], row[1] - 1)
|
||||
happy += 1
|
||||
|
||||
widgets.append({
|
||||
'content': NUM_WIDGET.format(num=str(happy), text=_('available to give to people on waiting list')),
|
||||
'priority': 50,
|
||||
'url': reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': sender.slug,
|
||||
'organizer': sender.organizer.slug,
|
||||
})
|
||||
})
|
||||
widgets.append({
|
||||
'content': NUM_WIDGET.format(num=str(wles.count()), text=_('total waiting list length')),
|
||||
'display_size': 'small',
|
||||
'priority': 50,
|
||||
'url': reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': sender.slug,
|
||||
'organizer': sender.organizer.slug,
|
||||
})
|
||||
})
|
||||
|
||||
return widgets
|
||||
|
||||
|
||||
@receiver(signal=event_dashboard_widgets)
|
||||
def quota_widgets(sender, **kwargs):
|
||||
widgets = []
|
||||
|
||||
for q in sender.quotas.all():
|
||||
status, left = q.availability()
|
||||
widgets.append({
|
||||
|
||||
@@ -607,24 +607,33 @@ class QuotaView(ChartContainingView, DetailView):
|
||||
data = [
|
||||
{
|
||||
'label': ugettext('Paid orders'),
|
||||
'value': self.object.count_paid_orders()
|
||||
'value': self.object.count_paid_orders(),
|
||||
'sum': True,
|
||||
},
|
||||
{
|
||||
'label': ugettext('Pending orders'),
|
||||
'value': self.object.count_pending_orders()
|
||||
'value': self.object.count_pending_orders(),
|
||||
'sum': True,
|
||||
},
|
||||
{
|
||||
'label': ugettext('Vouchers'),
|
||||
'value': self.object.count_blocking_vouchers()
|
||||
'value': self.object.count_blocking_vouchers(),
|
||||
'sum': True,
|
||||
},
|
||||
{
|
||||
'label': ugettext('Current user\'s carts'),
|
||||
'value': self.object.count_in_cart()
|
||||
}
|
||||
'value': self.object.count_in_cart(),
|
||||
'sum': True,
|
||||
},
|
||||
{
|
||||
'label': ugettext('Waiting list'),
|
||||
'value': self.object.count_waiting_list_pending(),
|
||||
'sum': False,
|
||||
},
|
||||
]
|
||||
ctx['quota_table_rows'] = list(data)
|
||||
|
||||
sum_values = sum([d['value'] for d in data])
|
||||
sum_values = sum([d['value'] for d in data if d['sum']])
|
||||
|
||||
if self.object.size is not None:
|
||||
data.append({
|
||||
|
||||
127
src/pretix/control/views/waitinglist.py
Normal file
127
src/pretix/control/views/waitinglist.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from django.contrib import messages
|
||||
from django.db.models import Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import ListView
|
||||
|
||||
from pretix.base.models import Item, WaitingListEntry
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
from pretix.base.services.waitinglist import assign_automatically
|
||||
from pretix.base.views.async import AsyncAction
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
|
||||
|
||||
class AutoAssign(EventPermissionRequiredMixin, AsyncAction, View):
|
||||
task = assign_automatically
|
||||
known_errortypes = ['WaitingListError']
|
||||
permission = 'can_change_orders'
|
||||
|
||||
def get_success_message(self, value):
|
||||
return _('{num} vouchers have been created and sent out via email.').format(num=value)
|
||||
|
||||
def get_success_url(self, value):
|
||||
return self.get_error_url()
|
||||
|
||||
def get_error_url(self):
|
||||
return reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug
|
||||
})
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.do(self.request.event.id)
|
||||
|
||||
|
||||
class WaitingListView(EventPermissionRequiredMixin, ListView):
|
||||
model = WaitingListEntry
|
||||
context_object_name = 'entries'
|
||||
paginate_by = 30
|
||||
template_name = 'pretixcontrol/waitinglist/index.html'
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if 'assign' in request.POST:
|
||||
if not request.eventperm.can_change_orders:
|
||||
messages.error(request, _('You do not have permission to do this'))
|
||||
return redirect(reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug
|
||||
}))
|
||||
try:
|
||||
wle = WaitingListEntry.objects.get(
|
||||
pk=request.POST.get('assign'), event=self.request.event,
|
||||
)
|
||||
try:
|
||||
wle.send_voucher()
|
||||
except WaitingListException as e:
|
||||
messages.error(request, str(e))
|
||||
else:
|
||||
messages.success(request, _('An email containing a voucher code has been sent to the '
|
||||
'specified address.'))
|
||||
return redirect(reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug
|
||||
}))
|
||||
except WaitingListEntry.DoesNotExist:
|
||||
messages.error(request, _('Waiting list entry not found.'))
|
||||
return redirect(reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug
|
||||
}))
|
||||
|
||||
def get_queryset(self):
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=self.request.event
|
||||
).select_related('item', 'variation', 'voucher').prefetch_related('item__quotas', 'variation__quotas')
|
||||
|
||||
s = self.request.GET.get("status", "")
|
||||
if s == 's':
|
||||
qs = qs.filter(voucher__isnull=False)
|
||||
elif s == 'a':
|
||||
pass
|
||||
else:
|
||||
qs = qs.filter(voucher__isnull=True)
|
||||
|
||||
if self.request.GET.get("item", "") != "":
|
||||
i = self.request.GET.get("item", "")
|
||||
qs = qs.filter(item_id__in=(i,))
|
||||
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['items'] = Item.objects.filter(event=self.request.event)
|
||||
ctx['filtered'] = ("status" in self.request.GET or "item" in self.request.GET)
|
||||
|
||||
itemvar_cache = {}
|
||||
quota_cache = {}
|
||||
any_avail = False
|
||||
for wle in ctx[self.context_object_name]:
|
||||
if (wle.item, wle.variation) in itemvar_cache:
|
||||
wle.availability = itemvar_cache.get((wle.item, wle.variation))
|
||||
else:
|
||||
wle.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)
|
||||
)
|
||||
itemvar_cache[(wle.item, wle.variation)] = wle.availability
|
||||
if wle.availability[0] == 100:
|
||||
any_avail = True
|
||||
|
||||
ctx['any_avail'] = any_avail
|
||||
ctx['estimate'] = self.get_sales_estimate()
|
||||
return ctx
|
||||
|
||||
def get_sales_estimate(self):
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=self.request.event, voucher__isnull=True
|
||||
).aggregate(
|
||||
s=Sum(
|
||||
Coalesce('variation__default_price', 'item__default_price')
|
||||
)
|
||||
)
|
||||
return qs['s']
|
||||
13
src/pretix/presale/forms/waitinglist.py
Normal file
13
src/pretix/presale/forms/waitinglist.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django import forms
|
||||
|
||||
from pretix.base.models import WaitingListEntry
|
||||
|
||||
|
||||
class WaitingListForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = WaitingListEntry
|
||||
fields = ('email',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1,13 +1,28 @@
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% if avail == 0 %}
|
||||
<div class="col-md-2 col-xs-6 availability-box gone">
|
||||
<strong>{% trans "SOLD OUT" %}</strong>
|
||||
{% if event.settings.waiting_list_enabled %}
|
||||
<br/>
|
||||
<a href="{% eventurl event "presale:event.waitinglist" %}?item={{ item.pk }}{% if var %}&var={{ var.pk }}{% endif %}">
|
||||
<span class="fa fa-plus-circle"></span>
|
||||
{% trans "Waiting list" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif avail < 100 %}
|
||||
<div class="col-md-2 col-xs-6 availability-box unavailable">
|
||||
<strong>{% trans "Reserved" %}</strong><br />
|
||||
<small>
|
||||
{% trans "All remaining products are reserved but might become available again." %}
|
||||
</small>
|
||||
<strong>{% trans "Reserved" %}
|
||||
<span class="fa fa-info-circle" data-toggle="tooltip"
|
||||
title="{% trans "All remaining products are reserved but might become available again." %}"></span>
|
||||
</strong>
|
||||
{% if event.settings.waiting_list_enabled %}
|
||||
<br/>
|
||||
<a href="{% eventurl event "presale:event.waitinglist" %}?item={{ item.pk }}{% if var %}&var={{ var.pk }}{% endif %}">
|
||||
<span class="fa fa-plus-circle"></span>
|
||||
{% trans "Waiting list" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
name="variation_{{ item.id }}_{{ var.id }}">
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 %}
|
||||
{% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 event=event item=item var=var %}
|
||||
{% endif %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@
|
||||
max="{{ item.order_max }}" name="item_{{ item.id }}">
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 %}
|
||||
{% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 event=event item=item var=0 %}
|
||||
{% endif %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "pretixpresale/event/base.html" %}
|
||||
{% load i18n eventurl thumbnail bootstrap3 %}
|
||||
{% block title %}{% trans "Waiting list" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h2>{% trans "Add me to the waiting list" %}</h2>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_email">{% trans "Product" %}</label>
|
||||
<div class="col-md-9">
|
||||
<input class="form-control" readonly="readonly"
|
||||
value="{{ item.name }}{% if variation %} – {{ variation.value }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
{% bootstrap_form form layout='horizontal' %}
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
If tickets become available again, we will inform the first persons on the waiting list. If we notify you, you'll have 48 hours time to buy a ticket until we assign it to the next person on the list.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% trans "Add me to the list" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -7,6 +7,7 @@ import pretix.presale.views.locale
|
||||
import pretix.presale.views.order
|
||||
import pretix.presale.views.organizer
|
||||
import pretix.presale.views.user
|
||||
import pretix.presale.views.waiting
|
||||
|
||||
# This is not a valid Django URL configuration, as the final
|
||||
# configuration is done by the pretix.multidomain package.
|
||||
@@ -14,6 +15,7 @@ import pretix.presale.views.user
|
||||
event_patterns = [
|
||||
url(r'^cart/add$', pretix.presale.views.cart.CartAdd.as_view(), name='event.cart.add'),
|
||||
url(r'^cart/remove$', pretix.presale.views.cart.CartRemove.as_view(), name='event.cart.remove'),
|
||||
url(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'),
|
||||
url(r'^checkout/start$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout.start'),
|
||||
url(r'^redeem$', pretix.presale.views.cart.RedeemView.as_view(),
|
||||
name='event.redeem'),
|
||||
|
||||
76
src/pretix/presale/views/waiting.py
Normal file
76
src/pretix/presale/views/waiting.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
from django.utils import translation
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
|
||||
from ...base.models import Item, ItemVariation, WaitingListEntry
|
||||
from ...multidomain.urlreverse import eventreverse
|
||||
from ..forms.waitinglist import WaitingListForm
|
||||
|
||||
|
||||
class WaitingView(FormView):
|
||||
template_name = 'pretixpresale/event/waitinglist.html'
|
||||
form_class = WaitingListForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['event'] = self.request.event
|
||||
kwargs['instance'] = WaitingListEntry(
|
||||
item=self.item_and_variation[0], variation=self.item_and_variation[1],
|
||||
event=self.request.event, locale=translation.get_language()
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['item'], ctx['variation'] = self.item_and_variation
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
def item_and_variation(self):
|
||||
try:
|
||||
item = self.request.event.items.get(pk=self.request.GET.get('item'))
|
||||
if 'var' in self.request.GET:
|
||||
var = item.variations.get(pk=self.request.GET['var'])
|
||||
elif item.has_variations:
|
||||
return None
|
||||
else:
|
||||
var = None
|
||||
return item, var
|
||||
except (Item.DoesNotExist, ItemVariation.DoesNotExist):
|
||||
return None
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
|
||||
if not self.request.event.settings.waiting_list_enabled:
|
||||
messages.error(request, _("Waiting lists are disabled for this event."))
|
||||
return redirect(eventreverse(self.request.event, 'presale:event.index'))
|
||||
|
||||
if not self.item_and_variation:
|
||||
messages.error(request, _("We could not identify the product you selected."))
|
||||
return redirect(eventreverse(self.request.event, 'presale:event.index'))
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
availability = (
|
||||
self.item_and_variation[1].check_quotas(count_waitinglist=False)
|
||||
if self.item_and_variation[1]
|
||||
else self.item_and_variation[0].check_quotas(count_waitinglist=False)
|
||||
)
|
||||
if availability[0] == 100:
|
||||
messages.error(self.request, _("You cannot add yourself to the waiting list as this product ist currently "
|
||||
"available."))
|
||||
return redirect(eventreverse(self.request.event, 'presale:event.index'))
|
||||
|
||||
form.save()
|
||||
messages.success(self.request, _("We've added you to the waiting list. You will receive "
|
||||
"an email as soon as tickets get available again."))
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return eventreverse(self.request.event, 'presale:event.index')
|
||||
@@ -33,6 +33,8 @@ $(function () {
|
||||
$("#voucher-box").slideDown();
|
||||
$("#voucher-toggle").slideUp();
|
||||
});
|
||||
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
|
||||
$("#ajaxerr").on("click", ".ajaxerr-close", ajaxErrDialog.hide);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.utils.timezone import now
|
||||
|
||||
from pretix.base.models import (
|
||||
CachedFile, CartPosition, Event, Item, ItemCategory, ItemVariation, Order,
|
||||
OrderPosition, Organizer, Question, Quota, User, Voucher,
|
||||
OrderPosition, Organizer, Question, Quota, User, Voucher, WaitingListEntry,
|
||||
)
|
||||
from pretix.base.services.orders import (
|
||||
OrderError, cancel_order, mark_order_paid, perform_order,
|
||||
@@ -316,6 +316,104 @@ class QuotaTestCase(BaseQuotaTestCase):
|
||||
self.assertEqual(self.quota.count_in_cart(), 1)
|
||||
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 1))
|
||||
|
||||
def test_waitinglist_item_active(self):
|
||||
self.quota.items.add(self.item1)
|
||||
self.quota.size = 1
|
||||
self.quota.save()
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item1, email='foo@bar.com'
|
||||
)
|
||||
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_RESERVED, 0))
|
||||
self.assertEqual(self.item1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1))
|
||||
|
||||
def test_waitinglist_variation_active(self):
|
||||
self.quota.variations.add(self.var1)
|
||||
self.quota.size = 1
|
||||
self.quota.save()
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com'
|
||||
)
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_RESERVED, 0))
|
||||
self.assertEqual(self.var1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1))
|
||||
|
||||
def test_waitinglist_variation_fulfilled(self):
|
||||
self.quota.variations.add(self.var1)
|
||||
self.quota.size = 1
|
||||
self.quota.save()
|
||||
v = Voucher.objects.create(quota=self.quota, event=self.event, block_quota=True, redeemed=1)
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com', voucher=v
|
||||
)
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_OK, 1))
|
||||
self.assertEqual(self.var1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1))
|
||||
|
||||
def test_waitinglist_variation_other(self):
|
||||
self.quota.variations.add(self.var1)
|
||||
self.quota.size = 1
|
||||
self.quota.save()
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var2, email='foo@bar.com'
|
||||
)
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_OK, 1))
|
||||
self.assertEqual(self.var1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1))
|
||||
|
||||
def test_quota_cache(self):
|
||||
self.quota.variations.add(self.var1)
|
||||
self.quota.size = 1
|
||||
self.quota.save()
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com'
|
||||
)
|
||||
|
||||
cache = {}
|
||||
|
||||
self.assertEqual(self.var1.check_quotas(_cache=cache), (Quota.AVAILABILITY_RESERVED, 0))
|
||||
|
||||
with self.assertNumQueries(1):
|
||||
self.assertEqual(self.var1.check_quotas(_cache=cache), (Quota.AVAILABILITY_RESERVED, 0))
|
||||
|
||||
# Do not reuse cache for count_waitinglist=False
|
||||
self.assertEqual(self.var1.check_quotas(_cache=cache, count_waitinglist=False), (Quota.AVAILABILITY_OK, 1))
|
||||
|
||||
with self.assertNumQueries(1):
|
||||
self.assertEqual(self.var1.check_quotas(_cache=cache, count_waitinglist=False), (Quota.AVAILABILITY_OK, 1))
|
||||
|
||||
|
||||
class WaitingListTestCase(BaseQuotaTestCase):
|
||||
|
||||
def test_duplicate(self):
|
||||
w1 = WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com'
|
||||
)
|
||||
w1.clean()
|
||||
w2 = WaitingListEntry(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com'
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
w2.clean()
|
||||
|
||||
def test_duplicate_of_successful(self):
|
||||
v = Voucher.objects.create(quota=self.quota, event=self.event, block_quota=True, redeemed=1)
|
||||
w1 = WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com',
|
||||
voucher=v
|
||||
)
|
||||
w1.clean()
|
||||
w2 = WaitingListEntry(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com'
|
||||
)
|
||||
w2.clean()
|
||||
|
||||
def test_missing_variation(self):
|
||||
w2 = WaitingListEntry(
|
||||
event=self.event, item=self.item2, email='foo@bar.com'
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
w2.clean()
|
||||
|
||||
|
||||
class VoucherTestCase(BaseQuotaTestCase):
|
||||
|
||||
def test_voucher_reuse(self):
|
||||
self.quota.items.add(self.item1)
|
||||
v = Voucher.objects.create(quota=self.quota, event=self.event, valid_until=now() + timedelta(days=5))
|
||||
@@ -396,9 +494,6 @@ class QuotaTestCase(BaseQuotaTestCase):
|
||||
v = Voucher(variation=self.var1, event=self.event)
|
||||
v.clean()
|
||||
|
||||
|
||||
class VoucherTestCase(BaseQuotaTestCase):
|
||||
|
||||
def test_calculate_price_none(self):
|
||||
v = Voucher.objects.create(event=self.event, price_mode='none', value=Decimal('10.00'))
|
||||
v.calculate_price(Decimal('23.42')) == Decimal('23.42')
|
||||
|
||||
132
src/tests/base/test_waitinglist.py
Normal file
132
src/tests/base/test_waitinglist.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from django.core import mail as djmail
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.base.models import (
|
||||
Event, Item, ItemVariation, Organizer, Quota, Voucher, WaitingListEntry,
|
||||
)
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
from pretix.base.services.waitinglist import (
|
||||
assign_automatically, process_waitinglist,
|
||||
)
|
||||
|
||||
|
||||
class WaitingListTestCase(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
o = Organizer.objects.create(name='Dummy', slug='dummy')
|
||||
cls.event = Event.objects.create(
|
||||
organizer=o, name='Dummy', slug='dummy',
|
||||
date_from=now(),
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
djmail.outbox = []
|
||||
self.quota = Quota.objects.create(name="Test", size=2, event=self.event)
|
||||
self.item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23,
|
||||
admission=True)
|
||||
self.item2 = Item.objects.create(event=self.event, name="T-Shirt", default_price=23)
|
||||
self.item3 = Item.objects.create(event=self.event, name="Goodie", default_price=23)
|
||||
self.var1 = ItemVariation.objects.create(item=self.item2, value='S')
|
||||
self.var2 = ItemVariation.objects.create(item=self.item2, value='M')
|
||||
self.var3 = ItemVariation.objects.create(item=self.item3, value='Fancy')
|
||||
|
||||
def test_send_unavailable(self):
|
||||
self.quota.items.add(self.item1)
|
||||
self.quota.size = 0
|
||||
self.quota.save()
|
||||
wle = WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item1, email='foo@bar.com'
|
||||
)
|
||||
with self.assertRaises(WaitingListException):
|
||||
wle.send_voucher()
|
||||
|
||||
def test_send_double(self):
|
||||
self.quota.variations.add(self.var1)
|
||||
self.quota.size = 1
|
||||
self.quota.save()
|
||||
v = Voucher.objects.create(quota=self.quota, event=self.event, block_quota=True, redeemed=1)
|
||||
wle = WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com', voucher=v
|
||||
)
|
||||
with self.assertRaises(WaitingListException):
|
||||
wle.send_voucher()
|
||||
|
||||
def test_send_variation(self):
|
||||
wle = WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com'
|
||||
)
|
||||
wle.send_voucher()
|
||||
wle.refresh_from_db()
|
||||
|
||||
assert wle.voucher
|
||||
assert wle.voucher.item == wle.item
|
||||
assert wle.voucher.variation == wle.variation
|
||||
assert wle.email in wle.voucher.comment
|
||||
assert wle.voucher.block_quota
|
||||
assert wle.voucher.max_usages == 1
|
||||
assert wle.voucher.event == self.event
|
||||
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == [wle.email]
|
||||
|
||||
def test_send_custom_validity(self):
|
||||
self.event.settings.set('waiting_list_hours', 24)
|
||||
wle = WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com'
|
||||
)
|
||||
wle.send_voucher()
|
||||
wle.refresh_from_db()
|
||||
|
||||
assert 3600 * 23 < (wle.voucher.valid_until - now()).seconds < 3600 * 24
|
||||
|
||||
def test_send_auto(self):
|
||||
self.quota.variations.add(self.var1)
|
||||
self.quota.size = 7
|
||||
self.quota.save()
|
||||
for i in range(10):
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo{}@bar.com'.format(i)
|
||||
)
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item1, email='bar{}@bar.com'.format(i)
|
||||
)
|
||||
|
||||
assign_automatically.apply(args=(self.event.pk,))
|
||||
assert WaitingListEntry.objects.filter(voucher__isnull=True).count() == 3
|
||||
assert Voucher.objects.count() == 17
|
||||
assert sorted(list(WaitingListEntry.objects.filter(voucher__isnull=True).values_list('email', flat=True))) == [
|
||||
'foo7@bar.com', 'foo8@bar.com', 'foo9@bar.com'
|
||||
]
|
||||
|
||||
def test_send_periodic(self):
|
||||
self.event.settings.set('waiting_list_enabled', True)
|
||||
self.event.settings.set('waiting_list_auto', True)
|
||||
for i in range(5):
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo{}@bar.com'.format(i)
|
||||
)
|
||||
process_waitinglist(None)
|
||||
assert Voucher.objects.count() == 5
|
||||
|
||||
def test_send_periodic_disabled(self):
|
||||
self.event.settings.set('waiting_list_enabled', True)
|
||||
self.event.settings.set('waiting_list_auto', False)
|
||||
for i in range(5):
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo{}@bar.com'.format(i)
|
||||
)
|
||||
process_waitinglist(None)
|
||||
assert WaitingListEntry.objects.filter(voucher__isnull=True).count() == 5
|
||||
assert Voucher.objects.count() == 0
|
||||
|
||||
def test_send_periodic_disabled2(self):
|
||||
self.event.settings.set('waiting_list_enabled', False)
|
||||
self.event.settings.set('waiting_list_auto', True)
|
||||
for i in range(5):
|
||||
WaitingListEntry.objects.create(
|
||||
event=self.event, item=self.item2, variation=self.var1, email='foo{}@bar.com'.format(i)
|
||||
)
|
||||
process_waitinglist(None)
|
||||
assert WaitingListEntry.objects.filter(voucher__isnull=True).count() == 5
|
||||
assert Voucher.objects.count() == 0
|
||||
@@ -67,6 +67,8 @@ event_urls = [
|
||||
"orders/ABC/contact",
|
||||
"orders/ABC/",
|
||||
"orders/",
|
||||
"waitinglist/",
|
||||
"waitinglist/auto_assign",
|
||||
"invoice/1",
|
||||
]
|
||||
|
||||
@@ -154,6 +156,8 @@ event_permission_urls = [
|
||||
("can_view_vouchers", "vouchers/tags/", 200),
|
||||
("can_change_vouchers", "vouchers/1234/", 404),
|
||||
("can_change_vouchers", "vouchers/1234/delete", 404),
|
||||
("can_view_orders", "waitinglist/", 200),
|
||||
("can_change_orders", "waitinglist/auto_assign", 405),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@ def logged_in_client(client, event):
|
||||
('/control/event/{orga}/{event}/orders/export/', 200),
|
||||
('/control/event/{orga}/{event}/orders/go', 302),
|
||||
('/control/event/{orga}/{event}/orders/', 200),
|
||||
('/control/event/{orga}/{event}/waitinglist/', 200),
|
||||
('/control/event/{orga}/{event}/waitinglist/auto_assign', 405),
|
||||
])
|
||||
@pytest.mark.django_db
|
||||
def test_one_view(logged_in_client, url, expected, event, item, item_category, order, question, quota, voucher):
|
||||
|
||||
87
src/tests/control/test_waitinglist.py
Normal file
87
src/tests/control/test_waitinglist.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import pytest
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.base.models import (
|
||||
Event, EventPermission, Item, Organizer, Quota, User, Voucher,
|
||||
WaitingListEntry,
|
||||
)
|
||||
from pretix.control.views.dashboards import waitinglist_widgets
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env():
|
||||
o = Organizer.objects.create(name='Dummy', slug='dummy')
|
||||
event = Event.objects.create(
|
||||
organizer=o, name='Dummy', slug='dummy',
|
||||
date_from=now(), plugins='pretix.plugins.banktransfer,tests.testdummy'
|
||||
)
|
||||
event.settings.set('ticketoutput_testdummy__enabled', True)
|
||||
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
|
||||
item1 = Item.objects.create(event=event, name="Ticket", default_price=23,
|
||||
admission=True)
|
||||
item2 = Item.objects.create(event=event, name="Ticket", default_price=23,
|
||||
admission=True)
|
||||
|
||||
for i in range(5):
|
||||
WaitingListEntry.objects.create(
|
||||
event=event, item=item1, email='foo{}@bar.com'.format(i)
|
||||
)
|
||||
v = Voucher.objects.create(item=item1, event=event, block_quota=True, redeemed=1)
|
||||
WaitingListEntry.objects.create(
|
||||
event=event, item=item1, email='success@example.org', voucher=v
|
||||
)
|
||||
WaitingListEntry.objects.create(
|
||||
event=event, item=item2, email='item2@example.org'
|
||||
)
|
||||
|
||||
EventPermission.objects.create(
|
||||
event=event,
|
||||
user=user,
|
||||
can_view_orders=True,
|
||||
can_change_orders=True
|
||||
)
|
||||
return event, user, o, item1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list(client, env):
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
response = client.get('/control/event/dummy/dummy/waitinglist/')
|
||||
assert 'success@example.org' not in response.rendered_content
|
||||
assert 'item2@example.org' in response.rendered_content
|
||||
assert 'foo0@bar.com' in response.rendered_content
|
||||
assert response.context['estimate'] == 23 * 6
|
||||
|
||||
response = client.get('/control/event/dummy/dummy/waitinglist/?status=a')
|
||||
assert 'success@example.org' in response.rendered_content
|
||||
assert 'foo0@bar.com' in response.rendered_content
|
||||
|
||||
response = client.get('/control/event/dummy/dummy/waitinglist/?status=s')
|
||||
assert 'success@example.org' in response.rendered_content
|
||||
assert 'foo0@bar.com' not in response.rendered_content
|
||||
|
||||
response = client.get('/control/event/dummy/dummy/waitinglist/?item=%d' % env[3].pk)
|
||||
assert 'item2@example.org' not in response.rendered_content
|
||||
assert 'foo0@bar.com' in response.rendered_content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_single(client, env):
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
wle = WaitingListEntry.objects.filter(voucher__isnull=True).last()
|
||||
|
||||
client.post('/control/event/dummy/dummy/waitinglist/', {
|
||||
'assign': wle.pk
|
||||
})
|
||||
wle.refresh_from_db()
|
||||
assert wle.voucher
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dashboard(client, env):
|
||||
quota = Quota.objects.create(name="Test", size=2, event=env[0])
|
||||
quota.items.add(env[3])
|
||||
w = waitinglist_widgets(env[0])
|
||||
assert '3' in w[0]['content']
|
||||
assert '7' in w[1]['content']
|
||||
@@ -9,7 +9,7 @@ from tests.base import SoupTest
|
||||
|
||||
from pretix.base.models import (
|
||||
Event, EventPermission, Item, ItemCategory, ItemVariation, Order,
|
||||
Organizer, Quota, User,
|
||||
Organizer, Quota, User, WaitingListEntry,
|
||||
)
|
||||
|
||||
|
||||
@@ -343,6 +343,72 @@ class VoucherRedeemItemDisplayTest(EventTestMixin, SoupTest):
|
||||
assert "alert-danger" in html.rendered_content
|
||||
|
||||
|
||||
class WaitingListTest(EventTestMixin, SoupTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.q = Quota.objects.create(event=self.event, name='Quota', size=0)
|
||||
self.v = self.event.vouchers.create(quota=self.q)
|
||||
self.item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=Decimal('12.00'),
|
||||
active=True)
|
||||
self.q.items.add(self.item)
|
||||
self.event.settings.set('waiting_list_enabled', True)
|
||||
|
||||
def test_disabled(self):
|
||||
self.event.settings.set('waiting_list_enabled', False)
|
||||
response = self.client.get(
|
||||
'/%s/%s/' % (self.orga.slug, self.event.slug)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn('waitinglist', response.rendered_content)
|
||||
response = self.client.get(
|
||||
'/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk + 1)
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_display_link(self):
|
||||
response = self.client.get(
|
||||
'/%s/%s/' % (self.orga.slug, self.event.slug)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('waitinglist', response.rendered_content)
|
||||
|
||||
def test_submit_form(self):
|
||||
response = self.client.get(
|
||||
'/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('waiting list', response.rendered_content)
|
||||
response = self.client.post(
|
||||
'/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk), {
|
||||
'email': 'foo@bar.com'
|
||||
}
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
wle = WaitingListEntry.objects.get(email='foo@bar.com')
|
||||
assert wle.event == self.event
|
||||
assert wle.item == self.item
|
||||
assert wle.variation is None
|
||||
assert wle.voucher is None
|
||||
assert wle.locale == 'en'
|
||||
|
||||
def test_invalid_item(self):
|
||||
response = self.client.get(
|
||||
'/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk + 1)
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_available(self):
|
||||
self.q.size = 1
|
||||
self.q.save()
|
||||
response = self.client.post(
|
||||
'/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk), {
|
||||
'email': 'foo@bar.com'
|
||||
}
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertFalse(WaitingListEntry.objects.filter(email='foo@bar.com').exists())
|
||||
|
||||
|
||||
class DeadlineTest(EventTestMixin, TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
Reference in New Issue
Block a user