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
|