diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 7f38a98a97..0aeb6a8263 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -785,6 +785,7 @@ class EventSettingsSerializer(SettingsSerializer): 'change_allow_user_addons', 'change_allow_user_until', 'change_allow_user_price', + 'change_allow_attendee', 'primary_color', 'theme_color_success', 'theme_color_danger', diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 248bc2a50f..3d2e253f69 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -2555,6 +2555,27 @@ class OrderPosition(AbstractPosition): attach_tickets=True ) + @property + @scopes_disabled() + def attendee_change_allowed(self) -> bool: + """ + Returns whether or not this order can be changed by the attendee. + """ + from .items import ItemAddOn + + if not self.event.settings.change_allow_attendee or not self.order.user_change_allowed: + return False + + positions = list( + self.order.positions.filter(Q(pk=self.pk) | Q(addon_to_id=self.pk)).annotate( + has_variations=Exists(ItemVariation.objects.filter(item_id=OuterRef('item_id'))), + ).select_related('item').prefetch_related('issued_gift_cards') + ) + return ( + (self.order.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])) or + (self.order.event.settings.change_allow_user_addons and ItemAddOn.objects.filter(base_item_id__in=[op.item_id for op in positions]).exists()) + ) + class Transaction(models.Model): """ diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 1eb641da63..1d6a26422c 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1484,6 +1484,19 @@ DEFAULTS = { label=_("Do not allow changes after"), ) }, + 'change_allow_attendee': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Allow individual attendees to change their ticket"), + help_text=_("By default, only the person who ordered the tickets can make any changes. If you check this " + "box, individual attendees can also make changes. However, individual attendees can always " + "only make changes that do not change the total price of the order. Such changes can always " + "only be made by the main customer."), + ) + }, 'cancel_allow_user': { 'default': 'True', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index d136ce8197..02f2fe37e7 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -690,6 +690,7 @@ class CancelSettingsForm(SettingsForm): 'change_allow_user_price', 'change_allow_user_until', 'change_allow_user_addons', + 'change_allow_attendee', ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/control/templates/pretixcontrol/event/cancel.html b/src/pretix/control/templates/pretixcontrol/event/cancel.html index 56815c3ac9..cd70e6d21d 100644 --- a/src/pretix/control/templates/pretixcontrol/event/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/event/cancel.html @@ -67,6 +67,7 @@ {% bootstrap_field form.change_allow_user_addons layout="control" %} {% bootstrap_field form.change_allow_user_until layout="control" %} {% bootstrap_field form.change_allow_user_price layout="control" %} + {% bootstrap_field form.change_allow_attendee layout="control" %}

{% blocktrans trimmed %} diff --git a/src/pretix/presale/forms/order.py b/src/pretix/presale/forms/order.py index f9521e672d..a49a934908 100644 --- a/src/pretix/presale/forms/order.py +++ b/src/pretix/presale/forms/order.py @@ -42,6 +42,7 @@ class OrderPositionChangeForm(forms.Form): invoice_address = kwargs.pop('invoice_address') initial = kwargs.get('initial', {}) event = kwargs.pop('event') + hide_prices = kwargs.pop('hide_prices') quota_cache = kwargs.pop('quota_cache') kwargs['initial'] = initial if instance.variation_id: @@ -105,23 +106,24 @@ class OrderPositionChangeForm(forms.Form): if new_price.gross != current_price.gross and event.settings.change_allow_user_price == 'eq': continue - if new_price.gross < current_price.gross: - if event.settings.display_net_prices: - label += ' (- {} {})'.format(money_filter(current_price.gross - new_price.gross, event.currency), _('plus taxes')) - else: - label += ' (- {})'.format(money_filter(current_price.gross - new_price.gross, event.currency)) - elif current_price.gross < new_price.gross: - if event.settings.display_net_prices: - label += ' ({}{} {})'.format( - '+ ' if current_price.gross != Decimal('0.00') else '', - money_filter(new_price.gross - current_price.gross, event.currency), - _('plus taxes') - ) - else: - label += ' ({}{})'.format( - '+ ' if current_price.gross != Decimal('0.00') else '', - money_filter(new_price.gross - current_price.gross, event.currency) - ) + if not hide_prices: + if new_price.gross < current_price.gross: + if event.settings.display_net_prices: + label += ' (- {} {})'.format(money_filter(current_price.gross - new_price.gross, event.currency), _('plus taxes')) + else: + label += ' (- {})'.format(money_filter(current_price.gross - new_price.gross, event.currency)) + elif current_price.gross < new_price.gross: + if event.settings.display_net_prices: + label += ' ({}{} {})'.format( + '+ ' if current_price.gross != Decimal('0.00') else '', + money_filter(new_price.gross - current_price.gross, event.currency), + _('plus taxes') + ) + else: + label += ' ({}{})'.format( + '+ ' if current_price.gross != Decimal('0.00') else '', + money_filter(new_price.gross - current_price.gross, event.currency) + ) choices.append((f'{i.pk}-{v.pk}', label)) diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html b/src/pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html index f223a53373..744b35ac15 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html @@ -70,6 +70,7 @@

+ {% if not hide_prices %} {% if c.price_included %} {% trans "free" context "price" %} {% elif item.free_price %} @@ -87,6 +88,7 @@ {% else %} {{ item.min_price|money:event.currency }} {% endif %} + {% endif %}

@@ -117,6 +119,7 @@ {% endif %}
+ {% if not hide_prices %} {% if not c.price_included %} {% if var.original_price %} {% trans "Original price:" %} @@ -169,6 +172,7 @@ {% else %} {% trans "free" context "price" %} {% endif %} + {% endif %}
{% if var.cached_availability.0 == 100 or var.initial %}
@@ -240,6 +244,7 @@

+ {% if not hide_prices %} {% if not c.price_included %} {% if item.original_price %} {% trans "Original price:" %} @@ -290,6 +295,7 @@ {% else %} {% trans "free" context "price" %} {% endif %} + {% endif %}

{% if item.cached_availability.0 == 100 or item.initial %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_change_confirm.html b/src/pretix/presale/templates/pretixpresale/event/fragment_change_confirm.html new file mode 100644 index 0000000000..0de38c0217 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_change_confirm.html @@ -0,0 +1,191 @@ +{% load i18n %} +{% load classname %} +{% load eventurl %} +{% load money %} + +
+
+
+

+ {% trans "Change summary" %} +

+
+ + {% for op in operations %} + {% if op|classname == "ItemOperation" %} + + + + + {% elif op|classname == "SubeventOperation" %} + + + + + {% elif op|classname == "PriceOperation" %} + + + + + {% elif op|classname == "AddOperation" %} + + + + + {% elif op|classname == "CancelOperation" %} + + + + + {% endif %} + {% endfor %} + {% if not hide_prices %} + + + + + + {% if totaldiff %} + + + + + + + + + + + + + {% endif %} + + {% endif %} +
+ {% if op.position.variation or op.variation %} + {% blocktrans trimmed with positionid=op.position.positionid old_item=op.position.item.name old_variation=op.position.variation new_item=op.item.name new_variation=op.variation %} + Change position #{{ positionid }} from "{{ old_item }} – {{ old_variation }}" to "{{ new_item }} – {{ new_variation }}" + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with positionid=op.position.positionid old_item=op.position.item.name new_item=op.item.name %} + Change position #{{ positionid }} from "{{ old_item }}" to "{{ new_item }}" + {% endblocktrans %} + {% endif %} + {% if op.position.addon_to %} + +
+ {% blocktrans with positionid=op.position.addon_to.positionid %}Add-on product to position #{{ positionid }}{% endblocktrans %} +
+ {% endif %} +
+
+ {% blocktrans trimmed with positionid=op.position.positionid old=op.position.subevent new=op.subevent %} + Change date of position #{{ positionid }} from "{{ old }}" to "{{ new }}" + {% endblocktrans %} + +
+ {% blocktrans trimmed with positionid=op.position.positionid old=op.position.price new=op.price %} + Change price of position #{{ positionid }} from {{ old }} to {{ new }} + {% endblocktrans %} + {% if op.position.addon_to %} + +
+ {% blocktrans with positionid=op.position.addon_to.positionid %}Add-on product to position #{{ positionid }}{% endblocktrans %} +
+ {% endif %} +
+ {% if not hide_prices %} + {{ op.price_diff|money:request.event.currency }} + {% endif %} +
+ {% if op.variation %} + {% blocktrans trimmed with item=op.item.name variation=op.variation.value %} + Add position ({{ item }} – {{ variation }}) + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with item=op.item.name %} + Add position ({{ item }}) + {% endblocktrans %} + {% endif %} + {% if op.addon_to %} + +
+ {% blocktrans with positionid=op.addon_to.positionid %}Add-on product to position #{{ positionid }}{% endblocktrans %} +
+ {% endif %} +
+ {% if not hide_prices %} + {{ op.price.gross|money:request.event.currency }} + {% endif %} +
+ {% if op.position.variation %} + {% blocktrans trimmed with positionid=op.position.positionid item=op.position.item.name variation=op.position.variation.value %} + Remove position #{{ positionid }} ({{ item }} – {{ variation }}) + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with positionid=op.position.positionid item=op.position.item.name %} + Remove position #{{ positionid }} ({{ item }}) + {% endblocktrans %} + {% endif %} + {% if op.position.addon_to %} + +
+ {% blocktrans with positionid=op.position.addon_to.positionid %}Add-on product to position #{{ positionid }}{% endblocktrans %} +
+ {% endif %} +
+ {% if not hide_prices %} + {{ op.price_diff|money:request.event.currency }} + {% endif %} +
{% trans "Total price change" %} + + {{ totaldiff|money:request.event.currency }} + +
{% trans "New order total" %} + {{ totaldiff|add:order.total|money:request.event.currency }} +
{% trans "You already paid" %} + {{ order.payment_refund_sum|money:request.event.currency }} +
+ {% if new_pending_sum > 0 %} + {% trans "You will need to pay" %} +
+ + {% trans "Your entire order will be considered unpaid until you paid this difference." %} + + {% else %} + {% trans "You will be refunded" %} +
+ + {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "manually" %} + {% trans "The organizer will get in touch with you to clarify the details of your refund." %} + {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} + {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} + {% else %} + {% if can_auto_refund %} + {% blocktrans trimmed %} + The refund amount will automatically be sent back to your original payment method. Depending + on the payment method, please allow for up to two weeks before this appears on your + statement. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + With the payment method you used, the refund amount can not be sent back to you + automatically. Instead, the event organizer will need to initiate the transfer + manually. Please be patient as this might take a bit longer. + {% endblocktrans %} + {% endif %} + {% endif %} + + {% endif %} +
+ + {{ new_pending_sum|money:request.event.currency }} + +
+
+
+{% for k, l in request.POST.lists %} + {% for v in l %} + + {% endfor %} +{% endfor %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_change_form.html b/src/pretix/presale/templates/pretixpresale/event/fragment_change_form.html new file mode 100644 index 0000000000..b2bafe453d --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_change_form.html @@ -0,0 +1,58 @@ +{% load i18n %} +{% load bootstrap3 %} +{% load rich_text %} +{% for position, addon_positions in formgroups.items %} +
+
+

+ {{ position.item }} + {% if position.variation %} + – {{ position.variation }} + {% endif %} +

+
+
+
+
+ {% if position.subevent %} +
+ +
+
    + {{ position.subevent.name }} · {{ position.subevent.get_date_range_display_as_html }} + {% if position.event.settings.show_times %} + + {{ position.subevent.date_from|date:"TIME_FORMAT" }} + {% endif %} +
+
+
+ {% endif %} + + {% for p in addon_positions %} + {% if p.pk != position.pk %} + {# Add-Ons #} + + {{ p.item.name }}{% if p.variation %} – {{ p.variation.value }}{% endif %} + {% endif %} + {% if p.attendee_name %} +
+ +
+ {{ p.attendee_name }} +
+
+ {% endif %} + {% bootstrap_form p.form layout="checkout" %} + {% endfor %} +
+
+ {% if position.addon_form %} + {% include "pretixpresale/event/fragment_addon_choice.html" with form=position.addon_form hide_prices=hide_prices %} + {% endif %} +
+
+{% endfor %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order_change.html b/src/pretix/presale/templates/pretixpresale/event/order_change.html index e476cfb80e..da96ba934a 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order_change.html +++ b/src/pretix/presale/templates/pretixpresale/event/order_change.html @@ -13,61 +13,7 @@
{% csrf_token %} - {% for position, addon_positions in formgroups.items %} -
-
-

- {{ position.item }} - {% if position.variation %} - – {{ position.variation }} - {% endif %} -

-
-
-
-
- {% if position.subevent %} -
- -
-
    - {{ position.subevent.name }} · {{ position.subevent.get_date_range_display_as_html }} - {% if position.event.settings.show_times %} - - {{ position.subevent.date_from|date:"TIME_FORMAT" }} - {% endif %} -
-
-
- {% endif %} - - {% for p in addon_positions %} - {% if p.pk != position.pk %} - {# Add-Ons #} - + {{ p.item.name }}{% if p.variation %} – {{ p.variation.value }}{% endif %} - {% endif %} - {% if p.attendee_name %} -
- -
- {{ p.attendee_name }} -
-
- {% endif %} - {% bootstrap_form p.form layout="checkout" %} - {% endfor %} -
-
- {% if position.addon_form %} - {% include "pretixpresale/event/fragment_addon_choice.html" with form=position.addon_form %} - {% endif %} -
-
- {% endfor %} + {% include "pretixpresale/event/fragment_change_form.html" %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/order_change_confirm.html b/src/pretix/presale/templates/pretixpresale/event/order_change_confirm.html index 169b80e85d..cd87d49332 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order_change_confirm.html +++ b/src/pretix/presale/templates/pretixpresale/event/order_change_confirm.html @@ -17,193 +17,11 @@ {% csrf_token %}

{% trans "Please confirm the following changes to your order." %}

-
-
-
-

- {% trans "Change summary" %} -

-
- - {% for op in operations %} - {% if op|classname == "ItemOperation" %} - - - - - {% elif op|classname == "SubeventOperation" %} - - - - - {% elif op|classname == "PriceOperation" %} - - - - - {% elif op|classname == "AddOperation" %} - - - - - {% elif op|classname == "CancelOperation" %} - - - - - {% endif %} - {% endfor %} - - - - - - {% if totaldiff %} - - - - - - - - - - - - - {% endif %} - -
- {% if op.position.variation or op.variation %} - {% blocktrans trimmed with positionid=op.position.positionid old_item=op.position.item.name old_variation=op.position.variation new_item=op.item.name new_variation=op.variation %} - Change position #{{ positionid }} from "{{ old_item }} – {{ old_variation }} - " to "{{ new_item }} – {{ new_variation }}" - {% endblocktrans %} - {% else %} - {% blocktrans trimmed with positionid=op.position.positionid old_item=op.position.item.name new_item=op.item.name %} - Change position #{{ positionid }} from "{{ old_item }}" to "{{ new_item }}" - {% endblocktrans %} - {% endif %} - {% if op.position.addon_to %} - -
- {% blocktrans with positionid=op.position.addon_to.positionid %} - Add-on product to position #{{ positionid }}{% endblocktrans %} -
- {% endif %} -
-
- {% blocktrans trimmed with positionid=op.position.positionid old=op.position.subevent new=op.subevent %} - Change date of position #{{ positionid }} from "{{ old }}" to "{{ new }}" - {% endblocktrans %} - -
- {% blocktrans trimmed with positionid=op.position.positionid old=op.position.price new=op.price %} - Change price of position #{{ positionid }} from {{ old }} to {{ new }} - {% endblocktrans %} - {% if op.position.addon_to %} - -
- {% blocktrans with positionid=op.position.addon_to.positionid %} - Add-on product to position #{{ positionid }}{% endblocktrans %} -
- {% endif %} -
- {{ op.price_diff|money:request.event.currency }} -
- {% if op.variation %} - {% blocktrans trimmed with item=op.item.name variation=op.variation.value %} - Add position ({{ item }} – {{ variation }}) - {% endblocktrans %} - {% else %} - {% blocktrans trimmed with item=op.item.name %} - Add position ({{ item }}) - {% endblocktrans %} - {% endif %} - {% if op.addon_to %} - -
- {% blocktrans with positionid=op.addon_to.positionid %}Add-on product - to position #{{ positionid }}{% endblocktrans %} -
- {% endif %} -
- {{ op.price.gross|money:request.event.currency }} -
- {% if op.position.variation %} - {% blocktrans trimmed with positionid=op.position.positionid item=op.position.item.name variation=op.position.variation.value %} - Remove position #{{ positionid }} ({{ item }} – {{ variation }}) - {% endblocktrans %} - {% else %} - {% blocktrans trimmed with positionid=op.position.positionid item=op.position.item.name %} - Remove position #{{ positionid }} ({{ item }}) - {% endblocktrans %} - {% endif %} - {% if op.position.addon_to %} - -
- {% blocktrans with positionid=op.position.addon_to.positionid %} - Add-on product to position #{{ positionid }}{% endblocktrans %} -
- {% endif %} -
- {{ op.price_diff|money:request.event.currency }} -
{% trans "Total price change" %} - - {{ totaldiff|money:request.event.currency }} - -
{% trans "New order total" %} - {{ totaldiff|add:order.total|money:request.event.currency }} -
{% trans "You already paid" %} - {{ order.payment_refund_sum|money:request.event.currency }} -
- {% if new_pending_sum > 0 %} - {% trans "You will need to pay" %} -
- - {% trans "Your entire order will be considered unpaid until you paid this difference." %} - - {% else %} - {% trans "You will be refunded" %} -
- - {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "manually" %} - {% trans "The organizer will get in touch with you to clarify the details of your refund." %} - {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} - {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} - {% else %} - {% if can_auto_refund %} - {% blocktrans trimmed %} - The refund amount will automatically be sent back to your original payment method. Depending - on the payment method, please allow for up to two weeks before this appears on your - statement. - {% endblocktrans %} - {% else %} - {% blocktrans trimmed %} - With the payment method you used, the refund amount can not be sent back to you - automatically. Instead, the event organizer will need to initiate the transfer - manually. Please be patient as this might take a bit longer. - {% endblocktrans %} - {% endif %} - {% endif %} - - {% endif %} -
- - {{ new_pending_sum|money:request.event.currency }} - -
-
-
- {% for k, l in request.POST.lists %} - {% for v in l %} - - {% endfor %} - {% endfor %} + {% include "pretixpresale/event/fragment_change_confirm.html" %} - {% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/position.html b/src/pretix/presale/templates/pretixpresale/event/position.html index 3d2f0632a1..d74968d003 100644 --- a/src/pretix/presale/templates/pretixpresale/event/position.html +++ b/src/pretix/presale/templates/pretixpresale/event/position.html @@ -51,4 +51,33 @@
{% eventsignal event "pretix.presale.signals.position_info" order=order position=position request=request %} + {% if attendee_change_allowed %} +
+
+

+ {% trans "Change your ticket" context "action" %} +

+
+
+

+ {% blocktrans trimmed %} + If you want to make changes to the components of your ticket, you can click on the following button. + {% endblocktrans %} +

+

+ {% blocktrans trimmed with email=order.email %} + You can only make some changes to this ticket yourself. For additional changes, please + get in touch with the person who bought the ticket ({{ email }}). + {% endblocktrans %} +

+

+ + + {% trans "Change ticket" %} + +

+
+
+ {% endif %} {% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/position_change.html b/src/pretix/presale/templates/pretixpresale/event/position_change.html new file mode 100644 index 0000000000..ed4960da18 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/position_change.html @@ -0,0 +1,35 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load rich_text %} +{% block title %}{% blocktrans trimmed %} + Change ticket +{% endblocktrans %}{% endblock %} +{% block content %} +

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

+
+ {% csrf_token %} +

{% trans "Please select the desired changes to your ticket. Note that you can only perform changes that do not change the total price of the ticket." %}

+ + {% include "pretixpresale/event/fragment_change_form.html" with hide_prices=request.event.settings.hide_prices_from_attendees %} + +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/position_change_confirm.html b/src/pretix/presale/templates/pretixpresale/event/position_change_confirm.html new file mode 100644 index 0000000000..b047636af7 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/position_change_confirm.html @@ -0,0 +1,36 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load classname %} +{% load eventurl %} +{% load money %} +{% block title %}{% blocktrans trimmed %} + Change ticket +{% endblocktrans %}{% endblock %} +{% block content %} +

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

+ +
+ {% csrf_token %} + +

{% trans "Please confirm the following changes to your ticket." %}

+ {% include "pretixpresale/event/fragment_change_confirm.html" with hide_prices=request.event.settings.hide_prices_from_attendees %} +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 2d8b0ad476..40a7c1e6fb 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -150,6 +150,9 @@ event_patterns = [ re_path(r'^ticket/(?P[^/]+)/(?P\d+)/(?P[A-Za-z0-9]+)/download/(?P[0-9]+)/(?P[^/]+)$', pretix.presale.views.order.OrderPositionDownload.as_view(), name='event.order.position.download'), + 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'^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 a9d9a8b708..f2f87354d3 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -371,6 +371,7 @@ class OrderPositionDetails(EventViewMixin, OrderPositionDetailMixin, CartMixin, order=self.order ) ctx['tickets_with_download'] = [p for p in ctx['cart']['positions'] if p.generate_ticket] + ctx['attendee_change_allowed'] = self.position.attendee_change_allowed return ctx @@ -1192,19 +1193,7 @@ class InvoiceDownload(EventViewMixin, OrderDetailMixin, View): return resp -@method_decorator(xframe_options_exempt, 'dispatch') -class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): - template_name = "pretixpresale/event/order_change.html" - - def dispatch(self, request, *args, **kwargs): - self.request = request - self.kwargs = kwargs - if not self.order: - raise Http404(_('Unknown order code or not authorized to access this order.')) - if not self.order.user_change_allowed: - messages.error(request, _('You cannot change this order.')) - return redirect(self.get_order_url()) - return super().dispatch(request, *args, **kwargs) +class OrderChangeMixin: @cached_property def formdict(self): @@ -1232,7 +1221,7 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): @cached_property def positions(self): positions = list( - self.order.positions.select_related('item', 'item__tax_rule').prefetch_related( + self.get_position_queryset().select_related('item', 'item__tax_rule').prefetch_related( 'item__variations', 'addons', ) ) @@ -1245,7 +1234,8 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): for p in positions: p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, invoice_address=ia, event=self.request.event, quota_cache=quota_cache, - data=self.request.POST if self.request.method == "POST" else None) + data=self.request.POST if self.request.method == "POST" else None, + hide_prices=self.get_hide_prices()) if p.addon_to_id is None and self.request.event.settings.change_allow_user_addons: p.addon_form = { @@ -1470,7 +1460,7 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): except OrderError as e: messages.error(self.request, str(e)) else: - if self.order.pending_sum < Decimal('0.00'): + if self.order.pending_sum < Decimal('0.00') and ocm._totaldiff < Decimal('0.00'): auto_refund = ( not self.request.event.settings.cancel_allow_user_paid_require_approval and self.request.event.settings.cancel_allow_user_paid_refund_as_giftcard != "manually" @@ -1493,10 +1483,10 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): else: messages.success(self.request, _('The order has been changed.')) - return redirect(self.get_order_url()) + return redirect(self.get_self_url()) elif not ocm._operations: messages.info(self.request, _('You did not make any changes.')) - return redirect(self.get_order_url()) + return redirect(self.get_self_url()) else: new_pending_sum = self.order.pending_sum + ocm._totaldiff can_auto_refund = False @@ -1504,23 +1494,25 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): proposals = self.order.propose_auto_refunds(Decimal('-1.00') * new_pending_sum) can_auto_refund = sum(proposals.values()) == Decimal('-1.00') * new_pending_sum - return render(request, 'pretixpresale/event/order_change_confirm.html', { + return render(request, self.confirm_template_name, { 'operations': ocm._operations, 'totaldiff': ocm._totaldiff, 'order': self.order, 'payment_refund_sum': self.order.payment_refund_sum, 'new_pending_sum': new_pending_sum, 'can_auto_refund': can_auto_refund, + **self.get_confirm_context_data(), }) return self.get(request, *args, **kwargs) def _validate_total_diff(self, ocm): - if ocm._totaldiff < Decimal('0.00') and self.request.event.settings.change_allow_user_price == 'gte': + pr = self.get_price_requirement() + if ocm._totaldiff < Decimal('0.00') and pr == 'gte': raise OrderError(_('You may not change your order in a way that reduces the total price.')) - if ocm._totaldiff <= Decimal('0.00') and self.request.event.settings.change_allow_user_price == 'gt': + if ocm._totaldiff <= Decimal('0.00') and pr == 'gt': raise OrderError(_('You may only change your order in a way that increases the total price.')) - if ocm._totaldiff != Decimal('0.00') and self.request.event.settings.change_allow_user_price == 'eq': + if ocm._totaldiff != Decimal('0.00') and pr == 'eq': raise OrderError(_('You may not change your order in a way that changes the total price.')) if ocm._totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PAID: @@ -1531,3 +1523,70 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): if self.order.expires < now(): raise OrderError(_('You may not change your order in a way that increases the total price since ' 'payments are no longer being accepted for this event.')) + + +@method_decorator(xframe_options_exempt, 'dispatch') +class OrderChange(OrderChangeMixin, EventViewMixin, OrderDetailMixin, TemplateView): + template_name = "pretixpresale/event/order_change.html" + confirm_template_name = 'pretixpresale/event/order_change_confirm.html' + + def dispatch(self, request, *args, **kwargs): + self.request = request + self.kwargs = kwargs + if not self.order: + raise Http404(_('Unknown order code or not authorized to access this order.')) + if not self.order.user_change_allowed: + messages.error(request, _('You cannot change this order.')) + return redirect(self.get_order_url()) + return super().dispatch(request, *args, **kwargs) + + def get_self_url(self): + return self.get_order_url() + + def get_position_queryset(self): + return self.order.positions + + def get_price_requirement(self): + return self.request.event.settings.change_allow_user_price + + def get_confirm_context_data(self): + return {} + + def get_hide_prices(self): + return False + + +@method_decorator(xframe_options_exempt, 'dispatch') +class OrderPositionChange(OrderChangeMixin, EventViewMixin, OrderPositionDetailMixin, TemplateView): + template_name = "pretixpresale/event/position_change.html" + confirm_template_name = 'pretixpresale/event/position_change_confirm.html' + + 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.attendee_change_allowed: + messages.error(request, _('You cannot change this order.')) + return redirect(self.get_position_url()) + return super().dispatch(request, *args, **kwargs) + + def get_self_url(self): + return self.get_position_url() + + def get_position_queryset(self): + return self.order.positions.filter(Q(pk=self.position.pk) | Q(addon_to_id=self.position.id)) + + def get_price_requirement(self): + return 'eq' + + def get_confirm_context_data(self): + return {'position': self.position} + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['position'] = self.position + return ctx + + def get_hide_prices(self): + return self.request.event.settings.hide_prices_from_attendees diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 2c41c090bc..d76bf58e62 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -1703,20 +1703,29 @@ class OrderTestCase(BaseQuotaTestCase): @classscope(attr='o') def test_can_change_order(self): + self.event.settings.change_allow_attendee = True item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, admission=True, allow_cancel=True) v = item1.variations.create(value="V") - OrderPosition.objects.create(order=self.order, item=item1, - variation=v, price=23) + op = OrderPosition.objects.create(order=self.order, item=item1, + variation=v, price=23) assert not self.order.user_change_allowed + assert not op.attendee_change_allowed self.event.settings.change_allow_user_variation = True assert self.order.user_change_allowed + assert op.attendee_change_allowed + + self.event.settings.change_allow_attendee = False + assert not op.attendee_change_allowed + self.event.settings.change_allow_attendee = True self.event.settings.change_allow_user_variation = False self.order.require_approval = True assert not self.order.user_change_allowed + assert not op.attendee_change_allowed self.event.settings.change_allow_user_variation = True assert not self.order.user_change_allowed + assert not op.attendee_change_allowed @classscope(attr='o') def test_can_change_order_with_giftcard(self): @@ -1726,10 +1735,12 @@ class OrderTestCase(BaseQuotaTestCase): p = OrderPosition.objects.create(order=self.order, item=item1, variation=v, price=23) self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_attendee = True self.event.organizer.issued_gift_cards.create( currency="EUR", issued_in=p ) assert not self.order.user_change_allowed + assert not p.attendee_change_allowed @classscope(attr='o') def test_can_change_checked_in(self): @@ -1738,12 +1749,14 @@ class OrderTestCase(BaseQuotaTestCase): self.order.status = Order.STATUS_PAID self.order.save() self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_attendee = True assert self.order.user_change_allowed Checkin.objects.create( position=self.order.positions.first(), list=CheckinList.objects.create(event=self.event, name='Default') ) assert not self.order.user_change_allowed + assert not self.order.positions.first().attendee_change_allowed @classscope(attr='o') def test_can_change_order_multiple(self): @@ -1758,7 +1771,9 @@ class OrderTestCase(BaseQuotaTestCase): OrderPosition.objects.create(order=self.order, item=item2, variation=v2, price=23) self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_attendee = True assert self.order.user_change_allowed + assert not self.order.positions.first().attendee_change_allowed @classscope(attr='o') def test_can_not_change_order(self): @@ -1768,22 +1783,26 @@ class OrderTestCase(BaseQuotaTestCase): OrderPosition.objects.create(order=self.order, item=item1, variation=v, price=23) self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_attendee = True assert self.order.user_change_allowed is False @classscope(attr='o') def test_require_any_variation(self): item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, admission=True, allow_cancel=True) - OrderPosition.objects.create(order=self.order, item=item1, - variation=None, price=23) + p = OrderPosition.objects.create(order=self.order, item=item1, + variation=None, price=23) self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_attendee = True assert self.order.user_change_allowed is False item2 = Item.objects.create(event=self.event, name="Ticket", default_price=23, admission=True, allow_cancel=True) v2 = item2.variations.create(value="V") - OrderPosition.objects.create(order=self.order, item=item2, - variation=v2, price=23) + p2 = OrderPosition.objects.create(order=self.order, item=item2, + variation=v2, price=23) assert self.order.user_change_allowed is True + assert p.attendee_change_allowed is False + assert p2.attendee_change_allowed is True @classscope(attr='o') def test_can_not_change_order_multiple(self): diff --git a/src/tests/presale/test_order_change.py b/src/tests/presale/test_order_change.py index e23e22fb26..6372d667fb 100644 --- a/src/tests/presale/test_order_change.py +++ b/src/tests/presale/test_order_change.py @@ -117,6 +117,11 @@ class OrderChangeVariationTest(BaseOrdersTest): ) assert response.status_code == 302 + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + assert response.status_code == 302 + def test_change_variation_paid(self): self.event.settings.change_allow_user_variation = True self.event.settings.change_allow_user_price = 'any' @@ -154,6 +159,13 @@ class OrderChangeVariationTest(BaseOrdersTest): assert self.order.status == Order.STATUS_PENDING assert self.order.total == Decimal('35.00') + # Attendee is not allowed + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/change' % ( + self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret) + ) + assert response.status_code == 302 + def test_change_variation_require_higher_price(self): self.event.settings.change_allow_user_variation = True self.event.settings.change_allow_user_price = 'gt' @@ -1464,3 +1476,83 @@ class OrderChangeAddonsTest(BaseOrdersTest): assert self.order.total == Decimal('23.00') r = self.order.refunds.get() assert r.provider == 'giftcard' + + def test_attendee(self): + self.workshop2a.default_price = Decimal('0.00') + self.workshop2a.save() + self.event.settings.change_allow_attendee = True + response = self.client.post( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret), + { + f'cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '1' + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), form_data, follow=True + ) + + with scopes_disabled(): + a = self.ticket_pos.addons.get() + assert a.variation == self.workshop2a + + def test_attendee_limited_to_own_ticket(self): + with scopes_disabled(): + ticket_pos2 = OrderPosition.objects.create( + order=self.order, + item=self.ticket, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"} + ) + self.event.settings.change_allow_attendee = True + response = self.client.post( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret), + { + f'cp_{ticket_pos2.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '1' + }, + follow=False + ) + assert response.status_code == 302 # nothing changed + + def test_attendee_needs_to_keep_price(self): + self.event.settings.change_allow_user_price = 'any' # ignored, for attendees its always "eq" + self.event.settings.change_allow_attendee = True + response = self.client.post( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret), + { + f'cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '1' + }, + follow=True + ) + assert 'alert-danger' in response.content.decode() + assert 'changes' in response.content.decode() + + self.workshop2a.default_price = Decimal('0.00') + self.workshop2a.save() + + response = self.client.post( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret), + { + f'cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '1' + }, + follow=True + ) + assert 'alert-danger' not in response.content.decode() + + def test_attendee_price_hidden(self): + self.event.settings.change_allow_attendee = True + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret), + follow=True + ) + assert '€' not in response.content.decode() + self.event.settings.hide_prices_from_attendees = False + response = self.client.get( + '/%s/%s/ticket/%s/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.ticket_pos.positionid, self.ticket_pos.web_secret), + follow=True + ) + assert '€' in response.content.decode()