diff --git a/src/pretix/base/models/memberships.py b/src/pretix/base/models/memberships.py index 864011f748..6cdcf20cae 100644 --- a/src/pretix/base/models/memberships.py +++ b/src/pretix/base/models/memberships.py @@ -49,7 +49,8 @@ class MembershipType(LoggedModel): allow_parallel_usage = models.BooleanField( verbose_name=_('Parallel usage is allowed'), help_text=_('If this is selected, the membership can be used to purchase tickets for events happening at the same time. Note ' - 'that this will only check for an identical start time of the events, not for any overlap between events.'), + 'that this will only check for an identical start time of the events, not for any overlap between events. An overlap ' + 'check will be performed if there is a product-level validity of the ticket.'), default=False ) max_usages = models.PositiveIntegerField( diff --git a/src/pretix/base/services/memberships.py b/src/pretix/base/services/memberships.py index ec456ea834..a66130a13c 100644 --- a/src/pretix/base/services/memberships.py +++ b/src/pretix/base/services/memberships.py @@ -29,8 +29,8 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from pretix.base.models import ( - AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition, - SubEvent, + AbstractPosition, CartPosition, Customer, Event, Item, Membership, Order, + OrderPosition, SubEvent, ) from pretix.helpers import OF_SELF @@ -82,7 +82,8 @@ 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): +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 @@ -132,7 +133,11 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo qs = qs.exclude(order_id=ignored_order.pk) m._used_at_dates = [ (op.subevent or op.order.event).date_from - for op in qs + for op in qs if not op.valid_from or not op.valid_until + ] + m._used_for_ranges = [ + (op.valid_from, op.valid_until) + for op in qs if op.valid_from or op.valid_until ] for p in applicable_positions: @@ -192,13 +197,42 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo m.usages += 1 if not m.membership_type.allow_parallel_usage: - df = ev.date_from - if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates): - raise ValidationError( - _('You are trying to use a membership of type "{type}" for an event taking place at {date}, ' - 'however you already used the same membership for a different ticket at the same time.').format( - type=m.membership_type.name, - date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'), + if isinstance(p, (OrderPosition, CartPosition)): + # override_ variants are for usage of fake cart in OrderChangeManager + valid_from = getattr(p, 'override_valid_from', p.valid_from) + valid_until = getattr(p, 'override_valid_until', p.valid_until) + else: # future safety, not technically defined on AbstractPosition + valid_from = None + valid_until = None + + if (valid_from or valid_until) and not (p.item.validity_dynamic_start_choice and valid_from_not_chosen): + for used_range in m._used_for_ranges: + if valid_from and valid_from > used_range[1]: + continue + if valid_until and valid_until < used_range[0]: + continue + raise ValidationError( + _('You are trying to use a membership of type "{type}" for a ticket valid from {valid_from} ' + 'until {valid_until}, however you already used the same membership for a different ticket ' + 'that overlaps with this time frame ({conflict_from} – {conflict_until}).').format( + type=m.membership_type.name, + valid_from=date_format(valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_from else _('start'), + valid_until=date_format(valid_until.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_until else _('open end'), + conflict_from=date_format(used_range[0].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[0] else _('start'), + conflict_until=date_format(used_range[1].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[1] else _('open end'), + ) ) - ) - m._used_at_dates.append(ev.date_from) + + m._used_for_ranges.append((p.valid_from, p.valid_until)) + + if not valid_from or not valid_until: + df = ev.date_from + if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates): + raise ValidationError( + _('You are trying to use a membership of type "{type}" for an event taking place at {date}, ' + 'however you already used the same membership for a different ticket at the same time.').format( + type=m.membership_type.name, + date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'), + ) + ) + m._used_at_dates.append(ev.date_from) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index a1c49cd39d..3990471256 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -2671,6 +2671,7 @@ class OrderChangeManager: for p in self.order.positions.all(): cp = CartPosition( + event=self.event, item=p.item, variation=p.variation, attendee_name_parts=p.attendee_name_parts, @@ -2691,16 +2692,23 @@ class OrderChangeManager: positions_to_fake_cart[op.position].seat = op.seat elif isinstance(op, self.MembershipOperation): positions_to_fake_cart[op.position].used_membership = op.membership + elif isinstance(op, self.ChangeValidFromOperation): + positions_to_fake_cart[op.position].override_valid_from = op.valid_from + elif isinstance(op, self.ChangeValidUntilOperation): + positions_to_fake_cart[op.position].override_valid_until = op.valid_until elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart: fake_cart.remove(positions_to_fake_cart[op.position]) elif isinstance(op, self.AddOperation): cp = CartPosition( + event=self.event, item=op.item, variation=op.variation, used_membership=op.membership, subevent=op.subevent, seat=op.seat, ) + cp.override_valid_from = op.valid_from + cp.override_valid_until = op.valid_until fake_cart.append(cp) try: validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 748d0457f5..da7d3aeab0 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -445,10 +445,11 @@ class MembershipStep(CartMixin, TemplateFlowStep): f.position.used_membership = f.cleaned_data['membership'] try: - validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False, testmode=self.request.event.testmode) + validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False, testmode=self.request.event.testmode, + valid_from_not_chosen=True) except ValidationError as e: messages.error(self.request, e.message) - self.render() + return self.render() else: for f in self.forms: f.position.save(update_fields=['used_membership']) @@ -934,6 +935,13 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): 'changed accordingly.')) return redirect_to_url(self.get_next_url(request) + '?open_cart=true') + try: + validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False, + testmode=self.request.event.testmode, valid_from_not_chosen=False) + except ValidationError as e: + messages.error(self.request, e.message) + return self.render() + return redirect_to_url(self.get_next_url(request)) def is_completed(self, request, warn=False): diff --git a/src/tests/base/test_memberships.py b/src/tests/base/test_memberships.py index 5650b9af74..9cb6ac0d0e 100644 --- a/src/tests/base/test_memberships.py +++ b/src/tests/base/test_memberships.py @@ -215,6 +215,7 @@ def test_validate_membership_ensure_locking(event, customer, membership, requiri customer, [ CartPosition( + event=event, item=requiring_ticket, used_membership=membership, ) @@ -449,6 +450,7 @@ def test_validate_membership_parallel(event, customer, membership, subevent, req customer, [ CartPosition( + event=event, item=requiring_ticket, used_membership=membership, subevent=subevent @@ -464,6 +466,7 @@ def test_validate_membership_parallel(event, customer, membership, subevent, req customer, [ CartPosition( + event=event, item=requiring_ticket, used_membership=membership, subevent=se2 @@ -479,11 +482,13 @@ def test_validate_membership_parallel(event, customer, membership, subevent, req customer, [ CartPosition( + event=event, item=requiring_ticket, used_membership=membership, subevent=se2 ), CartPosition( + event=event, item=requiring_ticket, used_membership=membership, subevent=se2 @@ -501,6 +506,7 @@ def test_validate_membership_parallel(event, customer, membership, subevent, req customer, [ CartPosition( + event=event, item=requiring_ticket, used_membership=membership, subevent=subevent @@ -512,6 +518,205 @@ def test_validate_membership_parallel(event, customer, membership, subevent, req ) +@pytest.mark.django_db +def test_validate_membership_parallel_validity_dynamic(event, customer, membership, requiring_ticket, membership_type): + requiring_ticket.validity_mode = Item.VALIDITY_MODE_DYNAMIC + requiring_ticket.validity_dynamic_start_choice = False + requiring_ticket.validity_dynamic_duration_days = 1 + requiring_ticket.save() + + membership_type.allow_parallel_usage = False + membership_type.save() + + o1 = Order.objects.create( + status=Order.STATUS_PENDING, + event=event, + email='admin@localhost', + datetime=now() - timedelta(days=3), + expires=now() + timedelta(days=11), + total=Decimal("23"), + ) + OrderPosition.objects.create( + order=o1, + item=requiring_ticket, + used_membership=membership, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"}, + valid_from=now(), + valid_until=now().replace(hour=23, minute=59, second=59), + ) + + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + event=event, + item=requiring_ticket, + used_membership=membership, + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "different ticket that overlaps" in str(excinfo.value) + + requiring_ticket.validity_dynamic_start_choice = True + requiring_ticket.save() + validate_memberships_in_order( + customer, + [ + CartPosition( + event=event, + item=requiring_ticket, + used_membership=membership, + ) + ], + event, + lock=False, + ignored_order=None, + valid_from_not_chosen=True + ) + + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + event=event, + item=requiring_ticket, + used_membership=membership, + ) + ], + event, + lock=False, + ignored_order=None, + valid_from_not_chosen=False + ) + assert "different ticket that overlaps" in str(excinfo.value) + + validate_memberships_in_order( + customer, + [ + CartPosition( + event=event, + item=requiring_ticket, + used_membership=membership, + requested_valid_from=now() + timedelta(days=1) + ) + ], + event, + lock=False, + ignored_order=None, + ) + + membership_type.allow_parallel_usage = True + membership_type.save() + validate_memberships_in_order( + customer, + [ + CartPosition( + event=event, + item=requiring_ticket, + used_membership=membership, + ) + ], + event, + lock=False, + ignored_order=None + ) + + +@pytest.mark.django_db +def test_validate_membership_parallel_validity_fixed(event, customer, membership, requiring_ticket, membership_type): + requiring_ticket.validity_mode = Item.VALIDITY_MODE_FIXED + requiring_ticket.validity_fixed_from = now().replace(hour=2, minute=20, second=0) + requiring_ticket.validity_fixed_until = now().replace(hour=6, minute=20, second=0) + requiring_ticket.save() + + membership_type.allow_parallel_usage = False + membership_type.save() + + o1 = Order.objects.create( + status=Order.STATUS_PENDING, + event=event, + email='admin@localhost', + datetime=now() - timedelta(days=3), + expires=now() + timedelta(days=11), + total=Decimal("23"), + ) + OrderPosition.objects.create( + order=o1, + item=requiring_ticket, + used_membership=membership, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"}, + valid_from=now().replace(hour=2, minute=20, second=0), + valid_until=now().replace(hour=6, minute=20, second=0), + ) + + requiring_ticket.validity_fixed_from = now().replace(hour=5, minute=20, second=0) + requiring_ticket.validity_fixed_until = now().replace(hour=8, minute=20, second=0) + requiring_ticket.save() + + with pytest.raises(ValidationError) as excinfo: + validate_memberships_in_order( + customer, + [ + CartPosition( + event=event, + item=requiring_ticket, + used_membership=membership, + ) + ], + event, + lock=False, + ignored_order=None + ) + assert "different ticket that overlaps" in str(excinfo.value) + + requiring_ticket.validity_fixed_from = now().replace(hour=6, minute=20, second=1) + requiring_ticket.validity_fixed_until = now().replace(hour=8, minute=20, second=0) + requiring_ticket.save() + + validate_memberships_in_order( + customer, + [ + CartPosition( + event=event, + item=requiring_ticket, + used_membership=membership, + ) + ], + event, + lock=False, + ignored_order=None, + valid_from_not_chosen=True + ) + + requiring_ticket.validity_fixed_from = now().replace(hour=0, minute=20, second=0) + requiring_ticket.validity_fixed_until = now().replace(hour=1, minute=20, second=0) + requiring_ticket.save() + + validate_memberships_in_order( + customer, + [ + CartPosition( + event=event, + item=requiring_ticket, + used_membership=membership, + ) + ], + event, + lock=False, + ignored_order=None, + valid_from_not_chosen=True + ) + + @pytest.mark.django_db def test_use_membership(event, customer, membership, requiring_ticket): cp1 = CartPosition.objects.create(