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 %}
{% csrf_token %} - {% bootstrap_form form layout="control" %} + {% bootstrap_form_errors form %} +
+ {% trans "General information" %} + {% bootstrap_field form.name layout="control" %} +
+
+ {% trans "Usage restrictions" %} +

+ {% blocktrans trimmed %} + The following restrictions apply whenever the membership is used to buy a product or variation that + is only available to membership holders. + {% endblocktrans %} +

+ {% bootstrap_field form.transferable layout="control" %} + {% bootstrap_field form.allow_parallel_usage layout="control" %} + {% bootstrap_field form.max_usages layout="control" %} +