diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index b6e970073..3ba6a2722 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -684,8 +684,9 @@ class EventSettingsSerializer(SettingsSerializer): 'locales', 'locale', 'region', - 'last_order_modification_date', + 'allow_modifications', 'allow_modifications_after_checkin', + 'last_order_modification_date', 'show_quota_left', 'waiting_list_enabled', 'waiting_list_auto_disable', diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index e08b1d63d..ed014b264 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -850,6 +850,9 @@ class Order(LockModel, LoggedModel): if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED): return False + if self.event.settings.allow_modifications not in ("order", "attendee"): + return False + modify_deadline = self.modify_deadline if modify_deadline is not None and now() > modify_deadline: return False @@ -2513,6 +2516,43 @@ class OrderPosition(AbstractPosition): reasons[b] = b return reasons + @property + def can_modify_answers(self) -> bool: + """ + ``True`` if the user can change the question answers / attendee names that are + related to the position. This checks order status and modification deadlines. It also + returns ``False`` if there are no questions that can be answered. + """ + from .checkin import Checkin + + if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED): + return False + + if self.event.settings.allow_modifications != "attendee": + return False + + modify_deadline = self.order.modify_deadline + if modify_deadline is not None and now() > modify_deadline: + return False + + positions = list( + self.order.positions.all().annotate( + has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True)) + ).select_related('item').prefetch_related('item__questions') + ) + if not self.event.settings.allow_modifications_after_checkin: + for cp in positions: + if cp.has_checkin: + return False + + ask_names = self.event.settings.get('attendee_names_asked', as_type=bool) + for cp in positions: + if cp.pk == self.pk or cp.addon_to_id == self.pk: + if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all(): + return True + + return False # nothing there to modify + @classmethod def transform_cart_positions(cls, cp: List, order) -> list: from . import Voucher diff --git a/src/pretix/base/services/placeholders.py b/src/pretix/base/services/placeholders.py index 60acbdcad..8ab1a42e6 100644 --- a/src/pretix/base/services/placeholders.py +++ b/src/pretix/base/services/placeholders.py @@ -337,6 +337,40 @@ def base_placeholders(sender, **kwargs): } ), ), + SimpleFunctionalTextPlaceholder( + 'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri( + event, + 'presale:event.order.position.modify', kwargs={ + 'order': position.order.code, + 'secret': position.web_secret, + 'position': position.positionid + } + ), lambda event: build_absolute_uri( + event, + 'presale:event.order.position.modify', kwargs={ + 'order': 'F8VVL', + 'secret': '6zzjnumtsx136ddy', + 'position': '123', + } + ), + ), + SimpleFunctionalTextPlaceholder( + 'url_products_change', ['position', 'event'], lambda position, event: build_absolute_uri( + event, + 'presale:event.order.position.change', kwargs={ + 'order': position.order.code, + 'secret': position.web_secret, + 'position': position.positionid + } + ), lambda event: build_absolute_uri( + event, + 'presale:event.order.position.change', kwargs={ + 'order': 'F8VVL', + 'secret': '6zzjnumtsx136ddy', + 'position': '123' + } + ), + ), SimpleFunctionalTextPlaceholder( 'order_modification_deadline_date_and_time', ['order', 'event'], lambda order, event: diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 5665f7377..2217ad104 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1653,6 +1653,28 @@ DEFAULTS = { "calendar.") ) }, + 'allow_modifications': { + 'default': 'order', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=( + ('no', _('No modifications after order was submitted')), + ('order', _('Only the person who ordered can make changes')), + ('attendee', _('Both the attendee and the person who ordered can make changes')), + ) + ), + 'form_kwargs': dict( + label=_("Allow customers to modify their information"), + widget=forms.RadioSelect, + choices=( + ('no', _('No modifications after order was submitted')), + ('order', _('Only the person who ordered can make changes')), + ('attendee', _('Both the attendee and the person who ordered can make changes')), + ) + ), + }, 'allow_modifications_after_checkin': { 'default': 'False', 'type': bool, @@ -1660,6 +1682,8 @@ DEFAULTS = { 'serializer_class': serializers.BooleanField, 'form_kwargs': dict( label=_("Allow customers to modify their information after they checked in."), + help_text=_("By default, no more modifications are possible for an order as soon as one of the tickets " + "in the order has been checked in.") ) }, 'last_order_modification_date': { diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index c9a0ec925..a0ca8d304 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -580,6 +580,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett 'banner_text', 'banner_text_bottom', 'order_email_asked_twice', + 'allow_modifications', 'last_order_modification_date', 'allow_modifications_after_checkin', 'checkout_show_copy_answers_button', diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 50f6c438b..db89dc996 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -113,10 +113,17 @@ {% bootstrap_field sform.attendee_data_explanation_text layout="control" %} -

