Allow quotas to "close" when once full (#1344)

* Model

* Some UI

* API and logging

* Permission check

* Add tests

* Move option around
This commit is contained in:
Raphael Michel
2019-07-16 14:02:27 +02:00
committed by GitHub
parent c1e2fb36ba
commit a02ea45dba
16 changed files with 219 additions and 12 deletions

View File

@@ -20,12 +20,22 @@ size integer The size of the
items list of integers List of item IDs this quota acts on. items list of integers List of item IDs this quota acts on.
variations list of integers List of item variation IDs this quota acts on. variations list of integers List of item variation IDs this quota acts on.
subevent integer ID of the date inside an event series this quota belongs to (or ``null``). subevent integer ID of the date inside an event series this quota belongs to (or ``null``).
close_when_sold_out boolean If ``true``, the quota will "close" as soon as it is
sold out once. Even if tickets become available again,
they will not be sold unless the quota is set to open
again.
closed boolean Whether the quota is currently closed (see above
field).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.10 .. versionchanged:: 1.10
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 3.0
The attributes ``close_when_sold_out`` and ``closed`` have been added.
Endpoints Endpoints
--------- ---------
@@ -61,7 +71,9 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
] ]
} }
@@ -102,7 +114,9 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -130,7 +144,9 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
**Example response**: **Example response**:
@@ -147,7 +163,9 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a quota for :param organizer: The ``slug`` field of the organizer of the event/item to create a quota for
@@ -200,7 +218,9 @@ Endpoints
1, 1,
2 2
], ],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify

View File

@@ -285,7 +285,7 @@ class QuotaSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Quota model = Quota
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent') fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)

View File

@@ -473,6 +473,19 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
# This costs us a few cycles on save, but avoids thousands of lines in our log. # This costs us a few cycles on save, but avoids thousands of lines in our log.
return return
if original_data['closed'] is True and serializer.instance.closed is False:
serializer.instance.log_action(
'pretix.event.quota.opened',
user=self.request.user,
auth=self.request.auth,
)
elif original_data['closed'] is False and serializer.instance.closed is True:
serializer.instance.log_action(
'pretix.event.quota.closed',
user=self.request.user,
auth=self.request.auth,
)
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.quota.changed', 'pretix.event.quota.changed',
user=self.request.user, user=self.request.user,

View File

@@ -0,0 +1,26 @@
# Generated by Django 2.2.1 on 2019-07-15 15:10
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0127_auto_20190711_0705'),
]
operations = [
migrations.AddField(
model_name='quota',
name='close_when_sold_out',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='quota',
name='closed',
field=models.BooleanField(default=False),
),
]

View File

@@ -518,6 +518,11 @@ class Event(EventMixin, LoggedModel):
vars = list(q.variations.all()) vars = list(q.variations.all())
q.pk = None q.pk = None
q.event = self q.event = self
q.cached_availability_state = None
q.cached_availability_number = None
q.cached_availability_paid_orders = None
q.cached_availability_time = None
q.closed = False
q.save() q.save()
for i in items: for i in items:
if i.pk in item_map: if i.pk in item_map:

View File

