mirror of
https://github.com/pretix/pretix.git
synced 2026-05-20 17:44:02 +00:00
Allow to hide a product unless a specific quota is sold out (#1351)
* Allow to hide a product unless a specific quota is sold out * Fix required property * Add API property and copy between events
This commit is contained in:
@@ -119,7 +119,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
'show_quota_left')
|
||||
'show_quota_left', 'hidden_if_available')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
21
src/pretix/base/migrations/0129_auto_20190724_1548.py
Normal file
21
src/pretix/base/migrations/0129_auto_20190724_1548.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 2.2.1 on 2019-07-24 15:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0128_auto_20190715_1510'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='hidden_if_available',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='pretixbase.Quota'),
|
||||
),
|
||||
]
|
||||
@@ -516,6 +516,7 @@ class Event(EventMixin, LoggedModel):
|
||||
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
||||
items = list(q.items.all())
|
||||
vars = list(q.variations.all())
|
||||
oldid = q.pk
|
||||
q.pk = None
|
||||
q.event = self
|
||||
q.cached_availability_state = None
|
||||
@@ -529,6 +530,7 @@ class Event(EventMixin, LoggedModel):
|
||||
q.items.add(item_map[i.pk])
|
||||
for v in vars:
|
||||
q.variations.add(variation_map[v.pk])
|
||||
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
|
||||
|
||||
question_map = {}
|
||||
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
|
||||
|
||||
@@ -334,6 +334,17 @@ class Item(LoggedModel):
|
||||
null=True, blank=True,
|
||||
help_text=_('This product will not be sold after the given date.')
|
||||
)
|
||||
hidden_if_available = models.ForeignKey(
|
||||
'Quota',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("Only show after sellout of"),
|
||||
help_text=_("If you select a quota here, this product will only be shown when that quota is "
|
||||
"unavailable. If combined with the option to hide sold-out products, this allows you to "
|
||||
"swap out products for more expensive ones once they are sold out. There might be a short period "
|
||||
"in which both products are visible while all tickets in the referenced quota are reserved, "
|
||||
"but not yet sold.")
|
||||
)
|
||||
require_voucher = models.BooleanField(
|
||||
verbose_name=_('This product can only be bought using a voucher.'),
|
||||
default=False,
|
||||
|
||||
@@ -402,6 +402,19 @@ class ItemUpdateForm(I18nModelForm):
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
change_decimal_field(self.fields['default_price'], self.event.currency)
|
||||
self.fields['hidden_if_available'].queryset = self.event.quotas.all()
|
||||
self.fields['hidden_if_available'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:event.items.quotas.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Quota')
|
||||
}
|
||||
)
|
||||
self.fields['hidden_if_available'].widget.choices = self.fields['hidden_if_available'].choices
|
||||
self.fields['hidden_if_available'].required = False
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
@@ -430,11 +443,13 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'generate_tickets',
|
||||
'original_price',
|
||||
'require_bundling',
|
||||
'show_quota_left'
|
||||
'show_quota_left',
|
||||
'hidden_if_available',
|
||||
]
|
||||
field_classes = {
|
||||
'available_from': SplitDateTimeField,
|
||||
'available_until': SplitDateTimeField,
|
||||
'hidden_if_available': SafeModelChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'available_from': SplitDateTimePickerWidget(),
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
{% bootstrap_field form.available_until layout="control" %}
|
||||
{% bootstrap_field form.max_per_order layout="control" %}
|
||||
{% bootstrap_field form.min_per_order layout="control" %}
|
||||
{% bootstrap_field form.hidden_if_available layout="control" %}
|
||||
{% bootstrap_field form.require_voucher layout="control" %}
|
||||
{% bootstrap_field form.hide_without_voucher layout="control" %}
|
||||
{% bootstrap_field form.require_bundling layout="control" %}
|
||||
|
||||
@@ -181,6 +181,7 @@ urlpatterns = [
|
||||
url(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
|
||||
url(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
|
||||
url(r'^quotas/(?P<quota>\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'),
|
||||
url(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'),
|
||||
url(r'^quotas/(?P<quota>\d+)/change$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'),
|
||||
url(r'^quotas/(?P<quota>\d+)/delete$', item.QuotaDelete.as_view(),
|
||||
name='event.items.quotas.delete'),
|
||||
|
||||
@@ -309,6 +309,53 @@ def subevent_select2(request, **kwargs):
|
||||
return JsonResponse(doc)
|
||||
|
||||
|
||||
@event_permission_required(None)
|
||||
def quotas_select2(request, **kwargs):
|
||||
query = request.GET.get('query', '')
|
||||
try:
|
||||
page = int(request.GET.get('page', '1'))
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
qf = Q(name__icontains=query) | Q(subevent__name__icontains=i18ncomp(query))
|
||||
tz = request.event.timezone
|
||||
|
||||
dt = None
|
||||
for f in get_format('DATE_INPUT_FORMATS'):
|
||||
try:
|
||||
dt = datetime.strptime(query, f)
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if dt and request.event.has_subevents:
|
||||
dt_start = make_aware(datetime.combine(dt.date(), time(hour=0, minute=0, second=0)), tz)
|
||||
dt_end = make_aware(datetime.combine(dt.date(), time(hour=23, minute=59, second=59)), tz)
|
||||
qf |= Q(subevent__date_from__gte=dt_start) & Q(subevent__date_from__lte=dt_end)
|
||||
|
||||
qs = request.event.quotas.filter(
|
||||
qf
|
||||
).order_by('-subevent__date_from', 'name')
|
||||
|
||||
total = qs.count()
|
||||
pagesize = 20
|
||||
offset = (page - 1) * pagesize
|
||||
doc = {
|
||||
'results': [
|
||||
{
|
||||
'id': q.pk,
|
||||
'name': str(q.name),
|
||||
'text': q.name
|
||||
}
|
||||
for q in qs[offset:offset + pagesize]
|
||||
],
|
||||
'pagination': {
|
||||
"more": total >= (offset + pagesize)
|
||||
}
|
||||
}
|
||||
return JsonResponse(doc)
|
||||
|
||||
|
||||
@event_permission_required(None)
|
||||
def checkinlist_select2(request, **kwargs):
|
||||
query = request.GET.get('query', '')
|
||||
|
||||
@@ -254,6 +254,11 @@ class AddOnsForm(forms.Form):
|
||||
self.vars_cache = {}
|
||||
|
||||
for i in items:
|
||||
if i.hidden_if_available:
|
||||
q = i.hidden_if_available.availability(_cache=quota_cache)
|
||||
if q[0] == Quota.AVAILABILITY_OK:
|
||||
continue
|
||||
|
||||
if i.has_variations:
|
||||
choices = [('', _('no selection'), '')]
|
||||
for v in i.available_variations:
|
||||
|
||||
@@ -52,6 +52,7 @@ def item_group_by_category(items):
|
||||
def get_grouped_items(event, subevent=None, voucher=None, channel='web', require_seat=0):
|
||||
items = event.items.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).select_related(
|
||||
'category', 'tax_rule', # for re-grouping
|
||||
'hidden_if_available',
|
||||
).prefetch_related(
|
||||
Prefetch('quotas',
|
||||
to_attr='_subevent_quotas',
|
||||
@@ -119,6 +120,12 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require
|
||||
|
||||
max_per_order = item.max_per_order or int(event.settings.max_items_per_order)
|
||||
|
||||
if item.hidden_if_available:
|
||||
q = item.hidden_if_available.availability(_cache=quota_cache)
|
||||
if q[0] == Quota.AVAILABILITY_OK:
|
||||
item._remove = True
|
||||
continue
|
||||
|
||||
if not item.has_variations:
|
||||
item._remove = False
|
||||
if not bool(item._subevent_quotas):
|
||||
|
||||
@@ -234,6 +234,7 @@ TEST_ITEM_RES = {
|
||||
"allow_cancel": True,
|
||||
"min_per_order": None,
|
||||
"max_per_order": None,
|
||||
"hidden_if_available": None,
|
||||
"checkin_attention": False,
|
||||
"has_variations": False,
|
||||
"require_approval": False,
|
||||
|
||||
@@ -660,10 +660,14 @@ class EventsTest(SoupTest):
|
||||
tr = self.event1.tax_rules.create(
|
||||
rate=19, name="VAT"
|
||||
)
|
||||
q1 = self.event1.quotas.create(
|
||||
name='Foo',
|
||||
size=0,
|
||||
)
|
||||
self.event1.items.create(
|
||||
name='Early-bird ticket',
|
||||
category=None, default_price=23, tax_rule=tr,
|
||||
admission=True
|
||||
admission=True, hidden_if_available=q1
|
||||
)
|
||||
self.event1.settings.tax_rate_default = tr
|
||||
doc = self.get_doc('/control/events/add')
|
||||
@@ -724,6 +728,10 @@ class EventsTest(SoupTest):
|
||||
assert ev.presale_end == berlin_tz.localize(datetime.datetime(2016, 11, 30, 18, 0, 0)).astimezone(pytz.utc)
|
||||
|
||||
assert ev.tax_rules.filter(rate=Decimal('19.00')).count() == 1
|
||||
i = ev.items.get()
|
||||
assert i.hidden_if_available.name == "Foo"
|
||||
assert i.hidden_if_available.event == ev
|
||||
assert i.hidden_if_available.pk != q1.pk
|
||||
|
||||
def test_create_event_clone_success(self):
|
||||
with scopes_disabled():
|
||||
|
||||
@@ -1781,6 +1781,53 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
|
||||
assert 'Workshop 1' in response.rendered_content
|
||||
assert '€12.00' not in response.rendered_content
|
||||
|
||||
def test_set_addons_hide_sold_out(self):
|
||||
with scopes_disabled():
|
||||
self.workshopquota.size = 0
|
||||
self.workshopquota.save()
|
||||
|
||||
ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1)
|
||||
CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=23, expires=now() - timedelta(minutes=10)
|
||||
)
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
assert 'Workshop 1' in response.rendered_content
|
||||
self.event.settings.hide_sold_out = True
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert 'Workshop 1' not in response.rendered_content
|
||||
|
||||
def test_set_addons_hidden_if_available(self):
|
||||
with scopes_disabled():
|
||||
self.workshopquota2 = Quota.objects.create(event=self.event, name='Workshop 1', size=5)
|
||||
self.workshopquota2.items.add(self.workshop2)
|
||||
self.workshopquota2.variations.add(self.workshop2a)
|
||||
self.workshop2.hidden_if_available = self.workshopquota
|
||||
self.workshop2.save()
|
||||
|
||||
ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1)
|
||||
CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=23, expires=now() - timedelta(minutes=10)
|
||||
)
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
assert 'Workshop 1' in response.rendered_content
|
||||
assert 'Workshop 2' not in response.rendered_content
|
||||
|
||||
self.workshopquota.size = 0
|
||||
self.workshopquota.save()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert 'Workshop 1' in response.rendered_content
|
||||
assert 'Workshop 2' in response.rendered_content
|
||||
|
||||
def test_set_addons_subevent(self):
|
||||
with scopes_disabled():
|
||||
self.event.has_subevents = True
|
||||
|
||||
@@ -364,6 +364,30 @@ class ItemDisplayTest(EventTestMixin, SoupTest):
|
||||
self.assertNotIn("Early-bird", doc.select("section:nth-of-type(1) div:nth-of-type(1)")[0].text)
|
||||
self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text)
|
||||
|
||||
def test_hidden_if_available(self):
|
||||
with scopes_disabled():
|
||||
q = Quota.objects.create(event=self.event, name='Early-bird', size=10)
|
||||
q2 = Quota.objects.create(event=self.event, name='Late-bird', size=10)
|
||||
item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=12)
|
||||
item2 = Item.objects.create(event=self.event, name='Late-bird ticket', default_price=12,
|
||||
hidden_if_available=q)
|
||||
q.items.add(item)
|
||||
q2.items.add(item2)
|
||||
self.event.settings.hide_sold_out = True
|
||||
|
||||
doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
|
||||
self.assertIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text)
|
||||
self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text)
|
||||
self.assertNotIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text)
|
||||
|
||||
q.size = 0
|
||||
q.save()
|
||||
|
||||
doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
|
||||
self.assertNotIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text)
|
||||
self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text)
|
||||
self.assertIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text)
|
||||
|
||||
def test_bundle_sold_out(self):
|
||||
with scopes_disabled():
|
||||
q = Quota.objects.create(event=self.event, name='Quota', size=2)
|
||||
|
||||
Reference in New Issue
Block a user