{% trans "Other settings" %}

+

{% trans "Form settings" %}

{% bootstrap_field sform.name_scheme layout="control" %} {% bootstrap_field sform.name_scheme_titles layout="control" %} {% bootstrap_field sform.checkout_show_copy_answers_button layout="control" %} + +

{% trans "Changes to existing orders" %}

+ {% bootstrap_field sform.allow_modifications layout="control" %} +
+ {% bootstrap_field sform.last_order_modification_date layout="control" %} + {% bootstrap_field sform.allow_modifications_after_checkin layout="control" %} +
{% trans "Texts" %} @@ -225,21 +232,47 @@ {% bootstrap_field sform.presale_start_show_date layout="control" %} {% bootstrap_field form.presale_end layout="control" %} {% bootstrap_field sform.show_items_outside_presale_period layout="control" %} - {% bootstrap_field sform.last_order_modification_date layout="control" %} - {% bootstrap_field sform.allow_modifications_after_checkin layout="control" %} - {% bootstrap_field sform.show_checkin_number_user layout="control" %}
{% trans "Display" %} +

{% trans "Date and time" %}

{% bootstrap_field sform.show_dates_on_frontpage layout="control" %} {% bootstrap_field sform.show_date_to layout="control" %} {% bootstrap_field sform.show_times layout="control" %} +

{% trans "Product list" %}

{% bootstrap_field sform.show_quota_left layout="control" %} {% bootstrap_field sform.display_net_prices layout="control" %} - {% bootstrap_field sform.hide_prices_from_attendees layout="control" %} {% bootstrap_field sform.show_variations_expanded layout="control" %} {% bootstrap_field sform.hide_sold_out layout="control" %} +

{% trans "Calendar and list views" context "subevents" %}

+ {% if sform.frontpage_subevent_ordering %} + {% bootstrap_field sform.frontpage_subevent_ordering layout="control" %} + {% endif %} + {% if sform.event_list_type %} + {% bootstrap_field sform.event_list_type layout="control" %} + {% endif %} + {% if sform.event_list_available_only %} + {% bootstrap_field sform.event_list_available_only layout="control" %} + {% endif %} + {% if sform.event_list_filters %} + {% bootstrap_field sform.event_list_filters layout="control" %} + {% endif %} + {% if sform.event_calendar_future_only %} + {% bootstrap_field sform.event_calendar_future_only layout="control" %} + {% endif %} + {% bootstrap_field sform.low_availability_percentage layout="control" addon_after="%" %} + + +

{% trans "Order details" %}

+ {% bootstrap_field sform.hide_prices_from_attendees layout="control" %} + {% bootstrap_field sform.show_checkin_number_user layout="control" %} + +

{% trans "Other settings" %}

+ {% url "control:organizer.edit" organizer=request.organizer.slug as org_url %} + {% propagated request.event org_url "meta_noindex" %} + {% bootstrap_field sform.meta_noindex layout="control" %} + {% endpropagated %}
- - {% if sform.frontpage_subevent_ordering %} - {% bootstrap_field sform.frontpage_subevent_ordering layout="control" %} - {% endif %} - {% if sform.event_list_type %} - {% bootstrap_field sform.event_list_type layout="control" %} - {% endif %} - {% if sform.event_list_available_only %} - {% bootstrap_field sform.event_list_available_only layout="control" %} - {% endif %} - {% if sform.event_list_filters %} - {% bootstrap_field sform.event_list_filters layout="control" %} - {% endif %} - {% if sform.event_calendar_future_only %} - {% bootstrap_field sform.event_calendar_future_only layout="control" %} - {% endif %} - {% bootstrap_field sform.low_availability_percentage layout="control" addon_after="%" %} - - {% url "control:organizer.edit" organizer=request.organizer.slug as org_url %} - {% propagated request.event org_url "meta_noindex" %} - {% bootstrap_field sform.meta_noindex layout="control" %} - {% endpropagated %}
{% trans "Cart" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/position.html b/src/pretix/presale/templates/pretixpresale/event/position.html index d74968d00..a8b735f10 100644 --- a/src/pretix/presale/templates/pretixpresale/event/position.html +++ b/src/pretix/presale/templates/pretixpresale/event/position.html @@ -29,6 +29,11 @@

{% trans "Your items" %} + {% if position.can_modify_answers %} + + {% trans "Change details" %} + + {% endif %}

diff --git a/src/pretix/presale/templates/pretixpresale/event/position_modify.html b/src/pretix/presale/templates/pretixpresale/event/position_modify.html new file mode 100644 index 000000000..d49a321c7 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/position_modify.html @@ -0,0 +1,55 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load rich_text %} +{% block title %}{% trans "Modify ticket" %}{% endblock %} +{% block content %} +