@@ -1269,6 +1269,15 @@ class Quota(LoggedModel):
cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True) cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True)
cached_availability_time = models.DateTimeField(null=True, blank=True) cached_availability_time = models.DateTimeField(null=True, blank=True)
close_when_sold_out = models.BooleanField(
verbose_name=_('Close this quota permanently once it is sold out'),
help_text=_('If you enable this, when the quota is sold out once, no more tickets will be sold, '
'even if tickets become available again through cancellations or expiring orders. Of course, '
'you can always re-open it manually.'),
default=False
)
closed = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer') objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
@@ -1334,6 +1343,11 @@ class Quota(LoggedModel):
count_waitinglist=count_waitinglist): count_waitinglist=count_waitinglist):
res = resp res = resp
if res[0] <= Quota.AVAILABILITY_ORDERED and self.close_when_sold_out and not self.closed:
self.closed = True
self.save(update_fields=['closed'])
self.log_action('pretix.event.quota.closed')
self.event.cache.delete('item_quota_cache') self.event.cache.delete('item_quota_cache')
rewrite_cache = count_waitinglist and ( rewrite_cache = count_waitinglist and (
not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state
@@ -1358,8 +1372,11 @@ class Quota(LoggedModel):
_cache['_count_waitinglist'] = count_waitinglist _cache['_count_waitinglist'] = count_waitinglist
return res return res
def _availability(self, now_dt: datetime=None, count_waitinglist=True): def _availability(self, now_dt: datetime=None, count_waitinglist=True, ignore_closed=False):
now_dt = now_dt or now() now_dt = now_dt or now()
if self.closed and not ignore_closed:
return Quota.AVAILABILITY_ORDERED, 0
size_left = self.size size_left = self.size
if size_left is None: if size_left is None:
return Quota.AVAILABILITY_OK, None return Quota.AVAILABILITY_OK, None

View File

@@ -169,7 +169,8 @@ class QuotaForm(I18nModelForm):
fields = [ fields = [
'name', 'name',
'size', 'size',
'subevent' 'subevent',
'close_when_sold_out'
] ]
field_classes = { field_classes = {
'subevent': SafeModelChoiceField, 'subevent': SafeModelChoiceField,

View File

@@ -263,6 +263,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.quota.added': _('The quota has been added.'), 'pretix.event.quota.added': _('The quota has been added.'),
'pretix.event.quota.deleted': _('The quota has been deleted.'), 'pretix.event.quota.deleted': _('The quota has been deleted.'),
'pretix.event.quota.changed': _('The quota has been changed.'), 'pretix.event.quota.changed': _('The quota has been changed.'),
'pretix.event.quota.closed': _('The quota has closed.'),
'pretix.event.quota.opened': _('The quota has been re-opened.'),
'pretix.event.category.added': _('The category has been added.'), 'pretix.event.category.added': _('The category has been added.'),
'pretix.event.category.deleted': _('The category has been deleted.'), 'pretix.event.category.deleted': _('The category has been deleted.'),
'pretix.event.category.changed': _('The category has been changed.'), 'pretix.event.category.changed': _('The category has been changed.'),

View File

@@ -1,5 +1,7 @@
{% load i18n %} {% load i18n %}
{% if availability.0 == 10 %} {% if closed %}
<span class="label label-danger">{% trans "Closed" %}</span>
{% elif availability.0 == 10 %}
<span class="label label-warning">{% trans "Sold out (pending orders)" %}</span> <span class="label label-warning">{% trans "Sold out (pending orders)" %}</span>
{% elif availability.0 == 100 %} {% elif availability.0 == 100 %}
{% if availability.1 != None %} {% if availability.1 != None %}

View File

@@ -20,6 +20,27 @@
<span class="fa fa-calendar"></span> {{ quota.subevent.name }} {{ quota.subevent.get_date_range_display }} <span class="fa fa-calendar"></span> {{ quota.subevent.name }} {{ quota.subevent.get_date_range_display }}
</p> </p>
{% endif %} {% endif %}
<form action="" method="post">
{% csrf_token %}
{% if quota.closed %}
{% if closed_and_sold_out %}
<div class="alert alert-info">
<button type="submit" class="btn btn-default pull-right" name="disable" value="true">
{% trans "Open quota and disable closing" %}
</button>
{% trans "This quota is sold out and closed. Even if tickets become available e.g. through cancellations, they will not become available again unless you manually re-open the quota on this page." %}
<div class="clearfix"></div>
</div>
{% else %}
<div class="alert alert-warning">
<button type="submit" class="btn btn-primary pull-right" name="reopen" value="true">{% trans "Open quota" %}</button>
{% trans "This quota is closed since it has been sold out before. Tickets are theoretically available, but will not be sold unless you manually re-open the quota." %}
<div class="clearfix"></div>
</div>
{% endif %}
{% endif %}
</form>
<div class="row" id="quota-stats"> <div class="row" id="quota-stats">
<div class="col-md-5 col-xs-12"> <div class="col-md-5 col-xs-12">
<legend>{% trans "Usage overview" %}</legend> <legend>{% trans "Usage overview" %}</legend>
@@ -30,7 +51,6 @@
</div> </div>
<div class="col-md-5 col-xs-12"> <div class="col-md-5 col-xs-12">
<legend>{% trans "Availability calculation" %}</legend> <legend>{% trans "Availability calculation" %}</legend>
<div class="row"> <div class="row">
<div class="col-xs-9">{% trans "Total quota" %}</div> <div class="col-xs-9">{% trans "Total quota" %}</div>
<div class="col-xs-3 text-right"> <div class="col-xs-3 text-right">

View File

@@ -24,6 +24,8 @@
{% if form.subevent %} {% if form.subevent %}
{% bootstrap_field form.subevent layout="control" %} {% bootstrap_field form.subevent layout="control" %}
{% endif %} {% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Items" %}</legend> <legend>{% trans "Items" %}</legend>
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
@@ -35,6 +37,10 @@
</p> </p>
{% bootstrap_field form.itemvars layout="control" %} {% bootstrap_field form.itemvars layout="control" %}
</fieldset> </fieldset>
<fieldset>
<legend>{% trans "Advanced options" %}</legend>
{% bootstrap_field form.close_when_sold_out layout="control" %}
</fieldset>
<div class="form-group submit-group"> <div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %} {% trans "Save" %}

View File

@@ -74,7 +74,7 @@
<td>{{ q.subevent.name }} {{ q.subevent.get_date_range_display }}</td> <td>{{ q.subevent.name }} {{ q.subevent.get_date_range_display }}</td>
{% endif %} {% endif %}
<td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td> <td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td>
<td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.availability %}</td> <td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.availability closed=q.closed %}</td>
<td class="text-right"> <td class="text-right">
<a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> <a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a> <a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>

View File

@@ -1,6 +1,7 @@
import json import json
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File from django.core.files import File
from django.db import transaction from django.db import transaction
from django.db.models import Count, F, Prefetch, Q from django.db.models import Count, F, Prefetch, Q
@@ -684,6 +685,8 @@ class QuotaView(ChartContainingView, DetailView):
Q(Q(self.object._position_lookup) | Q(quota=self.object)) & Q(Q(self.object._position_lookup) | Q(quota=self.object)) &
Q(redeemed__lt=F('max_usages')) Q(redeemed__lt=F('max_usages'))
).exists() ).exists()
if self.object.closed:
ctx['closed_and_sold_out'] = self.object._availability(ignore_closed=True)[0] <= Quota.AVAILABILITY_ORDERED
return ctx return ctx
@@ -695,6 +698,32 @@ class QuotaView(ChartContainingView, DetailView):
except Quota.DoesNotExist: except Quota.DoesNotExist:
raise Http404(_("The requested quota does not exist.")) raise Http404(_("The requested quota does not exist."))
def post(self, request, *args, **kwargs):
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_items', request):
raise PermissionDenied()
quota = self.get_object()
if 'reopen' in request.POST:
quota.closed = False
quota.save(update_fields=['closed'])
quota.log_action('pretix.event.quota.opened', user=request.user)
messages.success(request, _('The quota has been re-opened.'))
if 'disable' in request.POST:
quota.closed = False
quota.close_when_sold_out = False
quota.save(update_fields=['closed', 'close_when_sold_out'])
quota.log_action('pretix.event.quota.opened', user=request.user)
quota.log_action(
'pretix.event.quota.changed', user=self.request.user, data={
'close_when_sold_out': False
}
)
messages.success(request, _('The quota has been re-opened and will not close again.'))
return redirect(reverse('control:event.items.quotas.show', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'quota': quota.pk
}))
class QuotaUpdate(EventPermissionRequiredMixin, UpdateView): class QuotaUpdate(EventPermissionRequiredMixin, UpdateView):
model = Quota model = Quota

