Fix #2090 -- Test mode for memberships

This commit is contained in:
Raphael Michel
2021-05-27 11:47:42 +02:00
parent ab30129fc2
commit 49a9b71ea3
21 changed files with 195 additions and 16 deletions
+7 -1
View File
@@ -13,6 +13,7 @@ Field Type Description
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
id integer Internal ID of the membership id integer Internal ID of the membership
customer string Identifier of the customer associated with this membership (can't be changed) 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 membership_type integer Internal ID of the membership type
date_start datetime Start of validity date_start datetime Start of validity
date_end datetime End of validity date_end datetime End of validity
@@ -51,6 +52,7 @@ Endpoints
"id": 2, "id": 2,
"customer": "EGR9SYT", "customer": "EGR9SYT",
"membership_type": 1, "membership_type": 1,
"testmode": false,
"date_start": "2021-04-19T00:00:00+02:00", "date_start": "2021-04-19T00:00:00+02:00",
"date_end": "2021-04-20T00:00:00+02:00", "date_end": "2021-04-20T00:00:00+02:00",
"attendee_name_parts": { "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 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 string customer: A customer identifier to filter for
:query integer membership_type: A membership type ID 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 :param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -95,6 +98,7 @@ Endpoints
"id": 2, "id": 2,
"customer": "EGR9SYT", "customer": "EGR9SYT",
"membership_type": 1, "membership_type": 1,
"testmode": false,
"date_start": "2021-04-19T00:00:00+02:00", "date_start": "2021-04-19T00:00:00+02:00",
"date_end": "2021-04-20T00:00:00+02:00", "date_end": "2021-04-20T00:00:00+02:00",
"attendee_name_parts": { "attendee_name_parts": {
@@ -127,6 +131,7 @@ Endpoints
{ {
"membership_type": 2, "membership_type": 2,
"customer": "EGR9SYT", "customer": "EGR9SYT",
"testmode": false,
"date_start": "2021-04-19T00:00:00+02:00", "date_start": "2021-04-19T00:00:00+02:00",
"date_end": "2021-04-20T00:00:00+02:00", "date_end": "2021-04-20T00:00:00+02:00",
"attendee_name_parts": { "attendee_name_parts": {
@@ -149,6 +154,7 @@ Endpoints
"id": 3, "id": 3,
"membership_type": 2, "membership_type": 2,
"customer": "EGR9SYT", "customer": "EGR9SYT",
"testmode": false,
"date_start": "2021-04-19T00:00:00+02:00", "date_start": "2021-04-19T00:00:00+02:00",
"date_end": "2021-04-20T00:00:00+02:00", "date_end": "2021-04-20T00:00:00+02:00",
"attendee_name_parts": { "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 the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change. 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**: **Example request**:
+2 -1
View File
@@ -87,7 +87,7 @@ class MembershipSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Membership 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -96,6 +96,7 @@ class MembershipSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
validated_data['customer'] = instance.customer # no modifying validated_data['customer'] = instance.customer # no modifying
validated_data['testmode'] = instance.testmode # no modifying
return super().update(instance, validated_data) return super().update(instance, validated_data)
+1 -1
View File
@@ -598,7 +598,7 @@ with scopes_disabled():
class Meta: class Meta:
model = Membership model = Membership
fields = ['customer', 'membership_type'] fields = ['customer', 'membership_type', 'testmode']
class MembershipViewSet(viewsets.ModelViewSet): class MembershipViewSet(viewsets.ModelViewSet):
@@ -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),
),
]
+7
View File
@@ -114,6 +114,10 @@ class MembershipQuerySetManager(ScopedManager(organizer='customer__organizer')._
class Membership(models.Model): class Membership(models.Model):
id = models.BigAutoField(primary_key=True) id = models.BigAutoField(primary_key=True)
testmode = models.BooleanField(
verbose_name=_('Test mode'),
default=False
)
customer = models.ForeignKey( customer = models.ForeignKey(
Customer, Customer,
related_name='memberships', related_name='memberships',
@@ -168,3 +172,6 @@ class Membership(models.Model):
dt = now() dt = now()
return dt >= self.date_start and dt <= self.date_end return dt >= self.date_start and dt <= self.date_end
def allow_delete(self):
return self.testmode and not self.orderposition_set.exists()
+3 -2
View File
@@ -258,7 +258,7 @@ class Order(LockModel, LoggedModel):
return self.full_code return self.full_code
def gracefully_delete(self, user=None, auth=None): def gracefully_delete(self, user=None, auth=None):
from . import GiftCard, GiftCardTransaction, Voucher from . import GiftCard, GiftCardTransaction, Membership, Voucher
if not self.testmode: if not self.testmode:
raise TypeError("Only test mode orders can be deleted.") 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(refund__in=self.refunds.all()).update(refund=None)
GiftCardTransaction.objects.filter(order=self).update(order=None) GiftCardTransaction.objects.filter(order=self).update(order=None)
GiftCard.objects.filter(issued_in__in=self.positions.all()).update(issued_in=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, addon_to__isnull=False).delete()
OrderPosition.all.filter(order=self).delete() OrderPosition.all.filter(order=self).delete()
OrderFee.all.filter(order=self).delete() OrderFee.all.filter(order=self).delete()
@@ -846,7 +847,7 @@ class Order(LockModel, LoggedModel):
try: try:
if check_memberships: if check_memberships:
try: 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: except ValidationError as e:
raise Quota.QuotaExceededException(e.message) raise Quota.QuotaExceededException(e.message)
+9 -2
View File
@@ -76,11 +76,12 @@ def create_membership(customer: Customer, position: OrderPosition):
granted_in=position, granted_in=position,
date_start=date_start, date_start=date_start,
date_end=date_end, 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 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 event: Event this all is computed in
:param lock: Whether to place a SELECT FOR UPDATE lock on the selected memberships :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 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 tz = event.timezone
applicable_positions = [ 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.') _('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 ev = p.subevent or event
if not m.is_valid(ev): if not m.is_valid(ev):
+2 -2
View File
@@ -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.")) raise OrderError(_("You cannot pay with gift cards when buying a gift card."))
try: 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: except ValidationError as e:
raise OrderError(e.message) raise OrderError(e.message)
@@ -2063,7 +2063,7 @@ class OrderChangeManager:
) )
fake_cart.append(cp) fake_cart.append(cp)
try: 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: except ValidationError as e:
raise OrderError(e.message) raise OrderError(e.message)
+4 -1
View File
@@ -560,7 +560,7 @@ class MembershipUpdateForm(forms.ModelForm):
class Meta: class Meta:
model = Membership 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 = { field_classes = {
'date_start': SplitDateTimeField, 'date_start': SplitDateTimeField,
'date_end': SplitDateTimeField, 'date_end': SplitDateTimeField,
@@ -573,6 +573,9 @@ class MembershipUpdateForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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['membership_type'].queryset = self.instance.customer.organizer.membership_types.all()
self.fields['attendee_name_parts'] = NamePartsFormField( self.fields['attendee_name_parts'] = NamePartsFormField(
max_length=255, max_length=255,
+1
View File
@@ -325,6 +325,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.customer.changed': _('The account has been changed.'), 'pretix.customer.changed': _('The account has been changed.'),
'pretix.customer.membership.created': _('A membership for this account has been added.'), '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.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.anonymized': _('The account has been disabled and anonymized.'),
'pretix.customer.password.resetrequested': _('A new password has been requested.'), 'pretix.customer.password.resetrequested': _('A new password has been requested.'),
'pretix.customer.password.set': _('A new password has been set.'), 'pretix.customer.password.set': _('A new password has been set.'),
@@ -82,6 +82,7 @@
<tr> <tr>
<td> <td>
{{ m.membership_type.name }} {{ m.membership_type.name }}
{% if m.testmode %}<span class="label label-warning">{% trans "TEST MODE" %}</span>{% endif %}
</td> </td>
<td> <td>
{{ m.date_start|date:"SHORT_DATETIME_FORMAT" }} {{ m.date_start|date:"SHORT_DATETIME_FORMAT" }}
@@ -118,6 +119,14 @@
class="btn btn-default"> class="btn btn-default">
<i class="fa fa-edit"></i> <i class="fa fa-edit"></i>
</a> </a>
{% if m.testmode %}
<a href="{% url "control:organizer.customer.membership.delete" organizer=request.organizer.slug customer=customer.identifier id=m.pk %}"
data-toggle="tooltip"
title="{% trans "Delete" %}"
class="btn btn-danger">
<i class="fa fa-trash"></i>
</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -7,6 +7,7 @@
{% block inner %} {% block inner %}
<h1> <h1>
{% trans "Membership" %} {% trans "Membership" %}
{% if membership.testmode %}<span class="label label-warning">{% trans "TEST MODE" %}</span>{% endif %}
</h1> </h1>
<div class="panel panel-primary items"> <div class="panel panel-primary items">
<div class="panel-heading"> <div class="panel-heading">
@@ -0,0 +1,31 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% trans "Membership" %}
{% endblock %}
{% block inner %}
<h1>
{% trans "Membership" %}
{% if membership.testmode %}<span class="label label-warning">{% trans "TEST MODE" %}</span>{% endif %}
</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if is_allowed %}
<p>{% blocktrans %}Are you sure you want to delete this membership?{% endblocktrans %}
{% else %}
<p>{% blocktrans %}This membership cannot be deleted since it has been used in an order. Change its end date to the past instead.{% endblocktrans %}
{% endif %}
</p>
<div class="form-group submit-group">
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=membership.customer.identifier %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
{% if is_allowed %}
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
{% endif %}
</div>
</form>
{% endblock %}
+2
View File
@@ -141,6 +141,8 @@ urlpatterns = [
organizer.MembershipCreateView.as_view(), name='organizer.customer.membership.add'), organizer.MembershipCreateView.as_view(), name='organizer.customer.membership.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/membership/(?P<id>[^/]+)/edit$', re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/membership/(?P<id>[^/]+)/edit$',
organizer.MembershipUpdateView.as_view(), name='organizer.customer.membership.edit'), organizer.MembershipUpdateView.as_view(), name='organizer.customer.membership.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/membership/(?P<id>[^/]+)/delete$',
organizer.MembershipDeleteView.as_view(), name='organizer.customer.membership.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/anonymize$', re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/anonymize$',
organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'), organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'), re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'),
+1
View File
@@ -535,6 +535,7 @@ class OrderDelete(OrderView):
'organizer': self.request.organizer.slug, 'organizer': self.request.organizer.slug,
})) }))
except ProtectedError: 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 ' messages.error(self.request, _('The order could not be deleted as some constraints (e.g. data created '
'by plug-ins) do not allow it.')) 'by plug-ins) do not allow it.'))
return self.get(self.request, *self.args, **self.kwargs) return self.get(self.request, *self.args, **self.kwargs)
+39
View File
@@ -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): class MembershipCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
template_name = 'pretixcontrol/organizers/customer_membership.html' template_name = 'pretixcontrol/organizers/customer_membership.html'
permission = 'can_manage_customers' permission = 'can_manage_customers'
+1 -1
View File
@@ -395,7 +395,7 @@ class MembershipStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
f.position.used_membership = f.cleaned_data['membership'] f.position.used_membership = f.cleaned_data['membership']
try: 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: except ValidationError as e:
messages.error(self.request, e.message) messages.error(self.request, e.message)
self.render() self.render()
+7 -5
View File
@@ -227,11 +227,13 @@ class MembershipForm(forms.Form):
usages = f'({obj.usages} / {obj.membership_type.max_usages})' usages = f'({obj.usages} / {obj.membership_type.max_usages})'
else: else:
usages = '' usages = ''
return mark_safe( d = f'<strong>{escape(obj.membership_type)}</strong> {usages}<br>'
f'<strong>{escape(obj.membership_type)}</strong> {usages}<br>' if obj.attendee_name:
f'{escape(obj.attendee_name)}<br>' d += f'{escape(obj.attendee_name)}<br>'
f'<span class="text-muted">{ds} {de}</span>' d += f'<span class="text-muted">{ds} {de}</span>'
) if obj.testmode:
d += ' <span class="label label-warning">{}</span>'.format(_("TEST MODE"))
return mark_safe(d)
def clean(self): def clean(self):
d = super().clean() d = super().clean()
@@ -59,6 +59,7 @@
<tr> <tr>
<td> <td>
{{ m.membership_type.name }} {{ m.membership_type.name }}
{% if m.testmode %}<span class="label label-warning">{% trans "TEST MODE" %}</span>{% endif %}
</td> </td>
<td> <td>
{{ m.date_start|date:"SHORT_DATETIME_FORMAT" }} {{ m.date_start|date:"SHORT_DATETIME_FORMAT" }}
+3
View File
@@ -68,6 +68,7 @@ TEST_MEMBERSHIP_RES = {
"customer": "8WSAJCJ", "customer": "8WSAJCJ",
"date_start": "2021-04-01T00:00:00Z", "date_start": "2021-04-01T00:00:00Z",
"date_end": "2021-04-08T23:59:59.999999Z", "date_end": "2021-04-08T23:59:59.999999Z",
"testmode": False,
"attendee_name_parts": { "attendee_name_parts": {
"_scheme": "given_family", "_scheme": "given_family",
'given_name': 'John', 'given_name': 'John',
@@ -136,11 +137,13 @@ def test_membership_patch(token_client, organizer, customer, membership):
format='json', format='json',
data={ data={
"customer": other_customer.identifier, "customer": other_customer.identifier,
"testmode": True,
} }
) )
assert resp.status_code == 200 assert resp.status_code == 200
membership.refresh_from_db() membership.refresh_from_db()
assert membership.customer == customer # change is ignored assert membership.customer == customer # change is ignored
assert not membership.testmode # change is ignored
@pytest.mark.django_db @pytest.mark.django_db
+38
View File
@@ -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) 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 @pytest.mark.django_db
def test_validate_membership_wrong_customer(event, customer, membership, requiring_ticket, membership_type): def test_validate_membership_wrong_customer(event, customer, membership, requiring_ticket, membership_type):
customer2 = event.organizer.customers.create(email="doe@example.org") customer2 = event.organizer.customers.create(email="doe@example.org")