From 1ef076bb9bfcf8e4084fcac80705df5dfbd42b6e Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Jun 2021 18:10:45 +0200 Subject: [PATCH] Deal with cancelling memberships (#2130) --- .../migrations/0194_membership_canceled.py | 18 +++++++ src/pretix/base/models/memberships.py | 4 ++ src/pretix/base/models/orders.py | 2 + src/pretix/base/services/memberships.py | 5 ++ src/pretix/base/services/orders.py | 27 ++++++++++ src/pretix/control/forms/organizer.py | 2 +- .../pretixcontrol/organizers/customer.html | 2 + src/pretix/control/views/organizer.py | 1 - src/pretix/presale/checkoutflow.py | 1 + .../organizers/customer_profile.html | 2 + src/tests/base/test_memberships.py | 21 ++++++++ src/tests/base/test_models.py | 21 ++++++++ src/tests/base/test_orders.py | 49 +++++++++++++++++++ 13 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 src/pretix/base/migrations/0194_membership_canceled.py diff --git a/src/pretix/base/migrations/0194_membership_canceled.py b/src/pretix/base/migrations/0194_membership_canceled.py new file mode 100644 index 0000000000..ee930940ed --- /dev/null +++ b/src/pretix/base/migrations/0194_membership_canceled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.3 on 2021-06-17 10:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0193_auto_20210611_1355'), + ] + + operations = [ + migrations.AddField( + model_name='membership', + name='canceled', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/memberships.py b/src/pretix/base/models/memberships.py index c47296eff5..e851f3fee5 100644 --- a/src/pretix/base/models/memberships.py +++ b/src/pretix/base/models/memberships.py @@ -118,6 +118,10 @@ class Membership(models.Model): verbose_name=_('Test mode'), default=False ) + canceled = models.BooleanField( + verbose_name=_('Canceled'), + default=False + ) customer = models.ForeignKey( Customer, related_name='memberships', diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index cc8c34c720..a46f7844cc 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -599,6 +599,8 @@ class Order(LockModel, LoggedModel): for gc in op.issued_gift_cards.all(): if gc.value != op.price: return False + if op.granted_memberships.with_usages().filter(usages__gt=0): + return False if self.user_cancel_deadline and now() > self.user_cancel_deadline: return False if self.status == Order.STATUS_PENDING: diff --git a/src/pretix/base/services/memberships.py b/src/pretix/base/services/memberships.py index 49d2a4e0bd..7ac480eeda 100644 --- a/src/pretix/base/services/memberships.py +++ b/src/pretix/base/services/memberships.py @@ -141,6 +141,11 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo _('You selected a membership that is connected to a different customer account.') ) + if m.canceled: + raise ValidationError( + _('You selected membership that has been canceled.') + ) + if m.testmode != testmode: raise ValidationError( _('You can only use a test mode membership for test mode tickets.') diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 2ff5bcaafd..5e5e462f4e 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -177,6 +177,10 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None gc = GiftCard.objects.select_for_update().get(pk=gc.pk) gc.transactions.create(value=position.price, order=order) break + + for m in position.granted_memberships.all(): + m.canceled = False + m.save() else: raise OrderError(is_available) @@ -410,6 +414,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device else: gc.transactions.create(value=-position.price, order=order) + for m in position.granted_memberships.all(): + m.canceled = True + m.save() + if cancellation_fee: with order.event.lock(): for position in order.positions.all(): @@ -1768,7 +1776,26 @@ class OrderChangeManager: else: gc.transactions.create(value=-op.position.price, order=self.order) + for m in op.position.granted_memberships.with_usages().all(): + m.canceled = True + m.save() + for opa in op.position.addons.all(): + for gc in opa.issued_gift_cards.all(): + gc = GiftCard.objects.select_for_update().get(pk=gc.pk) + if gc.value < opa.position.price: + raise OrderError(_( + 'A position can not be canceled since the gift card {card} purchased in this order has ' + 'already been redeemed.').format( + card=gc.secret + )) + else: + gc.transactions.create(value=-opa.position.price, order=self.order) + + for m in opa.granted_memberships.with_usages().all(): + m.canceled = True + m.save() + self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ 'position': opa.pk, 'positionid': opa.positionid, diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 5363f053be..6317aa12e6 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -560,7 +560,7 @@ class MembershipUpdateForm(forms.ModelForm): class Meta: model = Membership - fields = ['testmode', 'membership_type', 'date_start', 'date_end', 'attendee_name_parts'] + fields = ['testmode', 'membership_type', 'date_start', 'date_end', 'attendee_name_parts', 'canceled'] field_classes = { 'date_start': SplitDateTimeField, 'date_end': SplitDateTimeField, diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customer.html b/src/pretix/control/templates/pretixcontrol/organizers/customer.html index 3767e81576..51a39d75d3 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/customer.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/customer.html @@ -81,7 +81,9 @@ {% for m in memberships %} + {% if m.canceled %}{% endif %} {{ m.membership_type.name }} + {% if m.canceled %}{% endif %} {% if m.testmode %}{% trans "TEST MODE" %}{% endif %} diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 6c65ba95d0..9eb76cc68f 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -1891,7 +1891,6 @@ class MembershipDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequired template_name = 'pretixcontrol/organizers/customer_membership_delete.html' permission = 'can_manage_customers' context_object_name = 'membership' - form_class = MembershipUpdateForm def get_object(self, queryset=None): return get_object_or_404( diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 7595c9b332..5b8ab0bdd4 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -376,6 +376,7 @@ class MembershipStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): memberships = list(self.cart_customer.memberships.with_usages().filter( Q(Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages'))), + canceled=False ).select_related('membership_type')) for p in self.applicable_positions: diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html index 6dd8f02d57..452cd1b5ce 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html @@ -58,7 +58,9 @@ {% for m in memberships %} + {% if m.canceled %}{% endif %} {{ m.membership_type.name }} + {% if m.canceled %}{% endif %} {% if m.testmode %}{% trans "TEST MODE" %}{% endif %} diff --git a/src/tests/base/test_memberships.py b/src/tests/base/test_memberships.py index 5bbf20a61e..ee61e373db 100644 --- a/src/tests/base/test_memberships.py +++ b/src/tests/base/test_memberships.py @@ -227,6 +227,27 @@ def test_validate_membership_ensure_locking(event, customer, membership, requiri assert any('FOR UPDATE' in s['sql'] for s in captured) +@pytest.mark.django_db +def test_validate_membership_canceled(event, customer, membership, requiring_ticket, membership_type): + with pytest.raises(ValidationError) as excinfo: + membership.canceled = True + membership.save() + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership + ) + ], + event, + lock=False, + ignored_order=None, + testmode=False, + ) + assert "canceled" in str(excinfo.value) + + @pytest.mark.django_db def test_validate_membership_test_mode(event, customer, membership, requiring_ticket, membership_type): with pytest.raises(ValidationError) as excinfo: diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index df604d60a8..35c8a7a5e2 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -1233,6 +1233,27 @@ class OrderTestCase(BaseQuotaTestCase): ) assert not self.order.user_cancel_allowed + @classscope(attr='o') + def test_can_cancel_order_with_membership(self): + mt = self.event.organizer.membership_types.create(name="foo") + customer = self.event.organizer.customers.create() + self.order.customer = customer + self.order.save() + item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True, issue_giftcard=True) + p = OrderPosition.objects.create(order=self.order, item=item1, + variation=None, price=23) + m = customer.memberships.create( + membership_type=mt, + date_start=now(), + date_end=now(), + granted_in=p, + ) + # yeah, doesn't really make sense on same order, but good enough for the test + OrderPosition.objects.create(order=self.order, item=item1, + variation=None, price=23, used_membership=m) + assert not self.order.user_cancel_allowed + @classscope(attr='o') def test_can_cancel_order_free(self): self.order.status = Order.STATUS_PAID diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index ce93a369d3..3b077584ee 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -1166,6 +1166,23 @@ class OrderChangeManagerTests(TestCase): self.ocm.commit() assert gc.value == Decimal('0.00') + @classscope(attr='o') + def test_cancel_issued_membership(self): + mt = self.event.organizer.membership_types.create(name="foo") + customer = self.event.organizer.customers.create() + self.order.customer = customer + self.o.save() + m = customer.memberships.create( + membership_type=mt, + date_start=now(), + date_end=now(), + granted_in=self.order.positions.first(), + ) + self.ocm.cancel(self.op1) + self.ocm.commit() + m.refresh_from_db() + assert m.canceled + @classscope(attr='o') def test_cancel_issued_giftcard_used(self): gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1) @@ -2419,6 +2436,21 @@ class OrderChangeManagerTests(TestCase): op.refresh_from_db() assert op.secret != s + @classscope(attr='o') + def test_cancel_order_membership(self): + mt = self.event.organizer.membership_types.create(name="foo") + customer = self.event.organizer.customers.create() + self.order.customer = customer + m = customer.memberships.create( + membership_type=mt, + date_start=now(), + date_end=now(), + granted_in=self.order.positions.first(), + ) + cancel_order(self.order) + m.refresh_from_db() + assert m.canceled + @classscope(attr='o') def test_auto_change_payment_fee(self): fee2 = self.order.fees.create(fee_type=OrderFee.FEE_TYPE_SHIPPING, value=Decimal('0.50')) @@ -3019,3 +3051,20 @@ class OrderReactivateTest(TestCase): gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1) reactivate_order(self.order) assert gc.value == 23 + + @classscope(attr='o') + def test_reactivate_membership(self): + mt = self.event.organizer.membership_types.create(name="foo") + customer = self.event.organizer.customers.create() + self.order.customer = customer + self.order.save() + m = customer.memberships.create( + membership_type=mt, + date_start=now(), + date_end=now(), + granted_in=self.order.positions.first(), + canceled=True, + ) + reactivate_order(self.order) + m.refresh_from_db() + assert not m.canceled