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

- {% trans "Item prices" %} + {% trans "Product settings" %} +

+ {% trans "These settings are optional, if you leave them empty, the default values from the product settings will be used." %} +

{% for f in itemvar_forms %} -
+
-
+
+
{% bootstrap_field f.price addon_after=request.event.currency form_group_class="" layout="inline" %}
-
+
+
{% bootstrap_field f.disabled layout="inline" form_group_class="" %}
+
+
+
+ {% bootstrap_field f.rel_available_from form_group_class="" layout="inline" %} +
+
+
+ {% bootstrap_field f.rel_available_until form_group_class="" layout="inline" %} +
+
{% endfor %}
diff --git a/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html b/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html index b1c10d9bbd..6f264df613 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/bulk_edit.html @@ -151,17 +151,29 @@
{% trans "Item prices" %} {% for f in itemvar_forms %} -
+
-
+
+
{% bootstrap_field f.price addon_after=request.event.currency form_group_class="" layout="bulkedit_inline" %}
-
+
+
{% bootstrap_field f.disabled layout="bulkedit_inline" form_group_class="" %}
+
+
+
+ {% bootstrap_field f.available_from form_group_class="" layout="bulkedit_inline" %} +
+
+
+ {% bootstrap_field f.available_until form_group_class="" layout="bulkedit_inline" %} +
+
{% endfor %}
diff --git a/src/pretix/control/templates/pretixcontrol/subevents/detail.html b/src/pretix/control/templates/pretixcontrol/subevents/detail.html index 83fc88cb84..047f95da5a 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/detail.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/detail.html @@ -122,21 +122,34 @@

- {% trans "Item prices" %} + {% trans "Product settings" %} +

+ {% trans "These settings are optional, if you leave them empty, the default values from the product settings will be used." %} +

