Allow attendees to change selected add-ons of same price (#3150)

This commit is contained in:
Raphael Michel
2023-03-08 16:01:59 +01:00
committed by GitHub
parent 2ebbe82baf
commit 61ae434ab1
18 changed files with 615 additions and 285 deletions

View File

@@ -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',

View File

@@ -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):
"""

View File

@@ -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,

View File

@@ -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):

View File

@@ -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" %}
<div class="alert alert-info">
<p>
{% blocktrans trimmed %}

View File

@@ -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))

View File

@@ -70,6 +70,7 @@
</div>
<div class="col-md-2 col-sm-3 col-xs-6 price">
<p>
{% if not hide_prices %}
{% if c.price_included %}
<span class="sr-only">{% trans "free" context "price" %}</span>
{% elif item.free_price %}
@@ -87,6 +88,7 @@
{% else %}
{{ item.min_price|money:event.currency }}
{% endif %}
{% endif %}
</p>
</div>
<div class="col-md-2 col-sm-3 col-xs-6 availability-box">
@@ -117,6 +119,7 @@
{% endif %}
</div>
<div class="col-md-2 col-sm-3 col-xs-6 price">
{% if not hide_prices %}
{% if not c.price_included %}
{% if var.original_price %}
<del><span class="sr-only">{% trans "Original price:" %}</span>
@@ -169,6 +172,7 @@
{% else %}
<span class="sr-only">{% trans "free" context "price" %}</span>
{% endif %}
{% endif %}
</div>
{% if var.cached_availability.0 == 100 or var.initial %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box available">
@@ -240,6 +244,7 @@
</div>
<div class="col-md-2 col-sm-3 col-xs-6 price">
<p>
{% if not hide_prices %}
{% if not c.price_included %}
{% if item.original_price %}
<del><span class="sr-only">{% trans "Original price:" %}</span>
@@ -290,6 +295,7 @@
{% else %}
<span class="sr-only">{% trans "free" context "price" %}</span>
{% endif %}
{% endif %}
</p>
</div>
{% if item.cached_availability.0 == 100 or item.initial %}

View File

@@ -0,0 +1,191 @@
{% load i18n %}
{% load classname %}
{% load eventurl %}
{% load money %}
<div class="row-fluid">
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Change summary" %}
</h3>
</div>
<table class="panel-body table table-hover">
{% for op in operations %}
{% if op|classname == "ItemOperation" %}
<tr>
<td>
{% 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 %}
<span class="text-muted">
<br>
<small>{% blocktrans with positionid=op.position.addon_to.positionid %}Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
</span>
{% endif %}
</td>
<td class="text-right flip">
</td>
</tr>
{% elif op|classname == "SubeventOperation" %}
<tr>
<td>
{% blocktrans trimmed with positionid=op.position.positionid old=op.position.subevent new=op.subevent %}
Change date of position #{{ positionid }} from "{{ old }}" to "{{ new }}"
{% endblocktrans %}
</td>
<td class="text-right flip">
</td>
</tr>
{% elif op|classname == "PriceOperation" %}
<tr>
<td>
{% 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 %}
<span class="text-muted">
<br>
<small>{% blocktrans with positionid=op.position.addon_to.positionid %}Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
</span>
{% endif %}
</td>
<td class="text-right flip">
{% if not hide_prices %}
{{ op.price_diff|money:request.event.currency }}
{% endif %}
</td>
</tr>
{% elif op|classname == "AddOperation" %}
<tr>
<td>
{% 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 %}
<span class="text-muted">
<br>
<small>{% blocktrans with positionid=op.addon_to.positionid %}Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
</span>
{% endif %}
</td>
<td class="text-right flip">
{% if not hide_prices %}
{{ op.price.gross|money:request.event.currency }}
{% endif %}
</td>
</tr>
{% elif op|classname == "CancelOperation" %}
<tr>
<td>
{% 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 %}
<span class="text-muted">
<br>
<small>{% blocktrans with positionid=op.position.addon_to.positionid %}Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
</span>
{% endif %}
</td>
<td class="text-right flip">
{% if not hide_prices %}
{{ op.price_diff|money:request.event.currency }}
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
{% if not hide_prices %}
<tfoot>
<tr>
<td><strong>{% trans "Total price change" %}</strong></td>
<td class="text-right flip">
<strong>
{{ totaldiff|money:request.event.currency }}
</strong>
</td>
</tr>
{% if totaldiff %}
<tr>
<td><strong>{% trans "New order total" %}</strong></td>
<td class="text-right flip">
{{ totaldiff|add:order.total|money:request.event.currency }}
</td>
</tr>
<tr>
<td><strong>{% trans "You already paid" %}</strong></td>
<td class="text-right flip">
{{ order.payment_refund_sum|money:request.event.currency }}
</td>
</tr>
<tr>
<td>
{% if new_pending_sum > 0 %}
<strong>{% trans "You will need to pay" %}</strong>
<br>
<span class="text-muted">
{% trans "Your entire order will be considered unpaid until you paid this difference." %}
</span>
{% else %}
<strong>{% trans "You will be refunded" %}</strong>
<br>
<span class="text-muted">
{% 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 <strong>can not be sent back to you
automatically</strong>. Instead, the event organizer will need to initiate the transfer
manually. Please be patient as this might take a bit longer.
{% endblocktrans %}
{% endif %}
{% endif %}
</span>
{% endif %}
</td>
<td class="text-right flip">
<strong>
{{ new_pending_sum|money:request.event.currency }}
</strong>
</td>
</tr>
{% endif %}
</tfoot>
{% endif %}
</table>
</div>
</div>
{% for k, l in request.POST.lists %}
{% for v in l %}
<input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %}
{% endfor %}

View File

@@ -0,0 +1,58 @@
{% load i18n %}
{% load bootstrap3 %}
{% load rich_text %}
{% for position, addon_positions in formgroups.items %}
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
<strong>{{ position.item }}</strong>
{% if position.variation %}
{{ position.variation }}
{% endif %}
</h3>
</div>
<div class="panel-body addons">
<div class="form-order-change form-horizontal">
<div class="form-order-change-main">
{% if position.subevent %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Date" context "subevent" %}
</label>
<div class="col-md-9 form-control-text">
<ul class="addon-list">
{{ position.subevent.name }} &middot; {{ position.subevent.get_date_range_display_as_html }}
{% if position.event.settings.show_times %}
<span class="fa fa-clock-o" aria-hidden="true"></span>
{{ position.subevent.date_from|date:"TIME_FORMAT" }}
{% endif %}
</ul>
</div>
</div>
{% endif %}
{% for p in addon_positions %}
{% if p.pk != position.pk %}
{# Add-Ons #}
<legend>+ {{ p.item.name }}{% if p.variation %} {{ p.variation.value }}{% endif %}</legend>
{% endif %}
{% if p.attendee_name %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Attendee name" %}
</label>
<div class="col-md-9 form-control-text">
{{ p.attendee_name }}
</div>
</div>
{% endif %}
{% bootstrap_form p.form layout="checkout" %}
{% endfor %}
</div>
</div>
{% if position.addon_form %}
{% include "pretixpresale/event/fragment_addon_choice.html" with form=position.addon_form hide_prices=hide_prices %}
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -13,61 +13,7 @@
</h2>
<form method="post" href="">
{% csrf_token %}
{% for position, addon_positions in formgroups.items %}
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
<strong>{{ position.item }}</strong>
{% if position.variation %}
{{ position.variation }}
{% endif %}
</h3>
</div>
<div class="panel-body addons">
<div class="form-order-change form-horizontal">
<div class="form-order-change-main">
{% if position.subevent %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Date" context "subevent" %}
</label>
<div class="col-md-9 form-control-text">
<ul class="addon-list">
{{ position.subevent.name }} &middot; {{ position.subevent.get_date_range_display_as_html }}
{% if position.event.settings.show_times %}
<span class="fa fa-clock-o" aria-hidden="true"></span>
{{ position.subevent.date_from|date:"TIME_FORMAT" }}
{% endif %}
</ul>
</div>
</div>
{% endif %}
{% for p in addon_positions %}
{% if p.pk != position.pk %}
{# Add-Ons #}
<legend>+ {{ p.item.name }}{% if p.variation %} {{ p.variation.value }}{% endif %}</legend>
{% endif %}
{% if p.attendee_name %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Attendee name" %}
</label>
<div class="col-md-9 form-control-text">
{{ p.attendee_name }}
</div>
</div>
{% endif %}
{% bootstrap_form p.form layout="checkout" %}
{% endfor %}
</div>
</div>
{% if position.addon_form %}
{% include "pretixpresale/event/fragment_addon_choice.html" with form=position.addon_form %}
{% endif %}
</div>
</div>
{% endfor %}
{% include "pretixpresale/event/fragment_change_form.html" %}
<div class="row checkout-button-row">
<div class="col-md-4">

View File

@@ -17,193 +17,11 @@
{% csrf_token %}
<p>{% trans "Please confirm the following changes to your order." %}</p>
<div class="row-fluid">
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Change summary" %}
</h3>
</div>
<table class="panel-body table table-hover">
{% for op in operations %}
{% if op|classname == "ItemOperation" %}
<tr>
<td>
{% 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 %}
<span class="text-muted">
<br>
<small>{% blocktrans with positionid=op.position.addon_to.positionid %}
Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
</span>
{% endif %}
</td>
<td class="text-right flip">
</td>
</tr>
{% elif op|classname == "SubeventOperation" %}
<tr>
<td>
{% blocktrans trimmed with positionid=op.position.positionid old=op.position.subevent new=op.subevent %}
Change date of position #{{ positionid }} from "{{ old }}" to "{{ new }}"
{% endblocktrans %}
</td>
<td class="text-right flip">
</td>
</tr>
{% elif op|classname == "PriceOperation" %}
<tr>
<td>
{% 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 %}
<span class="text-muted">
<br>
<small>{% blocktrans with positionid=op.position.addon_to.positionid %}
Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
</span>
{% endif %}
</td>
<td class="text-right flip">
{{ op.price_diff|money:request.event.currency }}
</td>
</tr>
{% elif op|classname == "AddOperation" %}
<tr>
<td>
{% 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 %}
<span class="text-muted">
<br>
<small>{% blocktrans with positionid=op.addon_to.positionid %}Add-on product
to position #{{ positionid }}{% endblocktrans %}</small>
</span>
{% endif %}
</td>
<td class="text-right flip">
{{ op.price.gross|money:request.event.currency }}
</td>
</tr>
{% elif op|classname == "CancelOperation" %}
<tr>
<td>
{% 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 %}
<span class="text-muted">
<br>
<small>{% blocktrans with positionid=op.position.addon_to.positionid %}
Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
</span>
{% endif %}
</td>
<td class="text-right flip">
{{ op.price_diff|money:request.event.currency }}
</td>
</tr>
{% endif %}
{% endfor %}
<tfoot>
<tr>
<td><strong>{% trans "Total price change" %}</strong></td>
<td class="text-right flip">
<strong>
{{ totaldiff|money:request.event.currency }}
</strong>
</td>
</tr>
{% if totaldiff %}
<tr>
<td><strong>{% trans "New order total" %}</strong></td>
<td class="text-right flip">
{{ totaldiff|add:order.total|money:request.event.currency }}
</td>
</tr>
<tr>
<td><strong>{% trans "You already paid" %}</strong></td>
<td class="text-right flip">
{{ order.payment_refund_sum|money:request.event.currency }}
</td>
</tr>
<tr>
<td>
{% if new_pending_sum > 0 %}
<strong>{% trans "You will need to pay" %}</strong>
<br>
<span class="text-muted">
{% trans "Your entire order will be considered unpaid until you paid this difference." %}
</span>
{% else %}
<strong>{% trans "You will be refunded" %}</strong>
<br>
<span class="text-muted">
{% 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 <strong>can not be sent back to you
automatically</strong>. Instead, the event organizer will need to initiate the transfer
manually. Please be patient as this might take a bit longer.
{% endblocktrans %}
{% endif %}
{% endif %}
</span>
{% endif %}
</td>
<td class="text-right flip">
<strong>
{{ new_pending_sum|money:request.event.currency }}
</strong>
</td>
</tr>
{% endif %}
</tfoot>
</table>
</div>
</div>
{% for k, l in request.POST.lists %}
{% for v in l %}
<input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %}
{% endfor %}
{% include "pretixpresale/event/fragment_change_confirm.html" %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% eventurl request.event "presale:event.order.change" secret=order.secret order=order.code %}">
href="{% eventurl request.event "presale:event.order.change" secret=order.secret order=order.code %}">
{% trans "Back" %}
</a>
</div>
@@ -215,5 +33,4 @@
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -51,4 +51,33 @@
</div>
</div>
{% eventsignal event "pretix.presale.signals.position_info" order=order position=position request=request %}
{% if attendee_change_allowed %}
<div class="panel panel-default panel-cancellation">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Change your ticket" context "action" %}
</h3>
</div>
<div class="panel-body">
<p>
{% blocktrans trimmed %}
If you want to make changes to the components of your ticket, you can click on the following button.
{% endblocktrans %}
</p>
<p class="help-block">
{% 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 %}
</p>
<p>
<a href="{% eventurl event 'presale:event.order.position.change' secret=position.web_secret position=position.positionid order=order.code %}"
class="btn btn-default">
<span class="fa fa-edit" aria-hidden="true"></span>
{% trans "Change ticket" %}
</a>
</p>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -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 %}
<h2>
{% blocktrans trimmed %}
Change ticket
{% endblocktrans %}
</h2>
<form method="post" href="">
{% csrf_token %}
<p>{% 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." %}</p>
{% include "pretixpresale/event/fragment_change_form.html" with hide_prices=request.event.settings.hide_prices_from_attendees %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{{ view.get_position_url }}">
{% trans "Cancel" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Continue" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -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 %}
<h2>
{% blocktrans trimmed %}
Change ticket
{% endblocktrans %}
</h2>
<form method="post" class="form-horizontal" href="">
{% csrf_token %}
<p>{% trans "Please confirm the following changes to your ticket." %}</p>
{% include "pretixpresale/event/fragment_change_confirm.html" with hide_prices=request.event.settings.hide_prices_from_attendees %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% eventurl request.event "presale:event.order.position.change" secret=position.web_secret position=position.positionid order=order.code %}">
{% trans "Back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-primary btn-lg" type="submit" name="confirm" value="true">
{% trans "Perform changes" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -150,6 +150,9 @@ event_patterns = [
re_path(r'^ticket/(?P<order>[^/]+)/(?P<position>\d+)/(?P<secret>[A-Za-z0-9]+)/download/(?P<pid>[0-9]+)/(?P<output>[^/]+)$',
pretix.presale.views.order.OrderPositionDownload.as_view(),
name='event.order.position.download'),
re_path(r'^ticket/(?P<order>[^/]+)/(?P<position>\d+)/(?P<secret>[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(),

View File

@@ -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

View File

@@ -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):

View File

@@ -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()