+ {% blocktrans trimmed %} + Modify ticket + {% endblocktrans %} +

+
+ {% csrf_token %} +
+ {% for pos, forms in formgroups %} +
+ +

+ {{ pos.item.name }}{% if pos.variation %} + – {{ pos.variation }} + {% endif %} +

+
+
+
+ {% for form in forms %} + {% if form.pos.item != pos.item %} + {# Add-Ons #} + + {{ form.pos.item.name }}{% if form.pos.variation %} + – {{ form.pos.variation.value }} + {% endif %} + {% endif %} + {% bootstrap_form form layout="checkout" %} + {% endfor %} +
+
+
+ {% endfor %} +
+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 1afd9dcb1..83006cd19 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -158,6 +158,9 @@ event_patterns = [ re_path(r'^ticket/(?P[^/]+)/(?P\d+)/(?P[A-Za-z0-9]+)/change$', pretix.presale.views.order.OrderPositionChange.as_view(), name='event.order.position.change'), + re_path(r'^ticket/(?P[^/]+)/(?P\d+)/(?P[A-Za-z0-9]+)/modify$', + pretix.presale.views.order.OrderPositionModify.as_view(), + name='event.order.position.modify'), re_path(r'^ical/?$', pretix.presale.views.event.EventIcalDownload.as_view(), diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index e9bc3646f..03c6710aa 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -858,6 +858,78 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem return super().dispatch(request, *args, **kwargs) +@method_decorator(xframe_options_exempt, 'dispatch') +class OrderPositionModify(EventViewMixin, OrderPositionDetailMixin, OrderQuestionsViewMixin, TemplateView): + form_class = QuestionsForm + invoice_form_class = None + template_name = "pretixpresale/event/position_modify.html" + + @cached_property + def invoice_form(self): + return None + + @cached_property + def positions(self): + return [p for p in super().positions if p.pk == self.position.pk or p.addon_to_id == self.position.pk] + + def get_question_override_sets(self, order_position, index): + override_sets = [ + resp for recv, resp in question_form_fields_overrides.send( + self.request.event, + position=order_position, + request=self.request + ) + ] + for override in override_sets: + for k in override: + # We don't want initial values to be modified, they should come from the order directly + override[k].pop('initial', None) + + if order_position.used_membership and not order_position.used_membership.membership_type.transferable: + override_sets.append({ + 'attendee_name_parts': { + 'disabled': True + } + }) + + return override_sets + + def post(self, request, *args, **kwargs): + failed = not self.save() + if failed: + messages.error(self.request, + _("We had difficulties processing your input. Please review the errors below.")) + return self.get(request, *args, **kwargs) + self.order.log_action('pretix.event.order.modified', { + 'by_ticket_holder': True, + 'data': [{ + k: (f.cleaned_data.get(k).name + if isinstance(f.cleaned_data.get(k), File) + else f.cleaned_data.get(k)) + for k in f.changed_data + } for f in self.forms] + }) + order_modified.send(sender=self.request.event, order=self.order) + + invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'order': self.order.pk}) + CachedTicket.objects.filter(order_position__order=self.order).delete() + CachedCombinedTicket.objects.filter(order=self.order).delete() + return redirect(self.get_position_url()) + + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def dispatch(self, request, *args, **kwargs): + self.request = request + self.kwargs = kwargs + if not self.position: + raise Http404(_('Unknown order code or not authorized to access this order.')) + if not self.position.can_modify_answers: + messages.error(request, _('You cannot modify this order')) + return redirect(self.get_position_url()) + return super().dispatch(request, *args, **kwargs) + + @method_decorator(xframe_options_exempt, 'dispatch') class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView): template_name = "pretixpresale/event/order_cancel.html" diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 8ee9d6c6a..4e20ddb3c 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -1263,22 +1263,34 @@ class OrderTestCase(BaseQuotaTestCase): self.event.settings.set('invoice_address_asked', False) self.event.settings.set('attendee_names_asked', True) assert self.order.can_modify_answers + assert not self.op1.can_modify_answers + + self.event.settings.set('allow_modifications', 'attendee') + assert self.op1.can_modify_answers + self.event.settings.set('attendee_names_asked', False) assert not self.order.can_modify_answers + assert not self.op1.can_modify_answers self.event.settings.set('invoice_address_asked', True) assert self.order.can_modify_answers + assert not self.op1.can_modify_answers self.event.settings.set('invoice_address_asked', False) self.event.settings.set('invoice_name_required', True) assert self.order.can_modify_answers + assert not self.op1.can_modify_answers q = Question.objects.create(question='Foo', type=Question.TYPE_BOOLEAN, event=self.event) self.item1.questions.add(q) assert self.order.can_modify_answers + assert self.op1.can_modify_answers self.order.status = Order.STATUS_CANCELED assert not self.order.can_modify_answers + assert not self.op1.can_modify_answers self.order.status = Order.STATUS_PAID assert self.order.can_modify_answers + assert self.op1.can_modify_answers self.event.settings.set('last_order_modification_date', now() - timedelta(days=1)) assert not self.order.can_modify_answers + assert not self.op1.can_modify_answers @classscope(attr='o') def test_can_modify_answers_subevent(self): diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index bc3f47a50..198f77a22 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -191,6 +191,24 @@ class OrdersTest(BaseOrdersTest): self.deleted_pos.positionid, self.deleted_pos.web_secret) ) assert response.status_code == 404 + response = self.client.get( + '/%s/%s/ticket/%s/1/123/modify' % (self.orga.slug, self.event.slug, self.not_my_order.code) + ) + assert response.status_code == 404 + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, + self.deleted_pos.positionid, self.deleted_pos.web_secret) + ) + assert response.status_code == 404 + response = self.client.get( + '/%s/%s/ticket/%s/1/123/change' % (self.orga.slug, self.event.slug, self.not_my_order.code) + ) + assert response.status_code == 404 + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, + self.deleted_pos.positionid, self.deleted_pos.web_secret) + ) + assert response.status_code == 404 def test_orders_confirm_email(self): response = self.client.get( @@ -424,6 +442,49 @@ class OrdersTest(BaseOrdersTest): with scopes_disabled(): assert self.order.invoices.count() == 3 + def test_orders_attendee_modify_invalid(self): + self.order.status = Order.STATUS_CANCELED + self.order.save() + self.event.settings.set('allow_modifications', 'attendee') + r = self.client.get( + '/%s/%s/ticket/%s/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, + self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + assert r.status_code == 302 + + def test_orders_attendee_modify_forbidden(self): + self.event.settings.set('allow_modifications', 'order') + r = self.client.get( + '/%s/%s/ticket/%s/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, + self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + assert r.status_code == 302 + + def test_orders_attendee_modify_attendee_optional(self): + self.event.settings.set('allow_modifications', 'attendee') + self.event.settings.set('attendee_names_asked', True) + self.event.settings.set('attendee_names_required', False) + + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, + self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select('input[name="%s-attendee_name_parts_0"]' % self.ticket_pos.id)), 1) + + # Not all fields filled out, expect success + response = self.client.post( + '/%s/%s/ticket/%s/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, + self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + self.assertRedirects(response, + '/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.ticket_pos.positionid, self.ticket_pos.web_secret), + target_status_code=200) + with scopes_disabled(): + self.ticket_pos = OrderPosition.objects.get(id=self.ticket_pos.id) + assert self.ticket_pos.attendee_name in (None, '') + def test_orders_cancel_invalid(self): self.order.status = Order.STATUS_PAID self.order.save()