From 4acf6609068342977e930466abd889668cbce4a3 Mon Sep 17 00:00:00 2001
From: Raphael Michel
Date: Thu, 29 Apr 2021 15:34:58 +0200
Subject: [PATCH] Fix #1567 -- Per-subevent availability of items (#2040)
---
doc/api/resources/subevents.rst | 26 +++++++-
src/pretix/api/serializers/event.py | 4 +-
.../migrations/0183_auto_20210423_0829.py | 33 ++++++++++
src/pretix/base/models/event.py | 52 ++++++++++++++-
src/pretix/base/models/items.py | 58 +++++++++++++++-
src/pretix/base/services/cart.py | 5 +-
src/pretix/base/services/orders.py | 5 +-
src/pretix/base/timeline.py | 50 ++++++++++++++
src/pretix/control/forms/subevents.py | 66 +++++++++++++++++--
.../pretixcontrol/subevents/bulk.html | 25 +++++--
.../pretixcontrol/subevents/bulk_edit.html | 18 ++++-
.../pretixcontrol/subevents/detail.html | 25 +++++--
src/pretix/control/views/subevents.py | 66 ++++++++++++++-----
src/pretix/presale/views/event.py | 6 +-
.../static/pretixcontrol/scss/_forms.scss | 14 ++++
src/tests/presale/test_cart.py | 32 +++++++++
src/tests/presale/test_checkout.py | 37 +++++++++++
src/tests/presale/test_event.py | 39 +++++++++++
18 files changed, 513 insertions(+), 48 deletions(-)
create mode 100644 src/pretix/base/migrations/0183_auto_20210423_0829.py
diff --git a/doc/api/resources/subevents.rst b/doc/api/resources/subevents.rst
index 65d559e277..d30c5621a5 100644
--- a/doc/api/resources/subevents.rst
+++ b/doc/api/resources/subevents.rst
@@ -38,14 +38,18 @@ location multi-lingual string The sub-event l
geo_lat float Latitude of the location (or ``null``)
geo_lon float Longitude of the location (or ``null``)
item_price_overrides list of objects List of items for which this sub-event overrides the
- default price
+ default price or settings
├ item integer The internal item ID
├ disabled boolean If ``true``, item should not be available for this sub-event
+├ available_from datetime Start of availability (or ``null``)
+├ available_until datetime End of availability (or ``null``)
└ price money (string) The price or ``null`` for the default price
variation_price_overrides list of objects List of variations for which this sub-event overrides
- the default price
+ the default price or settings
├ variation integer The internal variation ID
├ disabled boolean If ``true``, variation should not be available for this sub-event
+├ available_from datetime Start of availability (or ``null``)
+├ available_until datetime End of availability (or ``null``)
└ price money (string) The price or ``null`` for the default price
meta_data object Values set for organizer-specific meta data parameters.
seating_plan integer If reserved seating is in use, the ID of a seating
@@ -67,6 +71,10 @@ last_modified datetime Last modificati
The ``last_modified`` attribute has been added.
+.. versionchanged:: 3.18
+
+ The ``available_from``/``available_until`` attributes have been added to ``item_price_overrides`` and ``variation_price_overrides``.
+
Endpoints
---------
@@ -119,6 +127,8 @@ Endpoints
{
"item": 2,
"disabled": false,
+ "available_from": null,
+ "available_until": null,
"price": "12.00"
}
],
@@ -179,6 +189,8 @@ Endpoints
{
"item": 2,
"disabled": false,
+ "available_from": null,
+ "available_until": null,
"price": "12.00"
}
],
@@ -214,6 +226,8 @@ Endpoints
{
"item": 2,
"disabled": false,
+ "available_from": null,
+ "available_until": null,
"price": "12.00"
}
],
@@ -270,6 +284,8 @@ Endpoints
{
"item": 2,
"disabled": false,
+ "available_from": null,
+ "available_until": null,
"price": "12.00"
}
],
@@ -307,6 +323,8 @@ Endpoints
{
"item": 2,
"disabled": false,
+ "available_from": null,
+ "available_until": null,
"price": "23.42"
}
],
@@ -340,6 +358,8 @@ Endpoints
{
"item": 2,
"disabled": false,
+ "available_from": null,
+ "available_until": null,
"price": "23.42"
}
],
@@ -429,6 +449,8 @@ Endpoints
{
"item": 2,
"disabled": false,
+ "available_from": null,
+ "available_until": null,
"price": "12.00"
}
],
diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py
index 1525a58ca8..6e01de4bd9 100644
--- a/src/pretix/api/serializers/event.py
+++ b/src/pretix/api/serializers/event.py
@@ -423,13 +423,13 @@ class CloneEventSerializer(EventSerializer):
class SubEventItemSerializer(I18nAwareModelSerializer):
class Meta:
model = SubEventItem
- fields = ('item', 'price', 'disabled')
+ fields = ('item', 'price', 'disabled', 'available_from', 'available_until')
class SubEventItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = SubEventItemVariation
- fields = ('variation', 'price', 'disabled')
+ fields = ('variation', 'price', 'disabled', 'available_from', 'available_until')
class SubEventSerializer(I18nAwareModelSerializer):
diff --git a/src/pretix/base/migrations/0183_auto_20210423_0829.py b/src/pretix/base/migrations/0183_auto_20210423_0829.py
new file mode 100644
index 0000000000..14fde9f7d0
--- /dev/null
+++ b/src/pretix/base/migrations/0183_auto_20210423_0829.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.0.13 on 2021-04-23 08:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0182_question_valid_file_portrait'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='subeventitem',
+ name='available_from',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='subeventitem',
+ name='available_until',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='subeventitemvariation',
+ name='available_from',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='subeventitemvariation',
+ name='available_until',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py
index a076d9086c..3b164fae13 100644
--- a/src/pretix/base/models/event.py
+++ b/src/pretix/base/models/event.py
@@ -50,6 +50,7 @@ from django.core.validators import (
)
from django.db import models
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery, Value
+from django.db.models.functions import Coalesce
from django.template.defaultfilters import date as _date
from django.urls import reverse
from django.utils.crypto import get_random_string
@@ -280,6 +281,14 @@ class EventMixin:
vars_reserved = set()
items_gone = set()
vars_gone = set()
+ items_disabled = set()
+ vars_disabled = set()
+
+ if hasattr(self, 'disabled_items'): # SubEventItem
+ items_disabled = set(self.disabled_items.split(","))
+
+ if hasattr(self, 'disabled_vars'): # SubEventItemVariation
+ vars_disabled = set(self.disabled_vars.split(","))
r = getattr(self, '_quota_cache', {})
for q in self.active_quotas:
@@ -300,8 +309,19 @@ class EventMixin:
items_gone.update(q.active_items.split(","))
if q.active_variations:
vars_gone.update(q.active_variations.split(","))
- if not self.active_quotas:
+
+ items_available -= items_disabled
+ items_reserved -= items_disabled
+ items_gone -= items_disabled
+ vars_available -= vars_disabled
+ vars_reserved -= vars_disabled
+ vars_gone -= vars_gone
+
+ if not self.active_quotas or (
+ not items_available and not items_reserved and not items_gone and not vars_gone and not vars_available and not vars_reserved
+ ):
return None
+
if items_available - items_reserved - items_gone or vars_available - vars_reserved - vars_gone:
return Quota.AVAILABILITY_OK
if items_reserved - items_gone or vars_reserved - vars_gone:
@@ -1227,6 +1247,36 @@ class SubEvent(EventMixin, LoggedModel):
distance_only_within_row=self.settings.seating_distance_within_row)
return qs_annotated
+ @classmethod
+ def annotated(cls, qs, channel='web'):
+ from .items import SubEventItem, SubEventItemVariation
+
+ qs = super().annotated(qs, channel)
+ qs = qs.annotate(
+ disabled_items=Coalesce(
+ Subquery(
+ SubEventItem.objects.filter(
+ Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()),
+ subevent=OuterRef('pk'),
+ ).order_by().values('subevent').annotate(items=GroupConcat('item_id', delimiter=',')).values('items'),
+ output_field=models.TextField(),
+ ),
+ Value('')
+ ),
+ disabled_vars=Coalesce(
+ Subquery(
+ SubEventItemVariation.objects.filter(
+ Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()),
+ subevent=OuterRef('pk'),
+ ).order_by().values('subevent').annotate(items=GroupConcat('variation_id', delimiter=',')).values('items'),
+ output_field=models.TextField(),
+ ),
+ Value('')
+ )
+ )
+
+ return qs
+
@cached_property
def settings(self):
return self.event.settings
diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py
index 839a18b2fd..3bfac87041 100644
--- a/src/pretix/base/models/items.py
+++ b/src/pretix/base/models/items.py
@@ -151,11 +151,29 @@ class SubEventItem(models.Model):
:type item: Item
:param price: The modified price (or ``None`` for the original price)
:type price: Decimal
+ :param disabled: Disable the product for this subevent
+ :type disabled: bool
+ :param available_until: The date until when the product is on sale
+ :type available_until: datetime
+ :param available_from: The date this product goes on sale
+ :type available_from: datetime
+ :param available_until: The date until when the product is on sale
+ :type available_until: datetime
"""
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
item = models.ForeignKey('Item', on_delete=models.CASCADE)
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
disabled = models.BooleanField(default=False, verbose_name=_('Disable product for this date'))
+ available_from = models.DateTimeField(
+ verbose_name=_("Available from"),
+ null=True, blank=True,
+ help_text=_('This product will not be sold before the given date.')
+ )
+ available_until = models.DateTimeField(
+ verbose_name=_("Available until"),
+ null=True, blank=True,
+ help_text=_('This product will not be sold after the given date.')
+ )
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -167,6 +185,16 @@ class SubEventItem(models.Model):
if self.subevent:
self.subevent.event.cache.clear()
+ def is_available(self, now_dt: datetime=None) -> bool:
+ now_dt = now_dt or now()
+ if self.disabled:
+ return False
+ 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
+
class SubEventItemVariation(models.Model):
"""
@@ -179,11 +207,29 @@ class SubEventItemVariation(models.Model):
:type variation: ItemVariation
:param price: The modified price (or ``None`` for the original price)
:type price: Decimal
+ :param disabled: Disable the product for this subevent
+ :type disabled: bool
+ :param available_until: The date until when the product is on sale
+ :type available_until: datetime
+ :param available_from: The date this product goes on sale
+ :type available_from: datetime
+ :param available_until: The date until when the product is on sale
+ :type available_until: datetime
"""
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE)
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
- disabled = models.BooleanField(default=False)
+ disabled = models.BooleanField(default=False, verbose_name=_('Disable product for this date'))
+ available_from = models.DateTimeField(
+ verbose_name=_("Available from"),
+ null=True, blank=True,
+ help_text=_('This product will not be sold before the given date.')
+ )
+ available_until = models.DateTimeField(
+ verbose_name=_("Available until"),
+ null=True, blank=True,
+ help_text=_('This product will not be sold after the given date.')
+ )
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -195,6 +241,16 @@ class SubEventItemVariation(models.Model):
if self.subevent:
self.subevent.event.cache.clear()
+ def is_available(self, now_dt: datetime=None) -> bool:
+ now_dt = now_dt or now()
+ if self.disabled:
+ return False
+ 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 filter_available(qs, channel='web', voucher=None, allow_addons=False):
q = (
diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index 71bfe1b516..e405e22f47 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -292,10 +292,11 @@ class CartManager:
if self._sales_channel not in op.item.sales_channels:
raise CartError(error_messages['unavailable'])
- if op.subevent and op.item.pk in op.subevent.item_overrides and op.subevent.item_overrides[op.item.pk].disabled:
+ if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
raise CartError(error_messages['not_for_sale'])
- if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and op.subevent.var_overrides[op.variation.pk].disabled:
+ if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and \
+ not op.subevent.var_overrides[op.variation.pk].is_available():
raise CartError(error_messages['not_for_sale'])
if op.item.has_variations and not op.variation:
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index ada2291592..ed28cbbbea 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -696,12 +696,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
- if cp.subevent and cp.item.pk in cp.subevent.item_overrides and cp.subevent.item_overrides[cp.item.pk].disabled:
+ if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(now_dt):
err = err or error_messages['unavailable']
delete(cp)
continue
- if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and cp.subevent.var_overrides[cp.variation.pk].disabled:
+ if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and \
+ not cp.subevent.var_overrides[cp.variation.pk].is_available(now_dt):
err = err or error_messages['unavailable']
delete(cp)
continue
diff --git a/src/pretix/base/timeline.py b/src/pretix/base/timeline.py
index d712901c76..f4993fb68a 100644
--- a/src/pretix/base/timeline.py
+++ b/src/pretix/base/timeline.py
@@ -166,6 +166,56 @@ def timeline_for_event(event, subevent=None):
})
))
+ if subevent:
+ for sei in subevent.item_overrides.values():
+ if sei.available_from:
+ tl.append(TimelineEvent(
+ event=event, subevent=subevent,
+ datetime=sei.available_from,
+ description=pgettext_lazy('timeline', 'Product "{name}" becomes available').format(name=str(sei.item)),
+ edit_url=reverse('control:event.subevent', kwargs={
+ 'event': event.slug,
+ 'organizer': event.organizer.slug,
+ 'subevent': subevent.pk,
+ })
+ ))
+ if sei.available_until:
+ tl.append(TimelineEvent(
+ event=event, subevent=subevent,
+ datetime=sei.available_until,
+ description=pgettext_lazy('timeline', 'Product "{name}" becomes unavailable').format(name=str(sei.item)),
+ edit_url=reverse('control:event.subevent', kwargs={
+ 'event': event.slug,
+ 'organizer': event.organizer.slug,
+ 'subevent': subevent.pk,
+ })
+ ))
+ for sei in subevent.var_overrides.values():
+ if sei.available_from:
+ tl.append(TimelineEvent(
+ event=event, subevent=subevent,
+ datetime=sei.available_from,
+ description=pgettext_lazy('timeline', 'Product "{name}" becomes available').format(
+ name=str(sei.variation.item) + ' – ' + str(sei.variation)),
+ edit_url=reverse('control:event.subevent', kwargs={
+ 'event': event.slug,
+ 'organizer': event.organizer.slug,
+ 'subevent': subevent.pk,
+ })
+ ))
+ if sei.available_until:
+ tl.append(TimelineEvent(
+ event=event, subevent=subevent,
+ datetime=sei.available_until,
+ description=pgettext_lazy('timeline', 'Product "{name}" becomes unavailable').format(
+ name=str(sei.variation.item) + ' – ' + str(sei.variation)),
+ edit_url=reverse('control:event.subevent', kwargs={
+ 'event': event.slug,
+ 'organizer': event.organizer.slug,
+ 'subevent': subevent.pk,
+ })
+ ))
+
for p in event.items.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)):
if p.available_from:
tl.append(TimelineEvent(
diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py
index e748bddd1f..21b2f9c5b0 100644
--- a/src/pretix/control/forms/subevents.py
+++ b/src/pretix/control/forms/subevents.py
@@ -35,8 +35,8 @@ from i18nfield.forms import I18nInlineFormSet
from pretix.base.forms import I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget, TimePickerWidget
from pretix.base.models.event import SubEvent, SubEventMetaValue
-from pretix.base.models.items import SubEventItem
-from pretix.base.reldate import RelativeDateTimeField
+from pretix.base.models.items import SubEventItem, SubEventItemVariation
+from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
from pretix.base.templatetags.money import money_filter
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.helpers.money import change_decimal_field
@@ -263,10 +263,16 @@ class SubEventItemForm(SubEventItemOrVariationFormMixin, forms.ModelForm):
class Meta:
model = SubEventItem
- fields = ['price', 'disabled']
+ fields = ['price', 'disabled', 'available_from', 'available_until']
widgets = {
+ 'available_from': SplitDateTimePickerWidget(),
+ 'available_until': SplitDateTimePickerWidget(),
'price': forms.TextInput
}
+ field_classes = {
+ 'available_from': SplitDateTimeField,
+ 'available_until': SplitDateTimeField,
+ }
class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelForm):
@@ -276,11 +282,61 @@ class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelFor
self.fields['price'].label = '{} – {}'.format(str(self.item), self.variation.value)
class Meta:
- model = SubEventItem
- fields = ['price', 'disabled']
+ model = SubEventItemVariation
+ fields = ['price', 'disabled', 'available_from', 'available_until']
widgets = {
+ 'available_from': SplitDateTimePickerWidget(),
+ 'available_until': SplitDateTimePickerWidget(),
'price': forms.TextInput
}
+ field_classes = {
+ 'available_from': SplitDateTimeField,
+ 'available_until': SplitDateTimeField,
+ }
+
+
+class BulkSubEventItemForm(SubEventItemForm):
+ rel_available_from = RelativeDateTimeField(
+ label=_('Available from'),
+ required=False,
+ limit_choices=('date_from', 'date_to'),
+ )
+ rel_available_until = RelativeDateTimeField(
+ label=_('Available_until'),
+ required=False,
+ limit_choices=('date_from', 'date_to'),
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ del self.fields['available_from']
+ del self.fields['available_until']
+ if self.instance and self.instance.available_from and 'rel_available_from' not in self.initial:
+ self.initial['rel_available_from'] = RelativeDateWrapper(self.instance.available_from)
+ if self.instance and self.instance.available_until and 'rel_available_until' not in self.initial:
+ self.initial['rel_available_until'] = RelativeDateWrapper(self.instance.available_until)
+
+
+class BulkSubEventItemVariationForm(SubEventItemVariationForm):
+ rel_available_from = RelativeDateTimeField(
+ label=_('Available from'),
+ required=False,
+ limit_choices=('date_from', 'date_to'),
+ )
+ rel_available_until = RelativeDateTimeField(
+ label=_('Available_until'),
+ required=False,
+ limit_choices=('date_from', 'date_to'),
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ del self.fields['available_from']
+ del self.fields['available_until']
+ if self.instance and self.instance.available_from and 'rel_available_from' not in self.initial:
+ self.initial['rel_available_from'] = RelativeDateWrapper(self.instance.available_from)
+ if self.instance and self.instance.available_until and 'rel_available_until' not in self.initial:
+ self.initial['rel_available_until'] = RelativeDateWrapper(self.instance.available_until)
class QuotaFormSet(I18nInlineFormSet):
diff --git a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html
index ee43c58f6d..98f2e6dbf6 100644
--- a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html
+++ b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html
@@ -480,21 +480,34 @@