diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py
index 0809cfe24c..34afa9ef5d 100644
--- a/src/pretix/api/serializers/event.py
+++ b/src/pretix/api/serializers/event.py
@@ -623,6 +623,9 @@ class EventSettingsSerializer(serializers.Serializer):
'cancel_allow_user_paid_adjust_fees_explanation',
'cancel_allow_user_paid_refund_as_giftcard',
'cancel_allow_user_paid_require_approval',
+ 'change_allow_user_variation',
+ 'change_allow_user_until',
+ 'change_allow_user_price',
]
def __init__(self, *args, **kwargs):
diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py
index 9f3dd0605d..46578d97c1 100644
--- a/src/pretix/base/email.py
+++ b/src/pretix/base/email.py
@@ -315,6 +315,51 @@ def base_placeholders(sender, **kwargs):
}
),
),
+ SimpleFunctionalMailTextPlaceholder(
+ 'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
+ event,
+ 'presale:event.order.modify', kwargs={
+ 'order': order.code,
+ 'secret': order.secret,
+ }
+ ), lambda event: build_absolute_uri(
+ event,
+ 'presale:event.order.modify', kwargs={
+ 'order': 'F8VVL',
+ 'secret': '6zzjnumtsx136ddy',
+ }
+ ),
+ ),
+ SimpleFunctionalMailTextPlaceholder(
+ 'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
+ event,
+ 'presale:event.order.change', kwargs={
+ 'order': order.code,
+ 'secret': order.secret,
+ }
+ ), lambda event: build_absolute_uri(
+ event,
+ 'presale:event.order.change', kwargs={
+ 'order': 'F8VVL',
+ 'secret': '6zzjnumtsx136ddy',
+ }
+ ),
+ ),
+ SimpleFunctionalMailTextPlaceholder(
+ 'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
+ event,
+ 'presale:event.order.cancel', kwargs={
+ 'order': order.code,
+ 'secret': order.secret,
+ }
+ ), lambda event: build_absolute_uri(
+ event,
+ 'presale:event.order.cancel', kwargs={
+ 'order': 'F8VVL',
+ 'secret': '6zzjnumtsx136ddy',
+ }
+ ),
+ ),
SimpleFunctionalMailTextPlaceholder(
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
event,
diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py
index bbb67525a5..603dc652d7 100644
--- a/src/pretix/base/models/items.py
+++ b/src/pretix/base/models/items.py
@@ -378,9 +378,9 @@ class Item(LoggedModel):
'but only for fixed bundles!')
)
allow_cancel = models.BooleanField(
- verbose_name=_('Allow product to be canceled'),
+ verbose_name=_('Allow product to be canceled or changed'),
default=True,
- help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, '
+ help_text=_('If this is checked, the usual cancellation and order change settings of this event apply. If this is unchecked, '
'orders containing this product can not be canceled by users but only by you.')
)
min_per_order = models.IntegerField(
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index b0faf10435..4b62a0c9dc 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -434,6 +434,19 @@ class Order(LockModel, LoggedModel):
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED) and self.count_positions
)
+ @cached_property
+ def user_change_deadline(self):
+ until = self.event.settings.get('change_allow_user_until', as_type=RelativeDateWrapper)
+ if until:
+ if self.event.has_subevents:
+ terms = [
+ until.datetime(se)
+ for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
+ ]
+ return min(terms) if terms else None
+ else:
+ return until.datetime(self.event)
+
@cached_property
def user_cancel_deadline(self):
if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'):
@@ -466,6 +479,36 @@ class Order(LockModel, LoggedModel):
fee += self.event.settings.cancel_allow_user_paid_keep
return round_decimal(fee, self.event.currency)
+ @property
+ @scopes_disabled()
+ def user_change_allowed(self) -> bool:
+ """
+ Returns whether or not this order can be canceled by the user.
+ """
+ from .checkin import Checkin
+
+ if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID) or not self.count_positions:
+ return False
+
+ if self.cancellation_requests.exists():
+ return False
+ positions = list(
+ self.positions.all().annotate(
+ has_variations=Exists(ItemVariation.objects.filter(item_id=OuterRef('item_id'))),
+ has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
+ ).select_related('item').prefetch_related('issued_gift_cards')
+ )
+ cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
+ if not cancelable or not positions:
+ return False
+ for op in positions:
+ if op.issued_gift_cards.all():
+ return False
+ if self.user_change_deadline and now() > self.user_change_deadline:
+ return False
+
+ return self.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])
+
@property
@scopes_disabled()
def user_cancel_allowed(self) -> bool:
@@ -474,7 +517,7 @@ class Order(LockModel, LoggedModel):
"""
from .checkin import Checkin
- if self.cancellation_requests.exists():
+ if self.cancellation_requests.exists() or not self.cancel_allowed():
return False
positions = list(
self.positions.all().annotate(
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index ecdd0cd4f1..a895dd0a88 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -1131,7 +1131,7 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
try:
order.send_mail(
email_subject, email_template, email_context,
- 'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices,
+ 'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True,
)
except SendMailException:
logger.exception('Order changed email could not be sent')
@@ -1869,9 +1869,10 @@ class OrderChangeManager:
def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last()
- if self.reissue_invoice and i and self._invoice_dirty:
- self._invoices.append(generate_cancellation(i))
- if invoice_qualified(self.order):
+ if self.reissue_invoice and self._invoice_dirty:
+ if i:
+ self._invoices.append(generate_cancellation(i))
+ if (i or self.event.settings.invoice_generate == 'True') and invoice_qualified(self.order):
self._invoices.append(generate_invoice(self.order))
def _check_complete_cancel(self):
diff --git a/src/pretix/base/services/quotas.py b/src/pretix/base/services/quotas.py
index 22f99c5eed..7d6a97ef00 100644
--- a/src/pretix/base/services/quotas.py
+++ b/src/pretix/base/services/quotas.py
@@ -89,7 +89,7 @@ class QuotaAvailability:
def compute(self, now_dt=None):
now_dt = now_dt or now()
- quotas = list(self._queue)
+ quotas = list(set(self._queue))
quotas_original = list(self._queue)
self._queue.clear()
if not quotas:
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index fe535662bf..e176198a06 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -925,6 +925,46 @@ DEFAULTS = {
"multiple event dates, the earliest date will be used."),
)
},
+ 'change_allow_user_variation': {
+ 'default': 'False',
+ 'type': bool,
+ 'form_class': forms.BooleanField,
+ 'serializer_class': serializers.BooleanField,
+ 'form_kwargs': dict(
+ label=_("Customers can change the variation of the products they purchased"),
+ )
+ },
+ 'change_allow_user_price': {
+ 'default': 'gt',
+ 'type': str,
+ 'form_class': forms.ChoiceField,
+ 'serializer_class': serializers.ChoiceField,
+ 'serializer_kwargs': dict(
+ choices=(
+ ('gt', _('Only allow changes if the resulting price is higher or equal than the previous price.')),
+ ('eq', _('Only allow changes if the resulting price is equal to the previous price.')),
+ ('any', _('Allow changes regardless of price, even if this results in a refund.')),
+ )
+ ),
+ 'form_kwargs': dict(
+ label=_("Requirement for changed prices"),
+ choices=(
+ ('gt', _('Only allow changes if the resulting price is higher or equal than the previous price.')),
+ ('eq', _('Only allow changes if the resulting price is equal to the previous price.')),
+ ('any', _('Allow changes regardless of price, even if this results in a refund.')),
+ ),
+ widget=forms.RadioSelect,
+ ),
+ },
+ 'change_allow_user_until': {
+ 'default': None,
+ 'type': RelativeDateWrapper,
+ 'form_class': RelativeDateTimeField,
+ 'serializer_class': SerializerRelativeDateTimeField,
+ 'form_kwargs': dict(
+ label=_("Do not allow changes after"),
+ )
+ },
'cancel_allow_user': {
'default': 'True',
'type': bool,
diff --git a/src/pretix/base/templatetags/money.py b/src/pretix/base/templatetags/money.py
index 75e5adac33..a06d3a1634 100644
--- a/src/pretix/base/templatetags/money.py
+++ b/src/pretix/base/templatetags/money.py
@@ -37,7 +37,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
arg,
floatformat(value, 2)
)
- return format_currency(value, arg, locale=translation.get_language())
+ return format_currency(value, arg, locale=translation.get_language()[:2])
except:
return '{} {}'.format(
arg,
diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py
index 9c1c12f923..d0e6750852 100644
--- a/src/pretix/control/forms/event.py
+++ b/src/pretix/control/forms/event.py
@@ -575,6 +575,9 @@ class CancelSettingsForm(SettingsForm):
'cancel_allow_user_paid_adjust_fees_explanation',
'cancel_allow_user_paid_refund_as_giftcard',
'cancel_allow_user_paid_require_approval',
+ 'change_allow_user_variation',
+ 'change_allow_user_price',
+ 'change_allow_user_until',
]
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 d2cdd9ae14..5ef9f4bc7d 100644
--- a/src/pretix/control/templates/pretixcontrol/event/cancel.html
+++ b/src/pretix/control/templates/pretixcontrol/event/cancel.html
@@ -38,6 +38,17 @@
{% endif %}
+
- {% if order.cancel_allowed and order.user_cancel_allowed %}
+ {% if order.user_change_allowed or order.user_cancel_allowed %}
- {% trans "Cancellation" context "action" %}
+ {% trans "Change or cancel your order" context "action" %}
-
- {% if order.status == "p" and order.total != 0 %}
- {% if order.user_cancel_fee >= order.total %}
+
+ {% if order.user_change_allowed %}
+
- {% if request.event.settings.cancel_allow_user_paid_require_approval %}
- {% blocktrans trimmed %}
- You can request to cancel this order, but you will not receive a refund.
- {% endblocktrans %}
- {% else %}
- {% blocktrans trimmed %}
- You can cancel this order, but you will not receive a refund.
- {% endblocktrans %}
- {% endif %}
- {% trans "This will invalidate all tickets in this order." %}
+ {% blocktrans trimmed %}
+ If you want to make changes to the products you bought, you can click on the button to change your order.
+ {% endblocktrans %}
- {% elif order.user_cancel_fee %}
-
- {% if request.event.settings.cancel_allow_user_paid_require_approval %}
- {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %}
- You can request to cancel this order. If your request is approved, a cancellation
- fee of {{ fee }} will be kept and you will receive a refund of
- the remainder.
- {% endblocktrans %}
- {% else %}
- {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %}
- You can cancel this order. In this case, a cancellation fee of {{ fee }}
- will be kept and you will receive a refund of the remainder.
- {% endblocktrans %}
- {% endif %}
- {% if 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." %}
- {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %}
- {% trans "The refund can be issued to your original payment method or as a gift card." %}
- {% else %}
- {% trans "The refund will be issued to your original payment method." %}
- {% endif %}
- {% trans "This will invalidate all tickets in this order." %}
-
- {% else %}
-
- {% if request.event.settings.cancel_allow_user_paid_require_approval %}
- {% blocktrans trimmed %}
- You can request to cancel this order. If your request is approved, you get a full
- refund.
- {% endblocktrans %}
- {% else %}
- {% blocktrans trimmed %}
- You can cancel this order and receive a full refund.
- {% endblocktrans %}
- {% endif %}
- {% if 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." %}
- {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %}
- {% trans "The refund can be issued to your original payment method or as a gift card." %}
- {% else %}
- {% trans "The refund will be issued to your original payment method." %}
- {% endif %}
- {% trans "This will invalidate all tickets in this order." %}
-
- {% blocktrans trimmed %}
- You can cancel this order using the following button.
- {% endblocktrans %}
- {% trans "This will invalidate all tickets in this order." %}
-
+ {% if order.status == "p" and order.total != 0 %}
+ {% if order.user_cancel_fee >= order.total %}
+
+ {% if request.event.settings.cancel_allow_user_paid_require_approval %}
+ {% blocktrans trimmed %}
+ You can request to cancel this order, but you will not receive a refund.
+ {% endblocktrans %}
+ {% else %}
+ {% blocktrans trimmed %}
+ You can cancel this order, but you will not receive a refund.
+ {% endblocktrans %}
+ {% endif %}
+ {% trans "This will invalidate all tickets in this order." %}
+
+ {% elif order.user_cancel_fee %}
+
+ {% if request.event.settings.cancel_allow_user_paid_require_approval %}
+ {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %}
+ You can request to cancel this order. If your request is approved, a cancellation
+ fee of {{ fee }} will be kept and you will receive a refund of
+ the remainder.
+ {% endblocktrans %}
+ {% else %}
+ {% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %}
+ You can cancel this order. In this case, a cancellation fee of {{ fee }}
+ will be kept and you will receive a refund of the remainder.
+ {% endblocktrans %}
+ {% endif %}
+ {% if 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." %}
+ {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %}
+ {% trans "The refund can be issued to your original payment method or as a gift card." %}
+ {% else %}
+ {% trans "The refund will be issued to your original payment method." %}
+ {% endif %}
+ {% trans "This will invalidate all tickets in this order." %}
+
+ {% else %}
+
+ {% if request.event.settings.cancel_allow_user_paid_require_approval %}
+ {% blocktrans trimmed %}
+ You can request to cancel this order. If your request is approved, you get a full
+ refund.
+ {% endblocktrans %}
+ {% else %}
+ {% blocktrans trimmed %}
+ You can cancel this order and receive a full refund.
+ {% endblocktrans %}
+ {% endif %}
+ {% if 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." %}
+ {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %}
+ {% trans "The refund can be issued to your original payment method or as a gift card." %}
+ {% else %}
+ {% trans "The refund will be issued to your original payment method." %}
+ {% endif %}
+ {% trans "This will invalidate all tickets in this order." %}
+
+ {% blocktrans trimmed %}
+ You can cancel this order using the following button.
+ {% endblocktrans %}
+ {% trans "This will invalidate all tickets in this order." %}
+