diff --git a/doc/api/resources/discounts.rst b/doc/api/resources/discounts.rst index 1e89d9184b..9900268b60 100644 --- a/doc/api/resources/discounts.rst +++ b/doc/api/resources/discounts.rst @@ -30,6 +30,8 @@ available_from datetime The first da (or ``null``). available_until datetime The last date time at which this discount can be applied (or ``null``). +require_membership boolean If ``true``, booking this variation requires an active membership. +require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true`` subevent_mode strings Determines how the discount is handled when used in an event series. Can be ``"mixed"`` (no special effect), ``"same"`` (discount is only applied for groups within @@ -108,6 +110,8 @@ Endpoints "sales_channels": ["web"], "available_from": null, "available_until": null, + "require_membership_hidden": false, + "require_membership_types": [], "subevent_mode": "mixed", "subevent_date_from": null, "subevent_date_until": null, @@ -168,6 +172,8 @@ Endpoints "sales_channels": ["web"], "available_from": null, "available_until": null, + "require_membership_hidden": false, + "require_membership_types": [], "subevent_mode": "mixed", "subevent_date_from": null, "subevent_date_until": null, @@ -214,6 +220,8 @@ Endpoints "sales_channels": ["web"], "available_from": null, "available_until": null, + "require_membership_hidden": false, + "require_membership_types": [], "subevent_mode": "mixed", "subevent_date_from": null, "subevent_date_until": null, @@ -249,6 +257,8 @@ Endpoints "sales_channels": ["web"], "available_from": null, "available_until": null, + "require_membership_hidden": false, + "require_membership_types": [], "subevent_mode": "mixed", "subevent_date_from": null, "subevent_date_until": null, @@ -313,6 +323,8 @@ Endpoints "sales_channels": ["web"], "available_from": null, "available_until": null, + "require_membership_hidden": false, + "require_membership_types": [], "subevent_mode": "mixed", "subevent_date_from": null, "subevent_date_until": null, diff --git a/src/pretix/api/serializers/discount.py b/src/pretix/api/serializers/discount.py index e6f5d5f0a6..65e075e8c5 100644 --- a/src/pretix/api/serializers/discount.py +++ b/src/pretix/api/serializers/discount.py @@ -19,6 +19,8 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +from django.db.models import QuerySet +from django.utils.functional import lazy from rest_framework import serializers from pretix.api.serializers import SalesChannelMigrationMixin @@ -38,7 +40,8 @@ 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', 'subevent_date_from', 'subevent_date_until', + 'available_from', 'available_until', 'require_membership', 'require_membership_types', + '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', @@ -50,6 +53,7 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): self.fields['condition_limit_products'].queryset = self.context['event'].items.all() self.fields['benefit_limit_products'].queryset = self.context['event'].items.all() self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() + self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet) def validate(self, data): data = super().validate(data) diff --git a/src/pretix/api/views/discount.py b/src/pretix/api/views/discount.py index 92bbd671e0..41a4ef55ac 100644 --- a/src/pretix/api/views/discount.py +++ b/src/pretix/api/views/discount.py @@ -62,7 +62,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet): def get_queryset(self): return self.request.event.discounts.prefetch_related( - 'limit_sales_channels', + 'limit_sales_channels', 'require_membership_types', ) @transaction.atomic() diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py index f39a95b175..65d10b62e9 100644 --- a/src/pretix/base/models/discount.py +++ b/src/pretix/base/models/discount.py @@ -96,6 +96,16 @@ class Discount(LoggedModel): choices=SUBEVENT_MODE_CHOICES, ) + require_membership = models.BooleanField( + verbose_name=_('Require a valid membership'), + default=False, + ) + require_membership_types = models.ManyToManyField( + 'MembershipType', + verbose_name=_('Allowed membership types'), + blank=True, + ) + condition_all_products = models.BooleanField( default=True, verbose_name=_("Apply to all products (including newly created ones)") diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 235ab89f9c..44ea0d6547 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -1081,6 +1081,7 @@ class Event(EventMixin, LoggedModel): c_items = list(d.condition_limit_products.all()) b_items = list(d.benefit_limit_products.all()) limit_sales_channels = list(d.limit_sales_channels.all()) + require_membership_types = list(d.require_membership_types.all()) d.pk = None d.event = self d._prefetched_objects_cache = {} @@ -1092,9 +1093,10 @@ class Event(EventMixin, LoggedModel): for i in b_items: if i.pk in item_map: d.benefit_limit_products.add(item_map[i.pk]) - if not d.all_sales_channels: d.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels])) + if require_membership_types and not is_cross_organizer: + d.require_membership_types.set(require_membership_types) question_map = {} for q in Question.objects.filter(event=other).prefetch_related('items', 'options'): diff --git a/src/pretix/base/services/memberships.py b/src/pretix/base/services/memberships.py index f6495e3512..942f02026c 100644 --- a/src/pretix/base/services/memberships.py +++ b/src/pretix/base/services/memberships.py @@ -86,7 +86,7 @@ def create_membership(customer: Customer, position: OrderPosition): def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False, valid_from_not_chosen=False): """ - Validate that a set of cart or order positions. This currently does not validate + Validate that a set of cart or order positions. :param customer: Customer to validate for :param positions: List of order or cart positions diff --git a/src/pretix/control/forms/discounts.py b/src/pretix/control/forms/discounts.py index e73babde3f..7f134c431e 100644 --- a/src/pretix/control/forms/discounts.py +++ b/src/pretix/control/forms/discounts.py @@ -22,6 +22,7 @@ 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 @@ -45,6 +46,8 @@ class DiscountForm(I18nModelForm): 'limit_sales_channels', 'available_from', 'available_until', + 'require_membership', + 'require_membership_types', 'subevent_date_from', 'subevent_date_until', 'subevent_mode', @@ -107,6 +110,13 @@ class DiscountForm(I18nModelForm): self.fields['condition_min_value'].required = False self.fields['condition_min_value'].widget.is_required = False + qs = self.event.organizer.membership_types.all() + if qs: + self.fields['require_membership_types'].queryset = qs + else: + del self.fields['require_membership'] + del self.fields['require_membership_types'] + if not self.event.has_subevents: del self.fields['subevent_mode'] @@ -123,4 +133,12 @@ class DiscountForm(I18nModelForm): d['condition_min_count'] = 0 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." + ) + ) return d diff --git a/src/pretix/control/templates/pretixcontrol/items/discount.html b/src/pretix/control/templates/pretixcontrol/items/discount.html index 5265729600..0945f4e6e6 100644 --- a/src/pretix/control/templates/pretixcontrol/items/discount.html +++ b/src/pretix/control/templates/pretixcontrol/items/discount.html @@ -17,6 +17,12 @@ {% bootstrap_field form.available_until layout="control" %} {% bootstrap_field form.all_sales_channels layout="control" %} {% bootstrap_field form.limit_sales_channels layout="control" %} + {% if form.require_membership %} + {% bootstrap_field form.require_membership layout="control" %} +
+ {% bootstrap_field form.require_membership_types layout="control" %} +
+ {% endif %}
{% trans "Condition" context "discount" %} diff --git a/src/tests/api/test_discounts.py b/src/tests/api/test_discounts.py index de461c1299..0c87146800 100644 --- a/src/tests/api/test_discounts.py +++ b/src/tests/api/test_discounts.py @@ -46,6 +46,8 @@ TEST_DISCOUNT_RES = { "sales_channels": ["bar", "baz", "web"], "available_from": None, "available_until": None, + "require_membership_hidden": False, + "require_membership_types": [], "subevent_mode": "mixed", "subevent_date_from": None, "subevent_date_until": None,