diff --git a/doc/api/resources/discounts.rst b/doc/api/resources/discounts.rst
index 70f5c24006..1e89d9184b 100644
--- a/doc/api/resources/discounts.rst
+++ b/doc/api/resources/discounts.rst
@@ -35,6 +35,10 @@ subevent_mode strings Determines h
``"same"`` (discount is only applied for groups within
the same date), or ``"distinct"`` (discount is only applied
for groups with no two same dates).
+subevent_date_from datetime The first date time of a subevent to which this discount can be applied
+ (or ``null``). Ignored in non-series events.
+subevent_date_until datetime The last date time of a subevent to which this discount can be applied
+ (or ``null``). Ignored in non-series events.
condition_all_products boolean If ``true``, the discount condition applies to all items.
condition_limit_products list of integers If ``condition_all_products`` is not set, this is a list
of internal item IDs that the discount condition applies to.
@@ -105,6 +109,8 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
+ "subevent_date_from": null,
+ "subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
@@ -163,6 +169,8 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
+ "subevent_date_from": null,
+ "subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
@@ -207,6 +215,8 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
+ "subevent_date_from": null,
+ "subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
@@ -240,6 +250,8 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
+ "subevent_date_from": null,
+ "subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
@@ -302,6 +314,8 @@ Endpoints
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
+ "subevent_date_from": null,
+ "subevent_date_until": null,
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
diff --git a/src/pretix/api/serializers/discount.py b/src/pretix/api/serializers/discount.py
index 1f632c1b73..9cf4ae748b 100644
--- a/src/pretix/api/serializers/discount.py
+++ b/src/pretix/api/serializers/discount.py
@@ -38,11 +38,12 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class Meta:
model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels',
- 'available_from', 'available_until', 'subevent_mode', 'condition_all_products',
- 'condition_limit_products', 'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
- 'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
- 'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons',
- 'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted')
+ 'available_from', 'available_until', 'subevent_mode', 'subevent_date_from', 'subevent_date_until',
+ 'condition_all_products', 'condition_limit_products', 'condition_apply_to_addons',
+ 'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent',
+ 'benefit_only_apply_to_cheapest_n_matches', 'benefit_same_products', 'benefit_limit_products',
+ 'benefit_apply_to_addons', 'benefit_ignore_voucher_discounted',
+ 'condition_ignore_voucher_discounted')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py
index 162a21d6aa..1eb02e4939 100644
--- a/src/pretix/api/serializers/order.py
+++ b/src/pretix/api/serializers/order.py
@@ -1518,7 +1518,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
self.context['event'],
order.sales_channel,
[
- (cp.item_id, cp.subevent_id, cp.price, bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
+ (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
+ bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
diff --git a/src/pretix/base/migrations/0279_discount_event_date_from_discount_event_date_until.py b/src/pretix/base/migrations/0279_discount_event_date_from_discount_event_date_until.py
new file mode 100644
index 0000000000..609230e8ac
--- /dev/null
+++ b/src/pretix/base/migrations/0279_discount_event_date_from_discount_event_date_until.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.19 on 2025-03-18 09:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0278_login_source_add_unique_together'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='discount',
+ name='subevent_date_from',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='discount',
+ name='subevent_date_until',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py
index be2b3d0ac4..dc99e4bda6 100644
--- a/src/pretix/base/models/discount.py
+++ b/src/pretix/base/models/discount.py
@@ -36,7 +36,9 @@ from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
-PositionInfo = namedtuple('PositionInfo', ['item_id', 'subevent_id', 'line_price_gross', 'is_addon_to', 'voucher_discount'])
+PositionInfo = namedtuple('PositionInfo',
+ ['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to',
+ 'voucher_discount'])
class Discount(LoggedModel):
@@ -171,6 +173,17 @@ class Discount(LoggedModel):
"access to sold-out quota will still receive the discount."),
)
+ subevent_date_from = models.DateTimeField(
+ verbose_name=pgettext_lazy("subevent", "Available for dates starting from"),
+ null=True,
+ blank=True,
+ )
+ subevent_date_until = models.DateTimeField(
+ verbose_name=pgettext_lazy("subevent", "Available for dates starting until"),
+ null=True,
+ blank=True,
+ )
+
# more feature ideas:
# - max_usages_per_order
# - promote_to_user_if_almost_satisfied
@@ -355,11 +368,15 @@ class Discount(LoggedModel):
# First, filter out everything not even covered by our product scope
condition_candidates = [
idx
- for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
+ for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
+ positions.items()
if (
(self.condition_all_products or item_id in limit_products) and
(self.condition_apply_to_addons or not is_addon_to) and
(not self.condition_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
+ and (not subevent_id or (
+ self.subevent_date_from is None or subevent_date_from >= self.subevent_date_from)) and (
+ self.subevent_date_until is None or subevent_date_from <= self.subevent_date_until)
)
]
@@ -369,7 +386,8 @@ class Discount(LoggedModel):
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
benefit_candidates = [
idx
- for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
+ for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
+ positions.items()
if (
item_id in benefit_products and
(self.benefit_apply_to_addons or not is_addon_to) and
diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index 1263a24c00..d3e359013d 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -1398,7 +1398,8 @@ class CartManager:
self.event,
self._sales_channel.identifier,
[
- (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
+ (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
+ bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions
]
)
diff --git a/src/pretix/base/services/cross_selling.py b/src/pretix/base/services/cross_selling.py
index e81abd665e..fe48d1e01d 100644
--- a/src/pretix/base/services/cross_selling.py
+++ b/src/pretix/base/services/cross_selling.py
@@ -120,7 +120,8 @@ class CrossSellingService:
self.event,
self.sales_channel,
[
- (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled,
+ (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
+ bool(cp.addon_to), cp.is_bundled,
cp.listed_price - cp.price_after_voucher)
for cp in self.cartpositions
],
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index afa71dad19..4fc0ae1506 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -875,7 +875,8 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
event,
sales_channel.identifier,
[
- (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
+ (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
+ bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions
]
)
diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py
index ee93900304..ac6b1a13e8 100644
--- a/src/pretix/base/services/pricing.py
+++ b/src/pretix/base/services/pricing.py
@@ -21,6 +21,7 @@
#
import re
from collections import defaultdict
+from datetime import datetime
from decimal import Decimal
from typing import List, Optional, Tuple, Union
@@ -162,14 +163,14 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
- positions: List[Tuple[int, Optional[int], Decimal, bool, bool, Decimal]],
+ positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
"""
Applies any dynamic discounts to a cart
:param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with
- :param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
+ :param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
@@ -191,12 +192,14 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs:
result = discount.apply({
- idx: PositionInfo(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
- for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
+ idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount)
+ for
+ idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)
+ in enumerate(positions)
if not is_bundled and idx not in new_prices
}, collect_potential_discounts)
for k in result.keys():
result[k] = (result[k], discount)
new_prices.update(result)
- return [new_prices.get(idx, (p[2], None)) for idx, p in enumerate(positions)]
+ return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
diff --git a/src/pretix/control/forms/discounts.py b/src/pretix/control/forms/discounts.py
index aac9e08270..44ad2386cb 100644
--- a/src/pretix/control/forms/discounts.py
+++ b/src/pretix/control/forms/discounts.py
@@ -45,6 +45,8 @@ class DiscountForm(I18nModelForm):
'limit_sales_channels',
'available_from',
'available_until',
+ 'subevent_date_from',
+ 'subevent_date_until',
'subevent_mode',
'condition_all_products',
'condition_limit_products',
@@ -62,6 +64,8 @@ class DiscountForm(I18nModelForm):
field_classes = {
'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField,
+ 'subevent_date_from': SplitDateTimeField,
+ 'subevent_date_until': SplitDateTimeField,
'condition_limit_products': ItemMultipleChoiceField,
'benefit_limit_products': ItemMultipleChoiceField,
'limit_sales_channels': SafeModelMultipleChoiceField,
@@ -70,6 +74,8 @@ class DiscountForm(I18nModelForm):
'subevent_mode': forms.RadioSelect,
'available_from': SplitDateTimePickerWidget(),
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
+ 'subevent_date_from': SplitDateTimePickerWidget(),
+ 'subevent_date_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_subevent_date_from_0'}),
'condition_limit_products': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '<[name$=all_products]',
'class': 'scrolling-multiple-choice',
diff --git a/src/pretix/control/templates/pretixcontrol/items/discount.html b/src/pretix/control/templates/pretixcontrol/items/discount.html
index a7ea79b89f..5265729600 100644
--- a/src/pretix/control/templates/pretixcontrol/items/discount.html
+++ b/src/pretix/control/templates/pretixcontrol/items/discount.html
@@ -26,6 +26,8 @@
{% bootstrap_field form.condition_ignore_voucher_discounted layout="control" %}
{% if form.subevent_mode %}
{% bootstrap_field form.subevent_mode layout="control" %}
+ {% bootstrap_field form.subevent_date_from layout="control" %}
+ {% bootstrap_field form.subevent_date_until layout="control" %}
{% endif %}