{% for f in itemvar_forms %} -
+
-
+
+
{% bootstrap_field f.price addon_after=request.event.currency form_group_class="" layout="inline" %}
-
+
+
{% bootstrap_field f.disabled layout="inline" form_group_class="" %}
+
+
+
+ {% bootstrap_field f.available_from form_group_class="" layout="inline" %} +
+
+
+ {% bootstrap_field f.available_until form_group_class="" layout="inline" %} +
+
{% endfor %}
diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index c7e579d62a..f521a82692 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -68,9 +68,10 @@ from pretix.control.forms.checkin import SimpleCheckinListForm from pretix.control.forms.filter import SubEventFilterForm from pretix.control.forms.item import QuotaForm from pretix.control.forms.subevents import ( - CheckinListFormSet, QuotaFormSet, RRuleFormSet, SubEventBulkEditForm, - SubEventBulkForm, SubEventForm, SubEventItemForm, - SubEventItemVariationForm, SubEventMetaValueForm, TimeFormSet, + BulkSubEventItemForm, BulkSubEventItemVariationForm, CheckinListFormSet, + QuotaFormSet, RRuleFormSet, SubEventBulkEditForm, SubEventBulkForm, + SubEventForm, SubEventItemForm, SubEventItemVariationForm, + SubEventMetaValueForm, TimeFormSet, ) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.signals import subevent_forms @@ -193,6 +194,8 @@ class SubEventDelete(EventPermissionRequiredMixin, DeleteView): class SubEventEditorMixin(MetaDataEditorMixin): meta_form = SubEventMetaValueForm meta_model = SubEventMetaValue + itemformclass = SubEventItemForm + itemvarformclass = SubEventItemVariationForm @cached_property def plugin_forms(self): @@ -391,11 +394,17 @@ class SubEventEditorMixin(MetaDataEditorMixin): if self.copy_from: se_item_instances = { - sei.item_id: SubEventItem(item=sei.item, price=sei.price, disabled=sei.disabled) + sei.item_id: SubEventItem( + item=sei.item, price=sei.price, disabled=sei.disabled, + available_from=sei.available_from, available_until=sei.available_until + ) for sei in SubEventItem.objects.filter(subevent=self.copy_from).select_related('item') } se_var_instances = { - sei.variation_id: SubEventItemVariation(variation=sei.variation, price=sei.price, disabled=sei.disabled) + sei.variation_id: SubEventItemVariation( + variation=sei.variation, price=sei.price, disabled=sei.disabled, + available_from=sei.available_from, available_until=sei.available_until + ) for sei in SubEventItemVariation.objects.filter(subevent=self.copy_from).select_related('variation') } @@ -404,7 +413,7 @@ class SubEventEditorMixin(MetaDataEditorMixin): if i.has_variations: for v in i.variations.all(): inst = se_var_instances.get(v.pk) or SubEventItemVariation(subevent=self.object, variation=v) - formlist.append(SubEventItemVariationForm( + formlist.append(self.itemvarformclass( prefix='itemvar-{}'.format(v.pk), item=i, variation=v, instance=inst, @@ -412,7 +421,7 @@ class SubEventEditorMixin(MetaDataEditorMixin): )) else: inst = se_item_instances.get(i.pk) or SubEventItem(subevent=self.object, item=i) - formlist.append(SubEventItemForm( + formlist.append(self.itemformclass( prefix='item-{}'.format(i.pk), item=i, instance=inst, @@ -641,6 +650,8 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea permission = 'can_change_settings' context_object_name = 'subevent' form_class = SubEventBulkForm + itemformclass = BulkSubEventItemForm + itemvarformclass = BulkSubEventItemVariationForm def is_valid(self, form): return self.rrule_formset.is_valid() and self.time_formset.is_valid() and super().is_valid(form) @@ -846,6 +857,18 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea i = copy.copy(f.instance) i.pk = None i.subevent = se + + i.available_from = ( + f.cleaned_data['rel_available_from'].datetime(se) + if f.cleaned_data.get('rel_available_from') + else None + ) + i.available_until = ( + f.cleaned_data['rel_available_until'].datetime(se) + if f.cleaned_data.get('rel_available_until') + else None + ) + if isinstance(i, SubEventItem): to_save_items.append(i) else: @@ -962,16 +985,19 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie def cached_num(self): return self.get_queryset().count() + itemformclass = SubEventItemForm + itemvarformclass = SubEventItemVariationForm + @cached_property def itemvar_forms(self): matches = defaultdict(list) for sei in SubEventItem.objects.filter( subevent__in=self.get_queryset() - ).order_by().values('item', 'price', 'disabled').annotate(c=Count('*')): + ).order_by().values('item', 'price', 'disabled', 'available_from', 'available_until').annotate(c=Count('*')): matches['item', sei['item']].append(sei) for sei in SubEventItemVariation.objects.filter( subevent__in=self.get_queryset() - ).order_by().values('variation', 'price', 'disabled').annotate(c=Count('*')): + ).order_by().values('variation', 'price', 'disabled', 'available_from', 'available_until').annotate(c=Count('*')): matches['variation', sei['variation']].append(sei) total = self.cached_num @@ -981,10 +1007,13 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie for v in i.variations.all(): m = matches['variation', v.pk] if m and len(m) == 1 and m[0]['c'] == total: - inst = SubEventItemVariation(variation=v, disabled=m[0]['disabled'], price=m[0]['price']) + inst = SubEventItemVariation( + variation=v, disabled=m[0]['disabled'], price=m[0]['price'], + available_from=m[0]['available_from'], available_until=m[0]['available_until'] + ) else: inst = SubEventItemVariation(variation=v) - formlist.append(SubEventItemVariationForm( + formlist.append(self.itemvarformclass( prefix='itemvar-{}'.format(v.pk), item=i, variation=v, instance=inst, @@ -993,10 +1022,13 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie else: m = matches['item', i.pk] if m and len(m) == 1 and m[0]['c'] == total: - inst = SubEventItem(item=i, disabled=m[0]['disabled'], price=m[0]['price']) + inst = SubEventItem( + item=i, disabled=m[0]['disabled'], price=m[0]['price'], + available_from=m[0]['available_from'], available_until=m[0]['available_until'] + ) else: inst = SubEventItem(item=i) - formlist.append(SubEventItemForm( + formlist.append(self.itemformclass( prefix='item-{}'.format(i.pk), item=i, instance=inst, @@ -1405,12 +1437,16 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie u['price'] = f.cleaned_data.get('price') if f.prefix + 'disabled' in self.request.POST.getlist('_bulk'): u['disabled'] = f.cleaned_data.get('disabled') + if f.prefix + 'available_from' in self.request.POST.getlist('_bulk'): + u['available_from'] = f.cleaned_data.get('available_from') + if f.prefix + 'available_until' in self.request.POST.getlist('_bulk'): + u['available_until'] = f.cleaned_data.get('available_until') if not u: continue if isinstance(f, SubEventItemForm): - if u.get('price') is None and not u.get('disabled'): + if u.get('price') is None and not u.get('disabled') and not u.get('available_from') and not u.get('available_until'): SubEventItem.objects.filter( subevent__in=self.get_queryset(), item=f.instance.item, @@ -1423,7 +1459,7 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie defaults=u ) elif isinstance(f, SubEventItemVariationForm): - if u.get('price') is None and not u.get('disabled'): + if u.get('price') is None and not u.get('disabled') and not u.get('available_from') and not u.get('available_until'): SubEventItemVariation.objects.filter( subevent__in=self.get_queryset(), variation=f.instance.variation, diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index f7737b5df1..feabd46835 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -44,7 +44,7 @@ import pytz from django.conf import settings from django.core.exceptions import PermissionDenied from django.db.models import ( - Count, Exists, IntegerField, OuterRef, Prefetch, Value, + Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value, ) from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render @@ -139,9 +139,9 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate( subevent_disabled=Exists( SubEventItemVariation.objects.filter( + Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()), variation_id=OuterRef('pk'), subevent=subevent, - disabled=True, ) ), ).filter( @@ -156,9 +156,9 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require has_variations=Count('variations'), subevent_disabled=Exists( SubEventItem.objects.filter( + Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()), item_id=OuterRef('pk'), subevent=subevent, - disabled=True, ) ), requires_seat=requires_seat, diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index 462db06bac..71acf41921 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -718,3 +718,17 @@ table td > .checkbox input[type="checkbox"] { .pos-relative { position: relative; } + +#subevent-bulk-create-form { + fieldset { + margin-bottom: 40px; + } +} +.subevent-itemvar-group { + label.control-label { + padding-top: 0; + } + label:not(.control-label) { + font-weight: normal; + } +} diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index caa4cb2fe3..16eab8353c 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -554,6 +554,22 @@ class CartTest(CartTestMixin, TestCase): objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 0) + def test_subevent_availability(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + SubEventItem.objects.create(subevent=se, item=self.ticket, price=42, available_until=now() - timedelta(hours=1)) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se.pk + }, follow=False) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + def test_subevent_price(self): self.event.has_subevents = True self.event.save() @@ -706,6 +722,22 @@ class CartTest(CartTestMixin, TestCase): objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 0) + def test_subevent_variation_availability(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.variations.add(self.shirt_red) + SubEventItemVariation.objects.create(subevent=se, variation=self.shirt_red, price=42, available_from=now() + timedelta(hours=1)) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1', + 'subevent': se.pk + }, follow=False) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + def test_subevent_variation_price(self): self.event.has_subevents = True self.event.save() diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 1575fcd952..2b516b10c8 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -1764,6 +1764,43 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): with scopes_disabled(): assert not CartPosition.objects.filter(id=cr1.id).exists() + def test_subevent_availability(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se = self.event.subevents.create(name='Foo', date_from=now()) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + SubEventItem.objects.create(subevent=se, item=self.ticket, price=24, available_until=now() - timedelta(days=1)) + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + self._set_session('payment', 'banktransfer') + + self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + with scopes_disabled(): + assert not CartPosition.objects.filter(id=cr1.id).exists() + + def test_subevent_variation_availability(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se = self.event.subevents.create(name='Foo', date_from=now()) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.workshop2) + q.variations.add(self.workshop2b) + SubEventItemVariation.objects.create(subevent=se, variation=self.workshop2b, price=24, available_from=now() + timedelta(days=1)) + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.workshop2, variation=self.workshop2b, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + self._set_session('payment', 'banktransfer') + + self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + with scopes_disabled(): + assert not CartPosition.objects.filter(id=cr1.id).exists() + def test_addon_price_included(self): with scopes_disabled(): ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1, diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 6efce29fc8..6f74f6210c 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -318,6 +318,24 @@ class ItemDisplayTest(EventTestMixin, SoupTest): resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) self.assertIn("Early-bird", resp.rendered_content) + def test_subevent_availability(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=15) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + q.items.add(item) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se2) + q.items.add(item) + SubEventItem.objects.create(subevent=se1, item=item, price=12, available_until=now() - datetime.timedelta(hours=1)) + + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se1.pk)) + self.assertNotIn("Early-bird", resp.rendered_content) + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) + self.assertIn("Early-bird", resp.rendered_content) + def test_subevent_prices(self): self.event.has_subevents = True self.event.save() @@ -384,6 +402,27 @@ class ItemDisplayTest(EventTestMixin, SoupTest): resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) self.assertIn("Early-bird", resp.rendered_content) + def test_variations_subevent_availability(self): + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=15) + v = ItemVariation.objects.create(item=item, value='Blue') + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + q.items.add(item) + q.variations.add(v) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se2) + q.items.add(item) + q.variations.add(v) + SubEventItemVariation.objects.create(subevent=se1, variation=v, available_from=now() + datetime.timedelta(hours=1)) + + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se1.pk)) + self.assertNotIn("Early-bird", resp.rendered_content) + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) + self.assertIn("Early-bird", resp.rendered_content) + def test_no_variations_in_quota(self): with scopes_disabled(): c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)