diff --git a/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst
index 037ffd98b2..4419bb7b2f 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 9251ddcefa..61b8dbf577 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 c6b07cbb6d..8eb3e05149 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 0000000000..753269dac0
--- /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 6de35f3fdf..9f5007690c 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 0425c96867..5ba5864437 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 e851f3fee5..873a96dcee 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 c3a42b027e..bb5d9797d3 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 befeda1942..6a6de82c55 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 e651f45781..6f29c7d552 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 c256365806..7e9a1ff9f7 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 24060cec16..6bb9e98656 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 4d3e44edbf..278c5923db 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 b0c95e838e..7dcc4c540d 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 cae3a938e2..b5b1a0cada 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 c310525238..69f5daff05 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 fead27ca89..95e269710f 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 a048b65c67..bb880c9f50 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)