diff --git a/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst index 037ffd98b..4419bb7b2 100644 --- a/doc/api/resources/item_variations.rst +++ b/doc/api/resources/item_variations.rst @@ -25,6 +25,8 @@ description multi-lingual string A public descri Markdown syntax or can be ``null``. position integer An integer, used for sorting require_membership boolean If ``true``, booking this variation requires an active membership. +require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will + be hidden from users without a valid membership. require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true`` sales_channels list of strings Sales channels this variation is available on, such as ``"web"`` or ``"resellers"``. Defaults to all existing sales channels. @@ -75,6 +77,7 @@ Endpoints }, "active": true, "require_membership": false, + "require_membership_hidden": false, "require_membership_types": [], "sales_channels": ["web"], "available_from": null, @@ -95,6 +98,7 @@ Endpoints }, "active": true, "require_membership": false, + "require_membership_hidden": false, "require_membership_types": [], "description": {}, "position": 1, @@ -144,6 +148,7 @@ Endpoints "original_price": null, "active": true, "require_membership": false, + "require_membership_hidden": false, "require_membership_types": [], "sales_channels": ["web"], "available_from": null, @@ -179,6 +184,7 @@ Endpoints "default_price": "10.00", "active": true, "require_membership": false, + "require_membership_hidden": false, "require_membership_types": [], "sales_channels": ["web"], "available_from": null, @@ -204,6 +210,7 @@ Endpoints "original_price": null, "active": true, "require_membership": false, + "require_membership_hidden": false, "require_membership_types": [], "sales_channels": ["web"], "available_from": null, @@ -260,6 +267,7 @@ Endpoints "original_price": null, "active": false, "require_membership": false, + "require_membership_hidden": false, "require_membership_types": [], "sales_channels": ["web"], "available_from": null, diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 9251ddcef..61b8dbf57 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -70,6 +70,8 @@ require_approval boolean If ``true``, or paid. require_bundling boolean If ``true``, this item is only available as part of bundles. require_membership boolean If ``true``, booking this item requires an active membership. +require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this product will + be hidden from users without a valid membership. require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true`` grant_membership_type integer If set to the internal ID of a membership type, purchasing this item will create a membership of the given type. @@ -105,6 +107,8 @@ variations list of objects A list with one ├ active boolean If ``false``, this variation will not be sold or shown. ├ description multi-lingual string A public description of the variation. May contain ├ require_membership boolean If ``true``, booking this variation requires an active membership. +├ require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will + be hidden from users without a valid membership. ├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true`` Markdown syntax or can be ``null``. ├ sales_channels list of strings Sales channels this variation is available on, such as @@ -155,6 +159,10 @@ meta_data object Values set for The attributes ``require_membership``, ``require_membership_types``, ``grant_membership_type``, ``grant_membership_duration_like_event``, ``grant_membership_duration_days`` and ``grant_membership_duration_months`` have been added. +.. versionchanged:: 4.4 + + The attributes ``require_membership_hidden`` attribute has been added. + Notes ----- diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index c6b07cbb6..8eb3e0514 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -59,7 +59,7 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer): model = ItemVariation fields = ('id', 'value', 'active', 'description', 'position', 'default_price', 'price', 'original_price', - 'require_membership', 'require_membership_types', 'available_from', 'available_until', + 'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until', 'sales_channels', 'hide_without_voucher',) def __init__(self, *args, **kwargs): @@ -75,7 +75,7 @@ class ItemVariationSerializer(I18nAwareModelSerializer): model = ItemVariation fields = ('id', 'value', 'active', 'description', 'position', 'default_price', 'price', 'original_price', - 'require_membership', 'require_membership_types', 'available_from', 'available_until', + 'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until', 'sales_channels', 'hide_without_voucher',) def __init__(self, *args, **kwargs): @@ -175,7 +175,7 @@ class ItemSerializer(I18nAwareModelSerializer): 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', 'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data', - 'require_membership', 'require_membership_types', 'grant_membership_type', + 'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type', 'grant_membership_duration_like_event', 'grant_membership_duration_days', 'grant_membership_duration_months') read_only_fields = ('has_variations',) diff --git a/src/pretix/base/migrations/0199_auto_20211005_1050.py b/src/pretix/base/migrations/0199_auto_20211005_1050.py new file mode 100644 index 000000000..753269dac --- /dev/null +++ b/src/pretix/base/migrations/0199_auto_20211005_1050.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.4 on 2021-10-05 10:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0198_invoice_sent_to_customer'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='require_membership_hidden', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='itemvariation', + name='require_membership_hidden', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py index 6de35f3fd..9f5007690 100644 --- a/src/pretix/base/models/customers.py +++ b/src/pretix/base/models/customers.py @@ -25,6 +25,7 @@ from django.contrib.auth.hashers import ( check_password, is_password_usable, make_password, ) from django.db import models +from django.db.models import F, Q from django.utils.crypto import get_random_string, salted_hmac from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_scopes import ScopedManager, scopes_disabled @@ -183,6 +184,12 @@ class Customer(LoggedModel): def stored_addresses(self): return self.invoice_addresses(manager='profiles') + def usable_memberships(self, for_event, testmode=False): + 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, + ) + class AttendeeProfile(models.Model): customer = models.ForeignKey( diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 0425c9686..5ba586443 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -523,6 +523,12 @@ class Item(LoggedModel): verbose_name=_('Allowed membership types'), blank=True, ) + require_membership_hidden = models.BooleanField( + verbose_name=_('Hide without a valid membership'), + help_text=_('Do not show this unless the customer is logged in and has a valid membership. Be aware that ' + 'this means it will never be visible in the widget.'), + default=False, + ) grant_membership_type = models.ForeignKey( 'MembershipType', null=True, blank=True, @@ -802,6 +808,12 @@ class ItemVariation(models.Model): verbose_name=_('Membership types'), blank=True, ) + require_membership_hidden = models.BooleanField( + verbose_name=_('Hide without a valid membership'), + help_text=_('Do not show this unless the customer is logged in and has a valid membership. Be aware that ' + 'this means it will never be visible in the widget.'), + default=False, + ) available_from = models.DateTimeField( verbose_name=_("Available from"), null=True, blank=True, diff --git a/src/pretix/base/models/memberships.py b/src/pretix/base/models/memberships.py index e851f3fee..873a96dce 100644 --- a/src/pretix/base/models/memberships.py +++ b/src/pretix/base/models/memberships.py @@ -95,6 +95,7 @@ class MembershipQuerySet(models.QuerySet): def active(self, ev): return self.filter( + canceled=False, date_start__lte=ev.date_from, date_end__gte=ev.date_from ) @@ -175,7 +176,7 @@ class Membership(models.Model): else: dt = now() - return dt >= self.date_start and dt <= self.date_end + return not self.canceled and dt >= self.date_start and dt <= self.date_end def allow_delete(self): return self.testmode and not self.orderposition_set.exists() diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index c3a42b027..bb5d9797d 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -607,6 +607,7 @@ class ItemUpdateForm(I18nModelForm): 'issue_giftcard', 'require_membership', 'require_membership_types', + 'require_membership_hidden', 'grant_membership_type', 'grant_membership_duration_like_event', 'grant_membership_duration_days', @@ -713,6 +714,7 @@ class ItemVariationForm(I18nModelForm): 'original_price', 'description', 'require_membership', + 'require_membership_hidden', 'require_membership_types', 'available_from', 'available_until', diff --git a/src/pretix/control/templates/pretixcontrol/item/include_variations.html b/src/pretix/control/templates/pretixcontrol/item/include_variations.html index befeda194..6a6de82c5 100644 --- a/src/pretix/control/templates/pretixcontrol/item/include_variations.html +++ b/src/pretix/control/templates/pretixcontrol/item/include_variations.html @@ -77,6 +77,7 @@ {% bootstrap_field form.require_membership layout="control" %}
{% bootstrap_field form.require_membership_types layout="control" %} + {% bootstrap_field form.require_membership_hidden layout="control" %}
{% endif %} @@ -147,6 +148,7 @@ {% bootstrap_field formset.empty_form.require_membership layout="control" %}
{% bootstrap_field formset.empty_form.require_membership_types layout="control" %} + {% bootstrap_field formset.empty_form.require_membership_hidden layout="control" %}
{% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index e651f4578..6f29c7d55 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -105,6 +105,7 @@ {% bootstrap_field form.require_membership layout="control" %}
{% bootstrap_field form.require_membership_types layout="control" %} + {% bootstrap_field form.require_membership_hidden layout="control" %}
{% endif %} {% bootstrap_field form.allow_cancel layout="control" %} diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index c25636580..7e9a1ff9f 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -499,7 +499,14 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): channel=self.request.sales_channel.identifier, base_qs=iao.addon_category.items, allow_addons=True, - quota_cache=quota_cache + quota_cache=quota_cache, + memberships=( + self.request.customer.usable_memberships( + for_event=cartpos.subevent or self.request.event, + testmode=self.request.event.testmode + ) + if getattr(self.request, 'customer', None) else None + ), ) item_cache[ckey] = items else: diff --git a/src/pretix/presale/forms/waitinglist.py b/src/pretix/presale/forms/waitinglist.py index 24060cec1..6bb9e9865 100644 --- a/src/pretix/presale/forms/waitinglist.py +++ b/src/pretix/presale/forms/waitinglist.py @@ -43,13 +43,21 @@ class WaitingListForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.event = kwargs.pop('event') self.channel = kwargs.pop('channel') + customer = kwargs.pop('customer') super().__init__(*args, **kwargs) choices = [ ('', '') ] items, display_add_to_cart = get_grouped_items( - self.event, self.instance.subevent, require_seat=None + self.event, self.instance.subevent, require_seat=None, + memberships=( + self.request.customer.usable_memberships( + for_event=self.instance.subevent or self.event, + testmode=self.request.event.testmode + ) + if customer else None + ), ) for i in items: if not i.allow_waitinglist: diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 4d3e44edb..278c5923d 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -523,8 +523,18 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, CartMixin, TemplateView context['max_times'] = self.voucher.max_usages - self.voucher.redeemed # Fetch all items - items, display_add_to_cart = get_grouped_items(self.request.event, self.subevent, - voucher=self.voucher, channel=self.request.sales_channel.identifier) + items, display_add_to_cart = get_grouped_items( + self.request.event, + self.subevent, + voucher=self.voucher, + channel=self.request.sales_channel.identifier, + memberships=( + self.request.customer.usable_memberships( + for_event=self.subevent or self.request.event, + testmode=self.request.event.testmode + ) if getattr(self.request, 'customer', None) else None + ), + ) # Calculate how many options the user still has. If there is only one option, we can # check the box right away ;) diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index b0c95e838..7dcc4c540 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -100,7 +100,7 @@ def item_group_by_category(items): def get_grouped_items(event, subevent=None, voucher=None, channel='web', require_seat=0, base_qs=None, allow_addons=False, - quota_cache=None, filter_items=None, filter_categories=None): + quota_cache=None, filter_items=None, filter_categories=None, memberships=None): base_qs_set = base_qs is not None base_qs = base_qs if base_qs is not None else event.items @@ -120,10 +120,16 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require if not voucher or not voucher.show_hidden_items: variation_q &= Q(hide_without_voucher=False) + if memberships is not None: + prefetch_membership_types = ['require_membership_types'] + else: + prefetch_membership_types = [] + items = base_qs.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher, allow_addons=allow_addons).select_related( 'category', 'tax_rule', # for re-grouping 'hidden_if_available', ).prefetch_related( + *prefetch_membership_types, Prefetch('quotas', to_attr='_subevent_quotas', queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)), @@ -160,6 +166,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require quotas__isnull=False, subevent_disabled=False ).prefetch_related( + *prefetch_membership_types, Prefetch('quotas', to_attr='_subevent_quotas', queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)) @@ -240,6 +247,11 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require item._remove = True continue + if item.require_membership and item.require_membership_hidden: + if not memberships or not any([m.membership_type in item.require_membership_types.all() for m in memberships]): + item._remove = True + continue + item.description = str(item.description) for recv, resp in item_description.send(sender=event, item=item, variation=None): if resp: @@ -290,6 +302,11 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require display_add_to_cart = display_add_to_cart or item.order_max > 0 else: for var in item.available_variations: + if var.require_membership and var.require_membership_hidden: + if not memberships or not any([m.membership_type in var.require_membership_types.all() for m in memberships]): + var._remove = True + continue + var.description = str(var.description) for recv, resp in item_description.send(sender=event, item=item, variation=var): if resp: @@ -338,7 +355,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require item.available_variations = [ v for v in item.available_variations if v._subevent_quotas and ( not voucher or not voucher.quota_id or v in restrict_vars - ) + ) and not getattr(v, '_remove', False) ] if event.settings.hide_sold_out: @@ -439,7 +456,13 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): filter_items=self.request.GET.getlist('item'), filter_categories=self.request.GET.getlist('category'), require_seat=None, - channel=self.request.sales_channel.identifier + channel=self.request.sales_channel.identifier, + memberships=( + self.request.customer.usable_memberships( + for_event=self.subevent or self.request.event, + testmode=self.request.event.testmode + ) if getattr(self.request, 'customer', None) else None + ), ) context['waitinglist_seated'] = False diff --git a/src/pretix/presale/views/waiting.py b/src/pretix/presale/views/waiting.py index cae3a938e..b5b1a0cad 100644 --- a/src/pretix/presale/views/waiting.py +++ b/src/pretix/presale/views/waiting.py @@ -54,6 +54,7 @@ class WaitingView(EventViewMixin, FormView): subevent=self.subevent ) kwargs['channel'] = self.request.sales_channel.identifier + kwargs['customer'] = getattr(self.request, 'customer', None) kwargs.setdefault('initial', {}) if 'var' in self.request.GET: kwargs['initial']['itemvar'] = f'{self.request.GET.get("item")}-{self.request.GET.get("var")}' diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index c31052523..69f5daff0 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -226,8 +226,17 @@ class WidgetAPIProductList(EventListMixin, View): qs = qs.filter(category__pk__in=self.request.GET.get('categories').split(",")) items, display_add_to_cart = get_grouped_items( - self.request.event, subevent=self.subevent, voucher=self.voucher, channel=self.request.sales_channel.identifier, - base_qs=qs + self.request.event, + subevent=self.subevent, + voucher=self.voucher, + channel=self.request.sales_channel.identifier, + base_qs=qs, + memberships=( + self.request.customer.usable_memberships( + for_event=self.subevent or self.request.event, + testmode=self.request.event.testmode + ) if getattr(self.request, 'customer', None) else None + ), ) grps = [] diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index fead27ca8..95e269710 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -288,6 +288,7 @@ TEST_ITEM_RES = { "day": "Tuesday" }, "require_membership": False, + "require_membership_hidden": False, "require_membership_types": [], "grant_membership_type": None, "grant_membership_duration_like_event": True, @@ -376,6 +377,7 @@ def test_item_detail_variations(token_client, organizer, event, team, item): "description": None, "position": 0, "require_membership": False, + "require_membership_hidden": False, "require_membership_types": [], "sales_channels": list(get_all_sales_channels().keys()), "available_from": None, @@ -507,6 +509,7 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego }, "active": True, "require_membership": False, + "require_membership_hidden": False, "require_membership_types": [], "description": None, "position": 0, @@ -1210,6 +1213,7 @@ TEST_VARIATIONS_RES = { "default_price": None, "price": "23.00", "require_membership": False, + "require_membership_hidden": False, "require_membership_types": [], "sales_channels": list(get_all_sales_channels().keys()), "available_from": None, @@ -1227,6 +1231,7 @@ TEST_VARIATIONS_UPDATE = { "position": 1, "default_price": "20.0", "require_membership": False, + "require_membership_hidden": False, "require_membership_types": [], "sales_channels": ["web"], "available_from": None, diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index a048b65c6..bb880c9f5 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -195,6 +195,59 @@ class ItemDisplayTest(EventTestMixin, SoupTest): html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content self.assertNotIn("Early-bird", html) + def test_hidden_without_membership(self): + self.orga.settings.customer_accounts = True + + with scopes_disabled(): + mt = self.orga.membership_types.create(name="foo") + q = Quota.objects.create(event=self.event, name='Quota', size=2) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True, + require_membership=True, require_membership_hidden=True) + item.require_membership_types.add(mt) + q.items.add(item) + customer = self.orga.customers.create(email='john@example.org', is_verified=True, is_active=True) + customer.set_password('foo') + customer.save() + + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content + self.assertNotIn("Early-bird", html) + + r = self.client.post('/%s/account/login' % self.orga.slug, { + 'email': 'john@example.org', + 'password': 'foo', + }) + assert r.status_code == 302 + + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content + self.assertNotIn("Early-bird", html) + + with scopes_disabled(): + m = customer.memberships.create( + membership_type=mt, + date_start=self.event.date_from - datetime.timedelta(days=5), + date_end=self.event.date_from + datetime.timedelta(days=5), + ) + + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content + self.assertIn("Early-bird", html) + + m.canceled = True + m.save() + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content + self.assertNotIn("Early-bird", html) + + m.canceled = False + m.testmode = True + m.save() + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content + self.assertNotIn("Early-bird", html) + + m.testmode = False + m.date_end = m.date_start + m.save() + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content + self.assertNotIn("Early-bird", html) + def test_simple_with_category(self): with scopes_disabled(): c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0)