View File

@@ -1386,7 +1386,9 @@ TEST_QUOTA_RES = {
"size": 200, "size": 200,
"items": [], "items": [],
"variations": [], "variations": [],
"subevent": None "subevent": None,
"close_when_sold_out": False,
"closed": False
} }
@@ -1593,6 +1595,30 @@ def test_quota_update(token_client, organizer, event, quota, item):
assert quota.all_logentries().count() == 1 assert quota.all_logentries().count() == 1
@pytest.mark.django_db
def test_quota_update_closed(token_client, organizer, event, quota, item):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/quotas/{}/'.format(organizer.slug, event.slug, quota.pk),
{
"closed": True,
},
format='json'
)
assert resp.status_code == 200
with scopes_disabled():
quota = Quota.objects.get(pk=resp.data['id'])
assert quota.all_logentries().filter(action_type="pretix.event.quota.closed").count() == 1
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/quotas/{}/'.format(organizer.slug, event.slug, quota.pk),
{
"closed": False,
},
format='json'
)
assert resp.status_code == 200
assert quota.all_logentries().filter(action_type="pretix.event.quota.opened").count() == 1
@pytest.mark.django_db @pytest.mark.django_db
def test_quota_update_unchanged(token_client, organizer, event, quota, item): def test_quota_update_unchanged(token_client, organizer, event, quota, item):
resp = token_client.patch( resp = token_client.patch(

View File

@@ -483,6 +483,24 @@ class QuotaTestCase(BaseQuotaTestCase):
self.event.has_subevents = False self.event.has_subevents = False
self.event.save() self.event.save()
@classscope(attr='o')
def test_close_when_full_on_calculation(self):
self.quota.close_when_sold_out = True
self.quota.size = 0
self.quota.save()
assert not self.quota.closed
self.quota.availability()
self.quota.refresh_from_db()
assert self.quota.closed
assert self.quota.all_logentries().filter(action_type="pretix.event.quota.closed").exists()
@classscope(attr='o')
def test_closed_reports_as_sold_out(self):
self.quota.closed = True
self.quota.size = 100
self.quota.save()
assert self.quota.availability() == (Quota.AVAILABILITY_ORDERED, 0)
class BundleQuotaTestCase(BaseQuotaTestCase): class BundleQuotaTestCase(BaseQuotaTestCase):
def setUp(self): def setUp(self):

View File

@@ -338,6 +338,28 @@ class QuotaTest(ItemFormTest):
with scopes_disabled(): with scopes_disabled():
assert not Quota.objects.filter(id=c.id).exists() assert not Quota.objects.filter(id=c.id).exists()
def test_reopen(self):
with scopes_disabled():
c = Quota.objects.create(event=self.event1, name="Full house", size=500,
close_when_sold_out=True, closed=True)
self.post_doc('/control/event/%s/%s/quotas/%s/' % (self.orga1.slug, self.event1.slug, c.id),
{'reopen': 'true'})
with scopes_disabled():
c.refresh_from_db()
assert not c.closed
assert c.close_when_sold_out
def test_reopen_and_disable(self):
with scopes_disabled():
c = Quota.objects.create(event=self.event1, name="Full house", size=500,
close_when_sold_out=True, closed=True)
self.post_doc('/control/event/%s/%s/quotas/%s/' % (self.orga1.slug, self.event1.slug, c.id),
{'disable': 'true'})
with scopes_disabled():
c.refresh_from_db()
assert not c.closed
assert not c.close_when_sold_out
class ItemsTest(ItemFormTest): class ItemsTest(ItemFormTest):