diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py
index 418dbc00f9..639cac78c8 100644
--- a/src/pretix/api/serializers/order.py
+++ b/src/pretix/api/serializers/order.py
@@ -1585,6 +1585,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.invoice_address = ia
ia.last_modified = now()
+ if order.customer_id:
+ memberships = order.customer.memberships.filter(testmode=order.testmode).select_related('membership_type')
+ else:
+ memberships = []
+
# Generate position objects
pos_map = {}
for pos_data in positions_data:
@@ -1655,7 +1660,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
- cp.addon_to, cp.is_bundled, pos._voucher_discount)
+ cp.addon_to, cp.is_bundled, pos._voucher_discount, [
+ m for m in memberships if m.is_valid(
+ cp.subevent or self.context['event'],
+ cp.valid_from,
+ )
+ ])
for cp in order_positions
]
)
diff --git a/src/pretix/base/migrations/0311_discount_require_membership_and_more.py b/src/pretix/base/migrations/0311_discount_require_membership_and_more.py
new file mode 100644
index 0000000000..441a2534e1
--- /dev/null
+++ b/src/pretix/base/migrations/0311_discount_require_membership_and_more.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.2.17 on 2026-09-13 18:32
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("pretixbase", "0310_question_valid_string_length_min"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="discount",
+ name="require_membership",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name="discount",
+ name="require_membership_types",
+ field=models.ManyToManyField(to="pretixbase.membershiptype"),
+ ),
+ ]
diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py
index e53e771278..004a9b7a29 100644
--- a/src/pretix/base/models/customers.py
+++ b/src/pretix/base/models/customers.py
@@ -296,7 +296,7 @@ class Customer(LoggedModel):
return self.memberships.active(for_event).with_usages().filter(
Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages')),
testmode=testmode,
- )
+ ).select_related('membership_type')
def send_activation_mail(self):
from pretix.base.services.mail import mail
diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py
index 65d10b62e9..d36878fc1b 100644
--- a/src/pretix/base/models/discount.py
+++ b/src/pretix/base/models/discount.py
@@ -19,12 +19,12 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
-
-from collections import defaultdict, namedtuple
+import datetime
+from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from math import ceil, inf
-from typing import Dict
+from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
@@ -35,10 +35,20 @@ from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
+from pretix.base.timemachine import time_machine_now
-PositionInfo = namedtuple('PositionInfo',
- ['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
- 'voucher_discount'])
+if TYPE_CHECKING:
+ from pretix.base.models import Membership
+
+
+class PositionInfo(NamedTuple):
+ item_id: int
+ subevent_id: Optional[int]
+ subevent_date_from: Optional[datetime.datetime]
+ line_price_gross: Decimal
+ addon_to: bool
+ voucher_discount: Decimal
+ memberships_valid_by_time: Optional[List["Membership"]] = None
class Discount(LoggedModel):
@@ -249,6 +259,46 @@ class Discount(LoggedModel):
]}
)
+ if data.get('require_membership'):
+ if not data.get('require_membership_types'):
+ raise ValidationError(
+ {'require_membership_types': [
+ _("If a valid membership is required, at least one valid membership type needs to be selected.")
+ ]}
+ )
+ for mt in data.get('require_membership_types', []):
+ # These conditions are a bit annoying/surprising, we could also just "ignore" the
+ # transferable/allow_parallel_usage/max_usages flags, but maybe we DO want to honor them for discounts
+ # some day for use cases like "every member can get one discounted ticket per show for themselves", and
+ # then we'd be unable to move forward if we historically ignored them.
+ if not mt.transferable:
+ raise ValidationError(
+ {'require_membership_types': [
+ _('The membership type "{type}" cannot be used for discounts since it is not transferable, '
+ 'which is currently not supported for discounts.').format(
+ type=mt.name,
+ )
+ ]}
+ )
+ elif not mt.allow_parallel_usage:
+ raise ValidationError(
+ {'require_membership_types': [
+ _('The membership type "{type}" cannot be used for discounts since it does not allow parallel '
+ 'usage, which is currently not supported for discounts.').format(
+ type=mt.name,
+ )
+ ]}
+ )
+ elif mt.max_usages is not None:
+ raise ValidationError(
+ {'require_membership_types': [
+ _('The membership type "{type}" cannot be used for discounts since it has a maximum number of '
+ 'usages, which is currently not supported for discounts.').format(
+ type=mt.name,
+ )
+ ]}
+ )
+
def allow_delete(self):
return not self.orderposition_set.exists()
@@ -260,6 +310,7 @@ class Discount(LoggedModel):
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
'subevent_mode': self.subevent_mode,
'benefit_same_products': self.benefit_same_products,
+ # many-to-many is not passed here, therefore we also call validate_config in form and serializer
})
def is_available_by_time(self, now_dt=None) -> bool:
@@ -410,19 +461,25 @@ class Discount(LoggedModel):
limit_products = set()
if not self.condition_all_products:
limit_products = {p.pk for p in self.condition_limit_products.all()}
+ membership_types = set()
+ if self.require_membership and self.require_membership_types:
+ membership_types = {mt.pk for mt in self.require_membership_types.all()}
# First, filter out everything not even covered by our product scope
condition_candidates = [
idx
- for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
+ for idx, p 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)
+ (self.condition_all_products or p.item_id in limit_products) and
+ (self.condition_apply_to_addons or not p.addon_to) and
+ (not self.condition_ignore_voucher_discounted or p.voucher_discount is None or p.voucher_discount == Decimal('0.00')) and
+ (not p.subevent_id or (
+ self.subevent_date_from is None or p.subevent_date_from >= self.subevent_date_from)) and (
+ self.subevent_date_until is None or p.subevent_date_from <= self.subevent_date_until) and
+ (not self.require_membership or (
+ p.memberships_valid_by_time and any(m.membership_type_id in membership_types for m in p.memberships_valid_by_time)
+ ))
)
]
@@ -432,12 +489,15 @@ class Discount(LoggedModel):
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
benefit_candidates = [
idx
- for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
+ for idx, p in
positions.items()
if (
- item_id in benefit_products and
- (self.benefit_apply_to_addons or not is_addon_to) and
- (not self.benefit_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
+ p.item_id in benefit_products and
+ (self.benefit_apply_to_addons or not p.addon_to) and
+ (not self.benefit_ignore_voucher_discounted or p.voucher_discount is None or p.voucher_discount == Decimal('0.00')) and
+ (not self.require_membership or (
+ p.memberships_valid_by_time and any(m.membership_type_id in membership_types for m in p.memberships_valid_by_time)
+ ))
)
]
@@ -538,3 +598,38 @@ class Discount(LoggedModel):
None
)
return result
+
+ def condition_matches_product(self, item) -> bool:
+ return self.condition_all_products or item in self.condition_limit_products.all()
+
+ def valid_for_memberships(self, memberships, event, subevent, item, requested_valid_from=None) -> bool:
+ if not self.require_membership_types:
+ return True
+ if not memberships:
+ return False
+ memberships = [
+ m for m in memberships
+ # Filter currently unsupported configurations
+ if (
+ m.membership_type.max_usages is None and
+ m.membership_type.transferable and
+ m.membership_type.allow_parallel_usage and
+ # Filter list of membership types
+ m.membership_type in self.require_membership_types.all()
+ )
+ ]
+ valid_from, _ = item.compute_validity(
+ requested_start=(
+ max(requested_valid_from, time_machine_now())
+ if requested_valid_from and item.validity_dynamic_start_choice
+ else time_machine_now()
+ ),
+ override_tz=self.event.timezone,
+ )
+ return any(
+ m.is_valid(
+ subevent or event,
+ valid_from,
+ valid_from_not_chosen=item.validity_dynamic_start_choice and not requested_valid_from
+ ) for m in memberships
+ )
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index 15dba58a98..13a5d91ba6 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -3413,6 +3413,15 @@ class CartPosition(AbstractPosition):
def valid_until(self):
return self.predicted_validity[1]
+ def memberships_valid_by_time(self, memberships):
+ return [
+ m for m in memberships if m.is_valid(
+ self.subevent or self.event,
+ self.valid_from,
+ valid_from_not_chosen=self.item.validity_dynamic_start_choice and not self.requested_valid_from
+ ) for m in memberships
+ ]
+
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index 9e1c2e45fc..9fdd361b8b 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -47,6 +47,7 @@ from django.db import DatabaseError, transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
from django.db.models.aggregates import Min
from django.dispatch import receiver
+from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import (
gettext as _, gettext_lazy, ngettext_lazy, pgettext_lazy,
@@ -57,8 +58,8 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
- CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel,
- Seat, SeatCategoryMapping, Voucher,
+ CartPosition, Customer, Event, InvoiceAddress, Item, ItemVariation,
+ SalesChannel, Seat, SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import CheckoutSession, OrderFee
@@ -417,13 +418,15 @@ class CartManager:
}
def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel,
- invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None):
+ invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None,
+ customer: Customer=None):
"""
Creates a new CartManager for an event.
"""
self.event = event
self.cart_id = cart_id
self.real_now_dt = now()
+ self.customer = customer
self._operations = []
self._quota_diff = Counter()
self._voucher_use_diff = Counter()
@@ -1544,7 +1547,7 @@ class CartManager:
self._sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
- cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
+ cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher, cp.memberships_valid_by_time(self.memberships))
for cp in positions
]
)
@@ -1559,6 +1562,13 @@ class CartManager:
return diff
+ @cached_property
+ def memberships(self):
+ if self.customer:
+ return self.customer.memberships.filter(testmode=self.event.testmode).select_related('membership_type')
+ else:
+ return []
+
def _remove_parents_if_bundles_are_removed(self):
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
for op in self._operations:
@@ -1712,7 +1722,8 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
- invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None) -> None:
+ invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None,
+ customer: int=None) -> None:
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
@@ -1734,10 +1745,16 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
+ if customer:
+ try:
+ customer = event.organizer.customers.get(pk=customer)
+ except Customer.DoesNotExist:
+ raise CartError("Invalid customer.")
+
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
- sales_channel=sales_channel)
+ sales_channel=sales_channel, customer=customer)
cm.add_new_items(items)
cm.commit()
except LockTimeoutException:
@@ -1747,7 +1764,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
-def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
+def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
:param event: The event ID in question
:param voucher: A voucher code
@@ -1758,9 +1775,14 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
+ if customer:
+ try:
+ customer = event.organizer.customers.get(pk=customer)
+ except Customer.DoesNotExist:
+ raise CartError("Invalid customer.")
try:
try:
- cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
+ cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.apply_voucher(voucher)
cm.commit()
except LockTimeoutException:
@@ -1770,7 +1792,8 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
-def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
+def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en',
+ sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
Removes an item specified by its position ID from a user's cart.
:param event: The event ID in question
@@ -1782,9 +1805,14 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
+ if customer:
+ try:
+ customer = event.organizer.customers.get(pk=customer)
+ except Customer.DoesNotExist:
+ raise CartError("Invalid customer.")
try:
try:
- cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
+ cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.remove_item(position)
cm.commit()
except LockTimeoutException:
@@ -1794,7 +1822,7 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
-def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
+def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
Removes all items from a user's cart.
:param event: The event ID in question
@@ -1805,9 +1833,14 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
+ if customer:
+ try:
+ customer = event.organizer.customers.get(pk=customer)
+ except Customer.DoesNotExist:
+ raise CartError("Invalid customer.")
try:
try:
- cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
+ cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.clear()
cm.commit()
except LockTimeoutException:
@@ -1817,7 +1850,7 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
-def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> dict:
+def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> dict:
"""
Resets the expiry time of a cart to the configured reservation time of this event.
Limited to 11x the reservation time.
@@ -1830,9 +1863,14 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
+ if customer:
+ try:
+ customer = event.organizer.customers.get(pk=customer)
+ except Customer.DoesNotExist:
+ raise CartError("Invalid customer.")
try:
try:
- cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
+ cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.commit()
return {
"success": cm.num_extended_positions,
@@ -1848,7 +1886,7 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en',
- invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
+ invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to
ensure the requested addon state.
@@ -1869,9 +1907,14 @@ def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: L
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
+ if customer:
+ try:
+ customer = event.organizer.customers.get(pk=customer)
+ except Customer.DoesNotExist:
+ raise CartError("Invalid customer.")
try:
try:
- cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
+ cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel, customer=customer)
cm.set_addons(addons)
cm.add_new_items(add_to_cart_items)
cm.commit()
diff --git a/src/pretix/base/services/cross_selling.py b/src/pretix/base/services/cross_selling.py
index 132ce5d0cb..57ba6e6303 100644
--- a/src/pretix/base/services/cross_selling.py
+++ b/src/pretix/base/services/cross_selling.py
@@ -112,6 +112,13 @@ class CrossSellingService:
}
return category.items.filter(pk__in=potential_discount_items), potential_discount_items
+ @cached_property
+ def memberships(self):
+ if self.customer:
+ return self.customer.memberships.filter(testmode=self.event.testmode).select_related('membership_type')
+ else:
+ return []
+
@cached_property
def _potential_discounts_by_subevent_and_item_for_current_cart(self):
potential_discounts_by_cartpos = defaultdict(list)
@@ -123,7 +130,8 @@ class CrossSellingService:
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled,
- cp.listed_price - cp.price_after_voucher)
+ cp.listed_price - cp.price_after_voucher,
+ cp.memberships_valid_by_time(self.memberships))
for cp in self.cartpositions
],
collect_potential_discounts=potential_discounts_by_cartpos
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index 890fa06409..e25b23e625 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -56,7 +56,7 @@ from django.db.models import (
from django.db.models.functions import Cast, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
-from django.utils.functional import cached_property
+from django.utils.functional import cached_property, lazy
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
from django_scopes import scopes_disabled
@@ -923,12 +923,18 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
continue
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
+
+ if customer:
+ memberships = lazy(lambda: list(customer.memberships.filter(testmode=event.testmode).select_related('membership_type')), list)()
+ else:
+ memberships = []
+
discount_results = apply_discounts(
event,
sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
- cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
+ cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher, cp.memberships_valid_by_time(memberships))
for cp in sorted_positions
]
)
diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py
index cd332f157a..21fca82830 100644
--- a/src/pretix/base/services/pricing.py
+++ b/src/pretix/base/services/pricing.py
@@ -33,7 +33,7 @@ from django.db.models import Q
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
- ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
+ ItemVariation, Membership, OrderFee, OrderPosition, SalesChannel, Voucher,
)
from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent
@@ -167,14 +167,15 @@ 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], Optional[datetime], Decimal, bool, bool, Decimal]],
+ positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal, List[Membership]]],
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, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
+ :param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled,
+ voucher_discount, memberships_valid_by_time)``
``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but
a better result may be given if addons to the same main product have the same distinct value.
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
@@ -190,6 +191,17 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
sales_channel = sales_channel.identifier
new_prices = {}
+ def _filter_memberships(memberships: List[Membership]) -> List[Membership]:
+ return [
+ # Currently unsupported configurations
+ m for m in memberships
+ if (
+ m.membership_type.max_usages is None and
+ m.membership_type.transferable and
+ m.membership_type.allow_parallel_usage
+ )
+ ]
+
discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
@@ -198,9 +210,11 @@ 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, subevent_date_from, line_price_gross, addon_to, voucher_discount)
+ idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount,
+ _filter_memberships(memberships_valid_by_time))
for
- idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
+ idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount,
+ memberships_valid_by_time)
in enumerate(positions)
if not is_bundled and idx not in new_prices
}, collect_potential_discounts)
diff --git a/src/pretix/control/forms/discounts.py b/src/pretix/control/forms/discounts.py
index 7f134c431e..3dde8b2449 100644
--- a/src/pretix/control/forms/discounts.py
+++ b/src/pretix/control/forms/discounts.py
@@ -22,7 +22,6 @@
from decimal import Decimal
from django import forms
-from django.utils.translation import gettext_lazy as _
from django_scopes.forms import SafeModelMultipleChoiceField
from pretix.base.channels import get_all_sales_channel_types
@@ -72,6 +71,7 @@ class DiscountForm(I18nModelForm):
'condition_limit_products': ItemMultipleChoiceField,
'benefit_limit_products': ItemMultipleChoiceField,
'limit_sales_channels': SafeModelMultipleChoiceField,
+ 'require_membership_types': SafeModelMultipleChoiceField,
}
widgets = {
'subevent_mode': forms.RadioSelect,
@@ -91,6 +91,9 @@ class DiscountForm(I18nModelForm):
'data-display-dependency': '#id_condition_min_count',
}
),
+ 'require_membership_types': forms.CheckboxSelectMultiple(attrs={
+ 'class': 'scrolling-multiple-choice'
+ }),
}
def __init__(self, *args, **kwargs):
@@ -134,11 +137,5 @@ class DiscountForm(I18nModelForm):
if d.get('condition_min_value') is None:
d['condition_min_value'] = Decimal('0.00')
- if d.get('require_membership') and not d.get('require_membership_types'):
- self.add_error(
- 'require_membership_types',
- _(
- "If a valid membership is required, at least one valid membership type needs to be selected."
- )
- )
+ Discount.validate_config(d)
return d
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_edit.html
index 46705f58f2..adf7942afd 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_edit.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/membershiptype_edit.html
@@ -9,7 +9,23 @@
{% endif %}