forked from CGM_Public/pretix_original
Allow to restrict availability of variations by date, sales channel, and voucher (#2202)
This commit is contained in:
@@ -59,7 +59,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types',)
|
||||
'require_membership', 'require_membership_types', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -74,7 +75,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types',)
|
||||
'require_membership', 'require_membership_types', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
36
src/pretix/base/migrations/0197_auto_20210914_0814.py
Normal file
36
src/pretix/base/migrations/0197_auto_20210914_0814.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 3.2.4 on 2021-09-14 08:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
import pretix.base.models.items
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0196_auto_20210523_1322'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='available_from',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='available_until',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='hide_without_voucher',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='sales_channels',
|
||||
field=pretix.base.models.fields.MultiStringField(default=pretix.base.models.items._all_sales_channels_identifiers),
|
||||
),
|
||||
]
|
||||
@@ -268,6 +268,10 @@ class EventMixin:
|
||||
).values('items')
|
||||
sq_active_variation = ItemVariation.objects.filter(
|
||||
Q(active=True)
|
||||
& Q(sales_channels__contains=channel)
|
||||
& Q(hide_without_voucher=False)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(item__active=True)
|
||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||
|
||||
@@ -736,6 +736,11 @@ class Item(LoggedModel):
|
||||
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
|
||||
|
||||
|
||||
def _all_sales_channels_identifiers():
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
return list(get_all_sales_channels().keys())
|
||||
|
||||
|
||||
class ItemVariation(models.Model):
|
||||
"""
|
||||
A variation of a product. For example, if your item is 'T-Shirt'
|
||||
@@ -761,7 +766,7 @@ class ItemVariation(models.Model):
|
||||
)
|
||||
value = I18nCharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Description')
|
||||
verbose_name=_('Variation')
|
||||
)
|
||||
active = models.BooleanField(
|
||||
default=True,
|
||||
@@ -797,6 +802,29 @@ class ItemVariation(models.Model):
|
||||
verbose_name=_('Membership types'),
|
||||
blank=True,
|
||||
)
|
||||
available_from = models.DateTimeField(
|
||||
verbose_name=_("Available from"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold before the given date.')
|
||||
)
|
||||
available_until = models.DateTimeField(
|
||||
verbose_name=_("Available until"),
|
||||
null=True, blank=True,
|
||||
help_text=_('This variation will not be sold after the given date.')
|
||||
)
|
||||
sales_channels = fields.MultiStringField(
|
||||
verbose_name=_('Sales channels'),
|
||||
default=_all_sales_channels_identifiers,
|
||||
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||
'selected here but not on product level, the variation will not be available.'),
|
||||
blank=True,
|
||||
)
|
||||
hide_without_voucher = models.BooleanField(
|
||||
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
|
||||
default=False,
|
||||
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
|
||||
'that unlocks this variation.')
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='item__event__organizer')
|
||||
|
||||
@@ -928,6 +956,24 @@ class ItemVariation(models.Model):
|
||||
def is_only_variation(self):
|
||||
return ItemVariation.objects.filter(item=self.item).count() == 1
|
||||
|
||||
def is_available_by_time(self, now_dt: datetime=None) -> bool:
|
||||
now_dt = now_dt or now()
|
||||
if self.available_from and self.available_from > now_dt:
|
||||
return False
|
||||
if self.available_until and self.available_until < now_dt:
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_available(self, now_dt: datetime=None) -> bool:
|
||||
"""
|
||||
Returns whether this item is available according to its ``active`` flag
|
||||
and its ``available_from`` and ``available_until`` fields
|
||||
"""
|
||||
now_dt = now_dt or now()
|
||||
if not self.active or not self.is_available_by_time(now_dt):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class ItemAddOn(models.Model):
|
||||
"""
|
||||
|
||||
@@ -283,13 +283,16 @@ class CartManager:
|
||||
if op.item.require_voucher and op.voucher is None:
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if op.item.hide_without_voucher and (op.voucher is None or not op.voucher.show_hidden_items):
|
||||
if (
|
||||
(op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher)) and
|
||||
(op.voucher is None or not op.voucher.show_hidden_items)
|
||||
):
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if not op.item.is_available() or (op.variation and not op.variation.active):
|
||||
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if self._sales_channel not in op.item.sales_channels:
|
||||
if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
|
||||
|
||||
@@ -572,7 +572,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
if cp.pk in deleted_positions:
|
||||
continue
|
||||
|
||||
if not cp.item.is_available() or (cp.variation and not cp.variation.active):
|
||||
if not cp.item.is_available() or (cp.variation and not cp.variation.is_available()):
|
||||
err = err or error_messages['unavailable']
|
||||
delete(cp)
|
||||
continue
|
||||
@@ -644,7 +644,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
err = err or error_messages['voucher_required']
|
||||
break
|
||||
|
||||
if cp.item.hide_without_voucher and (
|
||||
if (cp.item.hide_without_voucher or (cp.variation and cp.variation.hide_without_voucher)) and (
|
||||
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
|
||||
) and not cp.is_bundled:
|
||||
delete(cp)
|
||||
|
||||
@@ -27,6 +27,7 @@ from django.urls import reverse
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from pretix.base.models import ItemVariation
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.signals import timeline_events
|
||||
|
||||
@@ -240,6 +241,39 @@ def timeline_for_event(event, subevent=None):
|
||||
})
|
||||
))
|
||||
|
||||
for v in ItemVariation.objects.filter(
|
||||
Q(available_from__isnull=False) | Q(available_until__isnull=False),
|
||||
item__event=event
|
||||
).select_related('item'):
|
||||
if v.available_from:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=v.available_from,
|
||||
description=pgettext_lazy('timeline', 'Product variation "{product} – {variation}" becomes available').format(
|
||||
product=str(v.item),
|
||||
variation=str(v.value),
|
||||
),
|
||||
edit_url=reverse('control:event.item', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'item': v.item.pk,
|
||||
})
|
||||
))
|
||||
if v.available_until:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=v.available_until,
|
||||
description=pgettext_lazy('timeline', 'Product variation "{product} – {variation}" becomes unavailable').format(
|
||||
product=str(v.item),
|
||||
variation=str(v.value),
|
||||
),
|
||||
edit_url=reverse('control:event.item', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'item': v.item.pk,
|
||||
})
|
||||
))
|
||||
|
||||
pprovs = event.get_payment_providers()
|
||||
# This is a special case, depending on payment providers not overriding BasePaymentProvider by too much, but it's
|
||||
# preferrable to having all plugins implement this spearately.
|
||||
|
||||
@@ -683,7 +683,20 @@ class ItemVariationForm(I18nModelForm):
|
||||
qs = kwargs.pop('membership_types')
|
||||
super().__init__(*args, **kwargs)
|
||||
change_decimal_field(self.fields['default_price'], self.event.currency)
|
||||
self.fields['sales_channels'] = forms.MultipleChoiceField(
|
||||
label=_('Sales channels'),
|
||||
required=False,
|
||||
choices=(
|
||||
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
|
||||
),
|
||||
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||
'selected here but not on product level, the variation will not be available.'),
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
if not self.instance.pk:
|
||||
self.initial.setdefault('sales_channels', list(get_all_sales_channels().keys()))
|
||||
|
||||
self.fields['description'].widget.attrs['rows'] = 3
|
||||
if qs:
|
||||
self.fields['require_membership_types'].queryset = qs
|
||||
else:
|
||||
@@ -700,9 +713,19 @@ class ItemVariationForm(I18nModelForm):
|
||||
'original_price',
|
||||
'description',
|
||||
'require_membership',
|
||||
'require_membership_types'
|
||||
'require_membership_types',
|
||||
'available_from',
|
||||
'available_until',
|
||||
'sales_channels',
|
||||
'hide_without_voucher',
|
||||
]
|
||||
field_classes = {
|
||||
'available_from': SplitDateTimeField,
|
||||
'available_until': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'available_from': SplitDateTimePickerWidget(),
|
||||
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
|
||||
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
|
||||
'class': 'scrolling-multiple-choice'
|
||||
}),
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/subevent.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/variations.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/dragndroplist.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/mail.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/orderchange.js" %}"></script>
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load formset_tags %}
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" id="item_variations">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for form in formset %}
|
||||
<div class="panel panel-default" data-formset-form>
|
||||
<details class="panel panel-default" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ form.id }}
|
||||
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% bootstrap_field form.value layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-3 text-right flip">
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
{% if form.instance.id %}
|
||||
<br><small class="text-muted">#{{ form.instance.id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<summary class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-xs-12">
|
||||
<strong class="panel-title">
|
||||
<span class="fa fa-fw chevron"></span>
|
||||
<span class="fa fa-warning text-danger hidden variation-error"></span>
|
||||
<span class="variation-name">
|
||||
Variation name
|
||||
</span>
|
||||
</strong>
|
||||
<span class="fa fa-warning text-warning hidden variation-warning"></span>
|
||||
{% if form.instance.id %}
|
||||
<br>
|
||||
<small class="text-muted">#{{ form.instance.id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden" data-toggle="tooltip" title="{% trans "Only available in a limited timeframe" %}"></span>
|
||||
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden" data-toggle="tooltip"
|
||||
title="{% trans "Only visible with a voucher" %}"></span>
|
||||
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden" data-toggle="tooltip"
|
||||
title="{% trans "Require a valid membership" %}"></span>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% for k, c in sales_channels.items %}
|
||||
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
|
||||
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-1 col-xs-6 text-right flip variation-price">
|
||||
<!-- price will be inserted by JS here -->
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 text-right flip">
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body form-horizontal">
|
||||
{% if form.instance.pk and not form.instance.quotas.exists %}
|
||||
<div class="alert alert-warning">
|
||||
@@ -43,9 +65,14 @@
|
||||
{% endif %}
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.active layout="control" %}
|
||||
{% bootstrap_field form.value layout="control" %}
|
||||
{% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field form.description layout="control" %}
|
||||
{% bootstrap_field form.available_from layout="control" %}
|
||||
{% bootstrap_field form.available_until layout="control" %}
|
||||
{% bootstrap_field form.sales_channels layout="control" %}
|
||||
{% bootstrap_field form.hide_without_voucher layout="control" %}
|
||||
{% if form.require_membership %}
|
||||
{% bootstrap_field form.require_membership layout="control" %}
|
||||
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
|
||||
@@ -53,39 +80,69 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<div class="panel panel-default" data-formset-form>
|
||||
<details class="panel panel-default" data-formset-form open>
|
||||
<div class="sr-only">
|
||||
{{ formset.empty_form.id }}
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% bootstrap_field formset.empty_form.value layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-3 text-right flip">
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
<summary class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-xs-12">
|
||||
<strong class="panel-title">
|
||||
<span class="fa fa-fw chevron"></span>
|
||||
<span class="fa fa-warning text-danger hidden variation-error"></span>
|
||||
<span class="variation-name">
|
||||
{% trans "New variation" %}
|
||||
</span>
|
||||
</strong>
|
||||
<span class="fa fa-warning text-warning hidden variation-warning"></span>
|
||||
{% if form.instance.id %}
|
||||
<br>
|
||||
<small class="text-muted">#{{ form.instance.id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden" data-toggle="tooltip" title="{% trans "Only available in a limited timeframe" %}"></span>
|
||||
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden" data-toggle="tooltip"
|
||||
title="{% trans "Only visible with a voucher" %}"></span>
|
||||
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden" data-toggle="tooltip"
|
||||
title="{% trans "Require a valid membership" %}"></span>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% for k, c in sales_channels.items %}
|
||||
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
|
||||
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-1 col-xs-6 text-right flip variation-price">
|
||||
<!-- price will be inserted by JS here -->
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 text-right flip">
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body form-horizontal">
|
||||
{% bootstrap_field formset.empty_form.active layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.value layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.description layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.available_from layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.available_until layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.sales_channels layout="control" %}
|
||||
{% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %}
|
||||
{% if formset.empty_form.require_membership %}
|
||||
{% bootstrap_field formset.empty_form.require_membership layout="control" %}
|
||||
<div data-display-dependency="#{{ formset.empty_form.require_membership.id_for_label }}">
|
||||
@@ -93,7 +150,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
|
||||
@@ -1331,6 +1331,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
|
||||
"Your participants won't be able to buy the bundle unless you remove this "
|
||||
"item from it."))
|
||||
|
||||
ctx['sales_channels'] = get_all_sales_channels()
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -113,6 +113,13 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require
|
||||
if not event.settings.seating_choice:
|
||||
requires_seat = Value(0, output_field=IntegerField())
|
||||
|
||||
variation_q = (
|
||||
Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) &
|
||||
Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
)
|
||||
if not voucher or not voucher.show_hidden_items:
|
||||
variation_q &= Q(hide_without_voucher=False)
|
||||
|
||||
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher, allow_addons=allow_addons).select_related(
|
||||
'category', 'tax_rule', # for re-grouping
|
||||
'hidden_if_available',
|
||||
@@ -147,7 +154,11 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require
|
||||
)
|
||||
),
|
||||
).filter(
|
||||
active=True, quotas__isnull=False, subevent_disabled=False
|
||||
variation_q,
|
||||
active=True,
|
||||
sales_channels__contains=channel,
|
||||
quotas__isnull=False,
|
||||
subevent_disabled=False
|
||||
).prefetch_related(
|
||||
Prefetch('quotas',
|
||||
to_attr='_subevent_quotas',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
setup_collapsible_details = function (el) {
|
||||
var isOpera = Object.prototype.toString.call(window.opera) == '[object Opera]';
|
||||
el.find("details summary, details summary a[data-toggle=variations]").click(function (e) {
|
||||
if (this.tagName !== "A" && $(e.target).closest("a").length > 0) {
|
||||
if (this.tagName !== "A" && $(e.target).closest("a, button").length > 0) {
|
||||
return true;
|
||||
}
|
||||
var $details = $(this).closest("details");
|
||||
|
||||
58
src/pretix/static/pretixcontrol/js/ui/variations.js
Normal file
58
src/pretix/static/pretixcontrol/js/ui/variations.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/*global $, Morris, gettext*/
|
||||
$(function () {
|
||||
// Question view
|
||||
if (!$("#item_variations").length) {
|
||||
return;
|
||||
}
|
||||
|
||||
function update_variation_summary($el) {
|
||||
var var_name = $el.find("input[name*=-value_]").filter(function () {return !!this.value}).first().val();
|
||||
var price = $el.find("input[name*=-default_price]").val();
|
||||
|
||||
|
||||
$el.find(".variation-name").text(var_name);
|
||||
$el.find(".variation-price").text(price);
|
||||
$el.find(".variation-timeframe").toggleClass("variation-icon-hidden", !(
|
||||
!!$el.find("input[name$=-available_from_0]").val() ||
|
||||
!!$el.find("input[name$=-available_until_0]").val()
|
||||
));
|
||||
$el.find(".variation-name").toggleClass("variation-disabled", !(
|
||||
!!$el.find("input[name$=-active]").prop("checked")
|
||||
));
|
||||
$el.find(".variation-voucher").toggleClass("variation-icon-hidden", !(
|
||||
!!$el.find("input[name$=-hide_without_voucher]").prop("checked")
|
||||
));
|
||||
$el.find(".variation-membership").toggleClass("variation-icon-hidden", !(
|
||||
!!$el.find("input[name$=-require_membership]").prop("checked")
|
||||
));
|
||||
$el.find(".variation-warning").toggleClass("hidden", !(
|
||||
$el.find(".alert-warning").length
|
||||
));
|
||||
$el.find(".variation-error").toggleClass("hidden", !(
|
||||
$el.find(".alert-danger, .has-error").length
|
||||
));
|
||||
$el.find("input[name$=-sales_channels]").each(function () {
|
||||
$el.find(".variation-channel-" + $(this).val()).toggleClass("variation-icon-hidden", !(
|
||||
$(this).prop("checked") && $("input[name=sales_channels][value=" + $(this).val() + "]").prop("checked")
|
||||
));
|
||||
})
|
||||
}
|
||||
|
||||
$("#item_variations [data-formset-form]").each(function () {
|
||||
var $el = $(this);
|
||||
update_variation_summary($el);
|
||||
$(this).on("change dp.change", "input", function () {update_variation_summary($el)});
|
||||
});
|
||||
$("input[name=sales_channels]").on("change", function() {
|
||||
$("#item_variations [data-formset-form]").each(function () {
|
||||
update_variation_summary($(this));
|
||||
});
|
||||
});
|
||||
$("#item_variations").on("formAdded", "details", function (event) {
|
||||
console.log("added", event.target)
|
||||
var $el = $(event.target);
|
||||
update_variation_summary($el);
|
||||
$(this).on("change dp.change", "input", function () {update_variation_summary($el)});
|
||||
setup_collapsible_details($("#item_variations"));
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ td > .form-group > .checkbox {
|
||||
@include box-shadow(none);
|
||||
}
|
||||
|
||||
div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], div[data-nested-formset-body] {
|
||||
div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], div[data-nested-formset-body], details[data-formset-form] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -813,3 +813,26 @@ table td > .checkbox input[type="checkbox"] {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
details {
|
||||
summary .chevron::before {
|
||||
content: $fa-var-caret-right;
|
||||
}
|
||||
&[open] .chevron::before {
|
||||
content: $fa-var-caret-down;
|
||||
}
|
||||
}
|
||||
|
||||
#item_variations {
|
||||
summary small {
|
||||
display: inline-block;
|
||||
margin-left: 2.1em;
|
||||
}
|
||||
.variation-icon-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.variation-disabled {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ from django_countries.fields import Country
|
||||
from django_scopes import scopes_disabled
|
||||
from pytz import UTC
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.models import (
|
||||
CartPosition, InvoiceAddress, Item, ItemAddOn, ItemBundle, ItemCategory,
|
||||
ItemVariation, Order, OrderPosition, Question, QuestionOption, Quota,
|
||||
@@ -376,6 +377,10 @@ def test_item_detail_variations(token_client, organizer, event, team, item):
|
||||
"position": 0,
|
||||
"require_membership": False,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": list(get_all_sales_channels().keys()),
|
||||
"available_from": None,
|
||||
"available_until": None,
|
||||
"hide_without_voucher": False,
|
||||
"original_price": None
|
||||
}]
|
||||
res["has_variations"] = True
|
||||
@@ -517,6 +522,7 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego
|
||||
new_item = Item.objects.get(pk=resp.data['id'])
|
||||
assert new_item.variations.first().value.localize('de') == "Kommentar"
|
||||
assert new_item.variations.first().value.localize('en') == "Comment"
|
||||
assert set(new_item.variations.first().sales_channels) == set(get_all_sales_channels().keys())
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1205,6 +1211,10 @@ TEST_VARIATIONS_RES = {
|
||||
"price": "23.00",
|
||||
"require_membership": False,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": list(get_all_sales_channels().keys()),
|
||||
"available_from": None,
|
||||
"available_until": None,
|
||||
"hide_without_voucher": False,
|
||||
"original_price": None
|
||||
}
|
||||
|
||||
@@ -1218,6 +1228,10 @@ TEST_VARIATIONS_UPDATE = {
|
||||
"default_price": "20.0",
|
||||
"require_membership": False,
|
||||
"require_membership_types": [],
|
||||
"sales_channels": ["web"],
|
||||
"available_from": None,
|
||||
"available_until": None,
|
||||
"hide_without_voucher": False,
|
||||
"original_price": None
|
||||
}
|
||||
|
||||
@@ -1264,6 +1278,7 @@ def test_variations_create(token_client, organizer, event, item, variation):
|
||||
var = ItemVariation.objects.get(pk=resp.data['id'])
|
||||
assert var.position == 1
|
||||
assert var.price == 23.0
|
||||
assert set(var.sales_channels) == set(get_all_sales_channels().keys())
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1302,6 +1317,7 @@ def test_variations_update(token_client, organizer, event, item, item3, variatio
|
||||
"en": "ChildC2"
|
||||
},
|
||||
"position": 1,
|
||||
"sales_channels": ["web"],
|
||||
"default_price": "20.00",
|
||||
"original_price": "50.00"
|
||||
},
|
||||
|
||||
@@ -880,6 +880,24 @@ class CartTest(CartTestMixin, TestCase):
|
||||
with scopes_disabled():
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0)
|
||||
|
||||
def test_variation_wrong_sales_channel(self):
|
||||
self.shirt_blue.sales_channels = ['bar']
|
||||
self.shirt_blue.save()
|
||||
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1',
|
||||
}, follow=True)
|
||||
with scopes_disabled():
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0)
|
||||
self.shirt_blue.sales_channels = ['bar', 'web']
|
||||
self.shirt_blue.save()
|
||||
self.shirt.sales_channels = ['bar']
|
||||
self.shirt.save()
|
||||
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1',
|
||||
}, follow=True)
|
||||
with scopes_disabled():
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0)
|
||||
|
||||
def test_other_sales_channel(self):
|
||||
self.ticket.sales_channels = ['bar']
|
||||
self.ticket.save()
|
||||
@@ -965,6 +983,34 @@ class CartTest(CartTestMixin, TestCase):
|
||||
with scopes_disabled():
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0)
|
||||
|
||||
def test_variation_in_time_available(self):
|
||||
self.shirt_blue.available_until = now() + timedelta(days=2)
|
||||
self.shirt_blue.available_from = now() - timedelta(days=2)
|
||||
self.shirt_blue.save()
|
||||
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1',
|
||||
}, follow=True)
|
||||
with scopes_disabled():
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1)
|
||||
|
||||
def test_variation_no_longer_available(self):
|
||||
self.shirt_blue.available_until = now() - timedelta(days=2)
|
||||
self.shirt_blue.save()
|
||||
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1',
|
||||
}, follow=True)
|
||||
with scopes_disabled():
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0)
|
||||
|
||||
def test_variation_not_yet_available(self):
|
||||
self.shirt_blue.available_from = now() + timedelta(days=2)
|
||||
self.shirt_blue.save()
|
||||
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1',
|
||||
}, follow=True)
|
||||
with scopes_disabled():
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0)
|
||||
|
||||
def test_max_per_item_failed(self):
|
||||
self.ticket.max_per_order = 2
|
||||
self.ticket.save()
|
||||
@@ -1751,6 +1797,44 @@ class CartTest(CartTestMixin, TestCase):
|
||||
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
|
||||
self.assertEqual(len(objs), 0)
|
||||
|
||||
def test_variation_hide_without_voucher(self):
|
||||
with scopes_disabled():
|
||||
v = Voucher.objects.create(item=self.shirt, event=self.event)
|
||||
self.shirt_red.hide_without_voucher = True
|
||||
self.shirt_red.save()
|
||||
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1',
|
||||
'_voucher_code': v.code
|
||||
}, follow=True)
|
||||
with scopes_disabled():
|
||||
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
|
||||
self.assertEqual(len(objs), 1)
|
||||
self.assertEqual(objs[0].item, self.shirt)
|
||||
self.assertEqual(objs[0].variation, self.shirt_red)
|
||||
|
||||
def test_variation_hide_without_voucher_failed_because_of_voucher(self):
|
||||
with scopes_disabled():
|
||||
v = Voucher.objects.create(item=self.shirt, event=self.event, show_hidden_items=False)
|
||||
self.shirt_red.hide_without_voucher = True
|
||||
self.shirt_red.save()
|
||||
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1',
|
||||
'_voucher_code': v.code
|
||||
}, follow=True)
|
||||
with scopes_disabled():
|
||||
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
|
||||
self.assertEqual(len(objs), 0)
|
||||
|
||||
def test_variation_hide_without_voucher_failed(self):
|
||||
self.shirt_red.hide_without_voucher = True
|
||||
self.shirt_red.save()
|
||||
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1',
|
||||
}, follow=True)
|
||||
with scopes_disabled():
|
||||
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
|
||||
self.assertEqual(len(objs), 0)
|
||||
|
||||
def test_voucher_multiuse_ok(self):
|
||||
with scopes_disabled():
|
||||
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
|
||||
|
||||
@@ -455,6 +455,54 @@ class ItemDisplayTest(EventTestMixin, SoupTest):
|
||||
q.variations.add(var1)
|
||||
self._assert_variation_found()
|
||||
|
||||
def test_variation_available_from(self):
|
||||
with scopes_disabled():
|
||||
c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)
|
||||
q = Quota.objects.create(event=self.event, name='Quota', size=None)
|
||||
item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0)
|
||||
var1 = ItemVariation.objects.create(item=item, value='Red', available_from=now() - datetime.timedelta(days=1))
|
||||
var2 = ItemVariation.objects.create(item=item, value='Blue', available_from=now() + datetime.timedelta(days=1))
|
||||
q.items.add(item)
|
||||
q.variations.add(var1)
|
||||
q.variations.add(var2)
|
||||
self._assert_variation_found()
|
||||
|
||||
def test_variation_available_until(self):
|
||||
with scopes_disabled():
|
||||
c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)
|
||||
q = Quota.objects.create(event=self.event, name='Quota', size=None)
|
||||
item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0)
|
||||
var1 = ItemVariation.objects.create(item=item, value='Red', available_until=now() + datetime.timedelta(days=1))
|
||||
var2 = ItemVariation.objects.create(item=item, value='Blue', available_until=now() - datetime.timedelta(days=1))
|
||||
q.items.add(item)
|
||||
q.variations.add(var1)
|
||||
q.variations.add(var2)
|
||||
self._assert_variation_found()
|
||||
|
||||
def test_variation_hide_without_voucher(self):
|
||||
with scopes_disabled():
|
||||
c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)
|
||||
q = Quota.objects.create(event=self.event, name='Quota', size=None)
|
||||
item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0)
|
||||
var1 = ItemVariation.objects.create(item=item, value='Red')
|
||||
var2 = ItemVariation.objects.create(item=item, value='Blue', hide_without_voucher=True)
|
||||
q.items.add(item)
|
||||
q.variations.add(var1)
|
||||
q.variations.add(var2)
|
||||
self._assert_variation_found()
|
||||
|
||||
def test_variation_sales_channel(self):
|
||||
with scopes_disabled():
|
||||
c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)
|
||||
q = Quota.objects.create(event=self.event, name='Quota', size=None)
|
||||
item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0)
|
||||
var1 = ItemVariation.objects.create(item=item, value='Red')
|
||||
var2 = ItemVariation.objects.create(item=item, value='Blue', sales_channels=['foobar'])
|
||||
q.items.add(item)
|
||||
q.variations.add(var1)
|
||||
q.variations.add(var2)
|
||||
self._assert_variation_found()
|
||||
|
||||
def _assert_variation_found(self):
|
||||
doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
|
||||
self.assertIn("Early-bird", doc.select("section:nth-of-type(1) div:nth-of-type(1)")[0].text)
|
||||
|
||||
Reference in New Issue
Block a user