diff --git a/doc/api/resources/memberships.rst b/doc/api/resources/memberships.rst index 07dbe83ce..0f28bd59e 100644 --- a/doc/api/resources/memberships.rst +++ b/doc/api/resources/memberships.rst @@ -13,6 +13,7 @@ Field Type Description ===================================== ========================== ======================================================= id integer Internal ID of the membership customer string Identifier of the customer associated with this membership (can't be changed) +testmode boolean Whether this is a test membership membership_type integer Internal ID of the membership type date_start datetime Start of validity date_end datetime End of validity @@ -51,6 +52,7 @@ Endpoints "id": 2, "customer": "EGR9SYT", "membership_type": 1, + "testmode": false, "date_start": "2021-04-19T00:00:00+02:00", "date_end": "2021-04-20T00:00:00+02:00", "attendee_name_parts": { @@ -66,6 +68,7 @@ Endpoints :query integer page: The page number in case of a multi-page result set, default is 1 :query string customer: A customer identifier to filter for :query integer membership_type: A membership type ID to filter for + :query boolean testmode: Filter for memberships that are (not) in test mode. :param organizer: The ``slug`` field of the organizer to fetch :statuscode 200: no error :statuscode 401: Authentication failure @@ -95,6 +98,7 @@ Endpoints "id": 2, "customer": "EGR9SYT", "membership_type": 1, + "testmode": false, "date_start": "2021-04-19T00:00:00+02:00", "date_end": "2021-04-20T00:00:00+02:00", "attendee_name_parts": { @@ -127,6 +131,7 @@ Endpoints { "membership_type": 2, "customer": "EGR9SYT", + "testmode": false, "date_start": "2021-04-19T00:00:00+02:00", "date_end": "2021-04-20T00:00:00+02:00", "attendee_name_parts": { @@ -149,6 +154,7 @@ Endpoints "id": 3, "membership_type": 2, "customer": "EGR9SYT", + "testmode": false, "date_start": "2021-04-19T00:00:00+02:00", "date_end": "2021-04-20T00:00:00+02:00", "attendee_name_parts": { @@ -171,7 +177,7 @@ Endpoints the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you want to change. - You can change all fields of the resource except the ``id`` and ``customer`` fields. + You can change all fields of the resource except the ``id``, ``customer``, and ``testmode`` fields. **Example request**: diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index fb56f4f6b..b207bbc57 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -87,7 +87,7 @@ class MembershipSerializer(I18nAwareModelSerializer): class Meta: model = Membership - fields = ('id', 'customer', 'membership_type', 'date_start', 'date_end', 'attendee_name_parts') + fields = ('id', 'testmode', 'customer', 'membership_type', 'date_start', 'date_end', 'attendee_name_parts') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -96,6 +96,7 @@ class MembershipSerializer(I18nAwareModelSerializer): def update(self, instance, validated_data): validated_data['customer'] = instance.customer # no modifying + validated_data['testmode'] = instance.testmode # no modifying return super().update(instance, validated_data) diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index 9fd724a42..eae5fccf5 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -598,7 +598,7 @@ with scopes_disabled(): class Meta: model = Membership - fields = ['customer', 'membership_type'] + fields = ['customer', 'membership_type', 'testmode'] class MembershipViewSet(viewsets.ModelViewSet): diff --git a/src/pretix/base/migrations/0189_auto_20210525_1311.py b/src/pretix/base/migrations/0189_auto_20210525_1311.py new file mode 100644 index 000000000..82d3ed227 --- /dev/null +++ b/src/pretix/base/migrations/0189_auto_20210525_1311.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.3 on 2021-05-25 13:11 + +from django.db import migrations, models + +import pretix.base.models.event +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0188_delete_requiredaction'), + ] + + operations = [ + migrations.AddField( + model_name='membership', + name='testmode', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='event', + name='sales_channels', + field=pretix.base.models.fields.MultiStringField(default=pretix.base.models.event.default_sales_channels), + ), + ] diff --git a/src/pretix/base/models/memberships.py b/src/pretix/base/models/memberships.py index f72880227..c47296eff 100644 --- a/src/pretix/base/models/memberships.py +++ b/src/pretix/base/models/memberships.py @@ -114,6 +114,10 @@ class MembershipQuerySetManager(ScopedManager(organizer='customer__organizer')._ class Membership(models.Model): id = models.BigAutoField(primary_key=True) + testmode = models.BooleanField( + verbose_name=_('Test mode'), + default=False + ) customer = models.ForeignKey( Customer, related_name='memberships', @@ -168,3 +172,6 @@ class Membership(models.Model): dt = now() return 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/base/models/orders.py b/src/pretix/base/models/orders.py index 5ea12f70d..582736810 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -258,7 +258,7 @@ class Order(LockModel, LoggedModel): return self.full_code def gracefully_delete(self, user=None, auth=None): - from . import GiftCard, GiftCardTransaction, Voucher + from . import GiftCard, GiftCardTransaction, Membership, Voucher if not self.testmode: raise TypeError("Only test mode orders can be deleted.") @@ -280,6 +280,7 @@ class Order(LockModel, LoggedModel): GiftCardTransaction.objects.filter(refund__in=self.refunds.all()).update(refund=None) GiftCardTransaction.objects.filter(order=self).update(order=None) GiftCard.objects.filter(issued_in__in=self.positions.all()).update(issued_in=None) + Membership.objects.filter(granted_in__order=self, testmode=True).update(granted_in=None) OrderPosition.all.filter(order=self, addon_to__isnull=False).delete() OrderPosition.all.filter(order=self).delete() OrderFee.all.filter(order=self).delete() @@ -846,7 +847,7 @@ class Order(LockModel, LoggedModel): try: if check_memberships: try: - validate_memberships_in_order(self.customer, positions, self.event, lock=False) + validate_memberships_in_order(self.customer, positions, self.event, lock=False, testmode=self.testmode) except ValidationError as e: raise Quota.QuotaExceededException(e.message) diff --git a/src/pretix/base/services/memberships.py b/src/pretix/base/services/memberships.py index 1f18bb8f9..49d2a4e0b 100644 --- a/src/pretix/base/services/memberships.py +++ b/src/pretix/base/services/memberships.py @@ -76,11 +76,12 @@ def create_membership(customer: Customer, position: OrderPosition): granted_in=position, date_start=date_start, date_end=date_end, - attendee_name_parts=position.attendee_name_parts + attendee_name_parts=position.attendee_name_parts, + testmode=position.order.testmode, ) -def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None): +def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False): """ Validate that a set of cart or order positions. This currently does not validate @@ -89,6 +90,7 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo :param event: Event this all is computed in :param lock: Whether to place a SELECT FOR UPDATE lock on the selected memberships :param ignored_order: An order that should be ignored for usage counting + :param testmode: If ``True``, only test mode memberships are allowed. If ``False``, test mode memberships are not allowed. """ tz = event.timezone applicable_positions = [ @@ -139,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.testmode != testmode: + raise ValidationError( + _('You can only use a test mode membership for test mode tickets.') + ) + ev = p.subevent or event if not m.is_valid(ev): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 9a4f38d71..d4596e092 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -817,7 +817,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d raise OrderError(_("You cannot pay with gift cards when buying a gift card.")) try: - validate_memberships_in_order(customer, positions, event, lock=True) + validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode) except ValidationError as e: raise OrderError(e.message) @@ -2063,7 +2063,7 @@ class OrderChangeManager: ) fake_cart.append(cp) try: - validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order) + validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode) except ValidationError as e: raise OrderError(e.message) diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index ad10d2b41..5363f053b 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 = ['membership_type', 'date_start', 'date_end', 'attendee_name_parts'] + fields = ['testmode', 'membership_type', 'date_start', 'date_end', 'attendee_name_parts'] field_classes = { 'date_start': SplitDateTimeField, 'date_end': SplitDateTimeField, @@ -573,6 +573,9 @@ class MembershipUpdateForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + del self.fields['testmode'] + self.fields['membership_type'].queryset = self.instance.customer.organizer.membership_types.all() self.fields['attendee_name_parts'] = NamePartsFormField( max_length=255, diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 96886de2b..b021b710c 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -325,6 +325,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.customer.changed': _('The account has been changed.'), 'pretix.customer.membership.created': _('A membership for this account has been added.'), 'pretix.customer.membership.changed': _('A membership of this account has been changed.'), + 'pretix.customer.membership.deleted': _('A membership of this account has been deleted.'), 'pretix.customer.anonymized': _('The account has been disabled and anonymized.'), 'pretix.customer.password.resetrequested': _('A new password has been requested.'), 'pretix.customer.password.set': _('A new password has been set.'), diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customer.html b/src/pretix/control/templates/pretixcontrol/organizers/customer.html index 447d321f8..3767e8157 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/customer.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/customer.html @@ -82,6 +82,7 @@ {{ m.membership_type.name }} + {% if m.testmode %}{% trans "TEST MODE" %}{% endif %} {{ m.date_start|date:"SHORT_DATETIME_FORMAT" }} @@ -118,6 +119,14 @@ class="btn btn-default"> + {% if m.testmode %} + + + + {% endif %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customer_membership.html b/src/pretix/control/templates/pretixcontrol/organizers/customer_membership.html index ab4c90857..76af8223b 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/customer_membership.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/customer_membership.html @@ -7,6 +7,7 @@ {% block inner %}

{% trans "Membership" %} + {% if membership.testmode %}{% trans "TEST MODE" %}{% endif %}

diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customer_membership_delete.html b/src/pretix/control/templates/pretixcontrol/organizers/customer_membership_delete.html new file mode 100644 index 000000000..a224ee14a --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/customer_membership_delete.html @@ -0,0 +1,31 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %} + {% trans "Membership" %} +{% endblock %} +{% block inner %} +

+ {% trans "Membership" %} + {% if membership.testmode %}{% trans "TEST MODE" %}{% endif %} +

+
+ {% csrf_token %} + {% if is_allowed %} +

{% blocktrans %}Are you sure you want to delete this membership?{% endblocktrans %} + {% else %} +

{% blocktrans %}This membership cannot be deleted since it has been used in an order. Change its end date to the past instead.{% endblocktrans %} + {% endif %} +

+
+ + {% trans "Cancel" %} + + {% if is_allowed %} + + {% endif %} +
+
+{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 58c8563ba..782535a0f 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -141,6 +141,8 @@ urlpatterns = [ organizer.MembershipCreateView.as_view(), name='organizer.customer.membership.add'), re_path(r'^organizer/(?P[^/]+)/customer/(?P[^/]+)/membership/(?P[^/]+)/edit$', organizer.MembershipUpdateView.as_view(), name='organizer.customer.membership.edit'), + re_path(r'^organizer/(?P[^/]+)/customer/(?P[^/]+)/membership/(?P[^/]+)/delete$', + organizer.MembershipDeleteView.as_view(), name='organizer.customer.membership.delete'), re_path(r'^organizer/(?P[^/]+)/customer/(?P[^/]+)/anonymize$', organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'), re_path(r'^organizer/(?P[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 1a208a6b5..c46311a60 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -535,6 +535,7 @@ class OrderDelete(OrderView): 'organizer': self.request.organizer.slug, })) except ProtectedError: + logger.exception('Could not delete order') messages.error(self.request, _('The order could not be deleted as some constraints (e.g. data created ' 'by plug-ins) do not allow it.')) return self.get(self.request, *self.args, **self.kwargs) diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 32129e23e..6c65ba95d 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -1887,6 +1887,45 @@ class MembershipUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequired }) +class MembershipDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView): + 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( + Membership, + customer__organizer=self.request.organizer, + customer__identifier=self.kwargs.get('customer'), + testmode=True, + pk=self.kwargs.get('id') + ) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['is_allowed'] = self.object.allow_delete() + return ctx + + @transaction.atomic + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + self.customer = self.object.customer + success_url = self.get_success_url() + if self.object.allow_delete(): + self.object.cartposition_set.all().delete() + self.object.customer.log_action('pretix.customer.membership.deleted', user=self.request.user) + self.object.delete() + messages.success(request, _('The selected object has been deleted.')) + return redirect(success_url) + + def get_success_url(self): + return reverse('control:organizer.customer', kwargs={ + 'organizer': self.request.organizer.slug, + 'customer': self.customer.identifier, + }) + + class MembershipCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): template_name = 'pretixcontrol/organizers/customer_membership.html' permission = 'can_manage_customers' diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index a9cc5bd49..ae3cf4f1a 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -395,7 +395,7 @@ class MembershipStep(QuestionsViewMixin, 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) + validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False, testmode=self.request.event.testmode) except ValidationError as e: messages.error(self.request, e.message) self.render() diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index b0113e368..dc1546d53 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -227,11 +227,13 @@ class MembershipForm(forms.Form): usages = f'({obj.usages} / {obj.membership_type.max_usages})' else: usages = '' - return mark_safe( - f'{escape(obj.membership_type)} {usages}
' - f'{escape(obj.attendee_name)}
' - f'{ds} – {de}' - ) + d = f'{escape(obj.membership_type)} {usages}
' + if obj.attendee_name: + d += f'{escape(obj.attendee_name)}
' + d += f'{ds} – {de}' + if obj.testmode: + d += ' {}'.format(_("TEST MODE")) + return mark_safe(d) def clean(self): d = super().clean() diff --git a/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html index d1b7b29b8..6dd8f02d5 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/customer_profile.html @@ -59,6 +59,7 @@ {{ m.membership_type.name }} + {% if m.testmode %}{% trans "TEST MODE" %}{% endif %} {{ m.date_start|date:"SHORT_DATETIME_FORMAT" }} diff --git a/src/tests/api/test_membership.py b/src/tests/api/test_membership.py index faf511534..33bef9346 100644 --- a/src/tests/api/test_membership.py +++ b/src/tests/api/test_membership.py @@ -68,6 +68,7 @@ TEST_MEMBERSHIP_RES = { "customer": "8WSAJCJ", "date_start": "2021-04-01T00:00:00Z", "date_end": "2021-04-08T23:59:59.999999Z", + "testmode": False, "attendee_name_parts": { "_scheme": "given_family", 'given_name': 'John', @@ -136,11 +137,13 @@ def test_membership_patch(token_client, organizer, customer, membership): format='json', data={ "customer": other_customer.identifier, + "testmode": True, } ) assert resp.status_code == 200 membership.refresh_from_db() assert membership.customer == customer # change is ignored + assert not membership.testmode # change is ignored @pytest.mark.django_db diff --git a/src/tests/base/test_memberships.py b/src/tests/base/test_memberships.py index 616d8dd89..5bbf20a61 100644 --- a/src/tests/base/test_memberships.py +++ b/src/tests/base/test_memberships.py @@ -227,6 +227,44 @@ 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_test_mode(event, customer, membership, requiring_ticket, membership_type): + with pytest.raises(ValidationError) as excinfo: + membership.testmode = True + membership.save() + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership + ) + ], + event, + lock=False, + ignored_order=None, + testmode=False, + ) + assert "test mode" in str(excinfo.value) + with pytest.raises(ValidationError) as excinfo: + membership.testmode = False + membership.save() + validate_memberships_in_order( + customer, + [ + CartPosition( + item=requiring_ticket, + used_membership=membership + ) + ], + event, + lock=False, + ignored_order=None, + testmode=True, + ) + assert "test mode" in str(excinfo.value) + + @pytest.mark.django_db def test_validate_membership_wrong_customer(event, customer, membership, requiring_ticket, membership_type): customer2 = event.organizer.customers.create(email="doe@example.org")