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" %}
+
+
+ {% 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 %}
+
+
+
+
+ {% elif op|classname == "SubeventOperation" %}
+
+
+ {% blocktrans trimmed with positionid=op.position.positionid old=op.position.subevent new=op.subevent %}
+ Change date of position #{{ positionid }} from "{{ old }}" to "{{ new }}"
+ {% endblocktrans %}
+
+
+
+
+ {% elif op|classname == "PriceOperation" %}
+
+
+ {% 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 %}
+
+
+ {% elif op|classname == "AddOperation" %}
+
+
+ {% 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 %}
+
+
+ {% elif op|classname == "CancelOperation" %}
+
+
+ {% 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 %}
+
+
+ {% endif %}
+ {% endfor %}
+ {% if not hide_prices %}
+
+
+ {% trans "Total price change" %}
+
+
+ {{ totaldiff|money:request.event.currency }}
+
+
+
+ {% if totaldiff %}
+
+ {% 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 }}
+
+
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+{% 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.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 @@
+{% 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 %}
+
+
+
+{% 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()