Allow to restrict availability of variations by date, sales channel, and voucher (#2202)

This commit is contained in:
Raphael Michel
2021-09-15 12:04:17 +02:00
committed by GitHub
parent 4789d82c4e
commit 17adde99fa
20 changed files with 583 additions and 52 deletions

View 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),
),
]

View File

@@ -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()))

View File

@@ -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):
"""

View File

@@ -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():

View File

@@ -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)

View File

@@ -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.