diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index c1eca9204c..e4e0eb4f28 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -764,6 +764,7 @@ class EventSettingsSerializer(SettingsSerializer): 'cancel_allow_user_paid_refund_as_giftcard', 'cancel_allow_user_paid_require_approval', 'change_allow_user_variation', + 'change_allow_user_addons', 'change_allow_user_until', 'change_allow_user_price', 'primary_color', diff --git a/src/pretix/base/migrations/0203_orderposition_is_bundled.py b/src/pretix/base/migrations/0203_orderposition_is_bundled.py new file mode 100644 index 0000000000..cca982c311 --- /dev/null +++ b/src/pretix/base/migrations/0203_orderposition_is_bundled.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.2 on 2021-11-08 07:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0202_user_needs_password_change'), + ] + + operations = [ + migrations.AddField( + model_name='orderposition', + name='is_bundled', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/migrations/0204_orderposition_backfill_is_bundled.py b/src/pretix/base/migrations/0204_orderposition_backfill_is_bundled.py new file mode 100644 index 0000000000..31416ae293 --- /dev/null +++ b/src/pretix/base/migrations/0204_orderposition_backfill_is_bundled.py @@ -0,0 +1,46 @@ +# Generated by Django 3.2.2 on 2021-11-08 07:51 + +from django.db import migrations, models +from django.db.models import Count, OuterRef, Subquery +from django.db.models.functions import Coalesce + + +def fill_is_bundled(apps, schema_editor): + # We cannot really know if a position was bundled or an add-on, but we can at least guess + ItemBundle = apps.get_model("pretixbase", "ItemBundle") + OrderPosition = apps.get_model("pretixbase", "OrderPosition") + + for ib in ItemBundle.objects.iterator(): + OrderPosition.all.alias( + pos_earlier=Coalesce(Subquery( + OrderPosition.all.filter( + canceled=False, + addon_to=OuterRef('addon_to'), + item=ib.bundled_item, + variation=ib.bundled_variation, + positionid__lt=OuterRef('positionid'), + ).values('addon_to').order_by().annotate(c=Count('*')).values('c'), + output_field=models.IntegerField() + ), 0) + ).filter( + canceled=False, + addon_to__item=ib.base_item, + item=ib.bundled_item, + variation=ib.bundled_variation, + pos_earlier__lt=ib.count, + ).update( + is_bundled=True + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0203_orderposition_is_bundled'), + ] + + operations = [ + migrations.RunPython( + fill_is_bundled, + migrations.RunPython.noop, + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 69eb74c0bd..8f936b533e 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -581,6 +581,7 @@ class Order(LockModel, LoggedModel): Returns whether or not this order can be canceled by the user. """ from .checkin import Checkin + from .items import ItemAddOn if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID) or not self.count_positions: return False @@ -606,7 +607,10 @@ class Order(LockModel, LoggedModel): 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]) + return ( + (self.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])) or + (self.event.settings.change_allow_user_addons and ItemAddOn.objects.filter(base_item_id__in=[op.item_id for op in positions]).exists()) + ) @property @scopes_disabled() @@ -1306,6 +1310,7 @@ class AbstractPosition(models.Model): seat = models.ForeignKey( 'Seat', null=True, blank=True, on_delete=models.PROTECT ) + is_bundled = models.BooleanField(default=False) company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True) street = models.TextField(verbose_name=_('Address'), blank=True, null=True) @@ -2566,7 +2571,6 @@ class CartPosition(AbstractPosition): max_digits=10, decimal_places=2, null=True, blank=True ) - is_bundled = models.BooleanField(default=False) objects = ScopedManager(organizer='event__organizer') diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 6aa35939a1..0a7db50004 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -35,7 +35,7 @@ import json import logging -from collections import Counter, namedtuple +from collections import Counter, defaultdict, namedtuple from datetime import datetime, time, timedelta from decimal import Decimal from typing import List, Optional @@ -46,7 +46,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import ( - Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value, + Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value, ) from django.db.models.functions import Coalesce, Greatest from django.db.transaction import get_connection @@ -73,7 +73,7 @@ from pretix.base.models.orders import ( InvoiceAddress, OrderFee, OrderRefund, generate_secret, ) from pretix.base.models.organizer import TeamAPIToken -from pretix.base.models.tax import TaxRule +from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.reldate import RelativeDateWrapper from pretix.base.secrets import assign_ticket_secret @@ -122,8 +122,7 @@ error_messages = { 'from your cart.'), 'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We ' 'removed this item from your cart.'), - 'voucher_required': _('You need a valid voucher code to order one of the products in your cart. We removed this ' - 'item from your cart.'), + 'voucher_required': _('You need a valid voucher code to order one of the products.'), 'some_subevent_not_started': _('The presale period for one of the events in your cart has not yet started. The ' 'affected positions have been removed from your cart.'), 'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected ' @@ -131,6 +130,13 @@ error_messages = { 'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'), 'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'), 'country_blocked': _('One of the selected products is not available in the selected country.'), + 'not_for_sale': _('You selected a product which is not available for sale.'), + 'addon_invalid_base': _('You can not select an add-on for the selected product.'), + 'addon_duplicate_item': _('You can not select two variations of the same add-on product.'), + 'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'), + 'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the ' + 'product %(base)s.'), + 'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'), } logger = logging.getLogger(__name__) @@ -1261,15 +1267,15 @@ class OrderChangeManager: ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation')) SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent')) SeatOperation = namedtuple('SubeventOperation', ('position', 'seat')) - PriceOperation = namedtuple('PriceOperation', ('position', 'price')) + PriceOperation = namedtuple('PriceOperation', ('position', 'price', 'price_diff')) TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule')) MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) - CancelOperation = namedtuple('CancelOperation', ('position',)) + CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership')) SplitOperation = namedtuple('SplitOperation', ('position',)) - FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value')) - AddFeeOperation = namedtuple('AddFeeOperation', ('fee',)) - CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee',)) + FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) + AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) + CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee', 'price_diff')) RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',)) def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True): @@ -1386,7 +1392,7 @@ class OrderChangeManager: if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'): self._invoice_dirty = True - self._operations.append(self.PriceOperation(position, price)) + self._operations.append(self.PriceOperation(position, price, price.gross - position.price)) def change_tax_rule(self, position_or_fee, tax_rule: TaxRule): self._operations.append(self.TaxRuleOperation(position_or_fee, tax_rule)) @@ -1426,28 +1432,28 @@ class OrderChangeManager: new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency, override_tax_rate=new_rate) self._totaldiff += new_tax.gross - pos.price - self._operations.append(self.PriceOperation(pos, new_tax)) + self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price)) def cancel_fee(self, fee: OrderFee): self._totaldiff -= fee.value - self._operations.append(self.CancelFeeOperation(fee)) + self._operations.append(self.CancelFeeOperation(fee, -fee.value)) self._invoice_dirty = True def add_fee(self, fee: OrderFee): self._totaldiff += fee.value self._invoice_dirty = True - self._operations.append(self.AddFeeOperation(fee)) + self._operations.append(self.AddFeeOperation(fee, fee.value)) def change_fee(self, fee: OrderFee, value: Decimal): value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross') self._totaldiff += value.gross - fee.value self._invoice_dirty = True - self._operations.append(self.FeeValueOperation(fee, value)) + self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value)) def cancel(self, position: OrderPosition): self._totaldiff -= position.price self._quotadiff.subtract(position.quotas) - self._operations.append(self.CancelOperation(position)) + self._operations.append(self.CancelOperation(position, -position.price)) if position.seat: self._seatdiff.subtract([position.seat]) @@ -1472,7 +1478,7 @@ class OrderChangeManager: try: if price is None: price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address) - else: + elif not isinstance(price, TaxedPrice): price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address) except TaxRule.SaleNotAllowed: raise OrderError(self.error_messages['tax_rule_country_blocked']) @@ -1515,6 +1521,190 @@ class OrderChangeManager: self._operations.append(self.SplitOperation(position)) + def set_addons(self, addons): + if self._operations: + raise ValueError("Setting addons should be the first/only operation") + + # Prepare various containers to hold data later + current_addons = defaultdict(lambda: defaultdict(list)) # OrderPos -> currently attached add-ons + input_addons = defaultdict(Counter) # OrderPos -> final desired set of add-ons + selected_addons = defaultdict(Counter) # OrderPos, ItemAddOn -> final desired set of add-ons + opcache = {} # OrderPos.pk -> OrderPos + quota_diff = Counter() # Quota -> Number of usages + available_categories = defaultdict(set) # OrderPos -> Category IDs to choose from + price_included = defaultdict(dict) # OrderPos -> CategoryID -> bool(price is included) + toplevel_op = self.order.positions.filter( + addon_to__isnull=True + ).prefetch_related( + 'addons', 'item__addons', 'item__addons__addon_category' + ).select_related('item', 'variation') + + _items_cache = { + i.pk: i + for i in self.event.items.select_related('category').prefetch_related( + 'addons', 'bundles', 'addons__addon_category', 'quotas' + ).annotate( + has_variations=Count('variations'), + ).filter( + id__in=[a['item'] for a in addons] + ).order_by() + } + _variations_cache = { + v.pk: v + for v in ItemVariation.objects.filter(item__event=self.event).prefetch_related( + 'quotas' + ).select_related('item', 'item__event').filter( + id__in=[a['variation'] for a in addons if a.get('variation')] + ).order_by() + } + + # Prefill some of the cache containers + for op in toplevel_op: + if op.canceled: + continue + available_categories[op.pk] = {iao.addon_category_id for iao in op.item.addons.all()} + price_included[op.pk] = {iao.addon_category_id: iao.price_included for iao in op.item.addons.all()} + opcache[op.pk] = op + for a in op.addons.all(): + if a.canceled: + continue + + if not a.is_bundled: + current_addons[op][a.item_id, a.variation_id].append(a) + + # Create operations, perform various checks + for a in addons: + # Check whether the specified items are part of what we just fetched from the database + # If they are not, the user supplied item IDs which either do not exist or belong to + # a different event + if a['item'] not in _items_cache or (a['variation'] and a['variation'] not in _variations_cache): + raise OrderError(error_messages['not_for_sale']) + + # Only attach addons to things that are actually in this user's cart + if a['addon_to'] not in opcache: + raise OrderError(error_messages['addon_invalid_base']) + + op = opcache[a['addon_to']] + item = _items_cache[a['item']] + variation = _variations_cache[a['variation']] if a['variation'] is not None else None + + if item.category_id not in available_categories[op.pk]: + raise OrderError(error_messages['addon_invalid_base']) + + # Fetch all quotas. If there are no quotas, this item is not allowed to be sold. + quotas = list(item.quotas.filter(subevent=op.subevent) + if variation is None else variation.quotas.filter(subevent=op.subevent)) + if not quotas: + raise OrderError(error_messages['unavailable']) + + if (a['item'], a['variation']) in input_addons[op.id]: + raise OrderError(error_messages['addon_duplicate_item']) + + if item.require_voucher or op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher): + raise OrderError(error_messages['voucher_required']) + + if not item.is_available() or (variation and not variation.is_available()): + raise OrderError(error_messages['unavailable']) + + if self.order.sales_channel not in item.sales_channels or ( + variation and self.order.sales_channel not in variation.sales_channels): + raise OrderError(error_messages['unavailable']) + + if op.subevent and item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available(): + raise OrderError(error_messages['not_for_sale']) + + if op.subevent and variation and variation.pk in op.subevent.var_overrides and \ + not op.subevent.var_overrides[variation.pk].is_available(): + raise OrderError(error_messages['not_for_sale']) + + if item.has_variations and not variation: + raise OrderError(error_messages['not_for_sale']) + + if variation and variation.item_id != item.pk: + raise OrderError(error_messages['not_for_sale']) + + if op.subevent and op.subevent.presale_start and now() < op.subevent.presale_start: + raise OrderError(error_messages['not_started']) + + if (op.subevent and op.subevent.presale_has_ended) or self.event.presale_has_ended: + raise OrderError(error_messages['ended']) + + if item.require_bundling: + raise OrderError(error_messages['unavailable']) + + input_addons[op.id][a['item'], a['variation']] = a.get('count', 1) + selected_addons[op.id, item.category_id][a['item'], a['variation']] = a.get('count', 1) + + if price_included[op.pk].get(item.category_id): + price = TAXED_ZERO + else: + price = get_price( + item, variation, voucher=None, custom_price=a.get('price'), subevent=op.subevent, + custom_price_is_net=self.event.settings.display_net_prices, + invoice_address=self._invoice_address, + ) + + if a.get('count', 1) > len(current_addons[op][a['item'], a['variation']]): + # This add-on is new, add it to the cart + for quota in quotas: + quota_diff[quota] += a.get('count', 1) - len(current_addons[op][a['item'], a['variation']]) + + for i in range(a.get('count', 1) - len(current_addons[op][a['item'], a['variation']])): + self.add_position( + item=item, variation=variation, price=price, + addon_to=op, subevent=op.subevent, seat=None, + ) + + # Check constraints on the add-on combinations + for op in toplevel_op: + item = op.item + for iao in item.addons.all(): + selected = selected_addons[op.id, iao.addon_category_id] + n_per_i = Counter() + for (i, v), c in selected.items(): + n_per_i[i] += c + if sum(selected.values()) > iao.max_count: + # TODO: Proper i18n + # TODO: Proper pluralization + raise OrderError( + error_messages['addon_max_count'], + { + 'base': str(item.name), + 'max': iao.max_count, + 'cat': str(iao.addon_category.name), + } + ) + elif sum(selected.values()) < iao.min_count: + # TODO: Proper i18n + # TODO: Proper pluralization + raise OrderError( + error_messages['addon_min_count'], + { + 'base': str(item.name), + 'min': iao.min_count, + 'cat': str(iao.addon_category.name), + } + ) + elif any(v > 1 for v in n_per_i.values()) and not iao.multi_allowed: + raise OrderError( + error_messages['addon_no_multi'], + { + 'base': str(item.name), + 'cat': str(iao.addon_category.name), + } + ) + + # Detect removed add-ons and create RemoveOperations + for cp, al in list(current_addons.items()): + for k, v in al.items(): + input_num = input_addons[cp.id].get(k, 0) + current_num = len(current_addons[cp].get(k, [])) + if input_num < current_num: + for a in current_addons[cp][k][:current_num - input_num]: + if a.canceled: + continue + self.cancel(a) + def _check_seats(self): for seat, diff in self._seatdiff.items(): if diff <= 0: diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 0daca7a8dc..bd6ca615be 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1301,6 +1301,15 @@ DEFAULTS = { label=_("Customers can change the variation of the products they purchased"), ) }, + 'change_allow_user_addons': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Customers can change their selected add-on products"), + ) + }, 'change_allow_user_price': { 'default': 'gte', 'type': str, diff --git a/src/pretix/base/templatetags/classname.py b/src/pretix/base/templatetags/classname.py new file mode 100644 index 0000000000..1bc239e9b3 --- /dev/null +++ b/src/pretix/base/templatetags/classname.py @@ -0,0 +1,29 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django import template + +register = template.Library() + + +@register.filter +def classname(obj): + return obj.__class__.__name__ diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 7d8cc2bc7d..37a353e0da 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -639,6 +639,7 @@ class CancelSettingsForm(SettingsForm): 'change_allow_user_variation', 'change_allow_user_price', 'change_allow_user_until', + 'change_allow_user_addons', ] 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 e99b7275e3..cf0e76c10a 100644 --- a/src/pretix/control/templates/pretixcontrol/event/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/event/cancel.html @@ -42,13 +42,37 @@
{% trans "Order changes" %}
- {% blocktrans trimmed %} - Allowing users to change their order is a feature under development. Therefore, currently only specific changes (such as changing the variation of a product) are possible. More options might be added later. - {% endblocktrans %} +

+ {% blocktrans trimmed %} + Allowing customers to change their own orders is a complex process due to the many different options pretix provides. Therefore, this feature currently has the following + limitations: + {% endblocktrans %} +

+
    +
  • {% trans "It is possible to switch to a different variation of the same product, but not to an entirely different product (except for add-on products)." %}
  • +
  • {% trans "Changing the seat or the event date in an event series will become available in the future, but is not possible now." %}
  • +
  • {% trans "If a change leads to a price change, there will not be a change to fees such as payment, service, or shipping fees, even though an additional payment might be required." %}
  • +
  • {% trans "If an add-on product is newly added, the system currently does not validate if there are required questions or fields that need to be filled out." %}
  • +
  • {% trans "Customers currently cannot switch to a product variation or add an add-on product that requires them to use a voucher or membership." %}
  • +
  • {% trans "Additional constraints and validation steps added by plugins are not enforced." %}
  • +
{% bootstrap_field form.change_allow_user_variation layout="control" %} - {% bootstrap_field form.change_allow_user_price layout="control" %} + {% 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" %} +
+

+ {% blocktrans trimmed %} + If the change leads to a price reduction and automatic refunds are enabled for self-service cancellations, + the system will try to refund the money automatically. + {% endblocktrans %} + {% blocktrans trimmed %} + Refunds can be issued as a gift card if the respective option is set, but there is no customer choice between + gift card and direct refund. + {% endblocktrans %} +

+
diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 4943bc5352..b60a543ce7 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -475,7 +475,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): 'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation', ).order_by('pk'): formsetentry = { - 'cartpos': cartpos, + 'pos': cartpos, 'item': cartpos.item, 'variation': cartpos.variation, 'categories': [] @@ -582,13 +582,13 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): for i in category['items']: if i.has_variations: for v in i.available_variations: - val = int(self.request.POST.get(f'cp_{form["cartpos"].pk}_variation_{i.pk}_{v.pk}') or '0') - price = self.request.POST.get(f'cp_{form["cartpos"].pk}_variation_{i.pk}_{v.pk}_price') or '0' + val = int(self.request.POST.get(f'cp_{form["pos"].pk}_variation_{i.pk}_{v.pk}') or '0') + price = self.request.POST.get(f'cp_{form["pos"].pk}_variation_{i.pk}_{v.pk}_price') or '0' if val: selected[i, v] = val, price else: - val = int(self.request.POST.get(f'cp_{form["cartpos"].pk}_item_{i.pk}') or '0') - price = self.request.POST.get(f'cp_{form["cartpos"].pk}_item_{i.pk}_price') or '0' + val = int(self.request.POST.get(f'cp_{form["pos"].pk}_item_{i.pk}') or '0') + price = self.request.POST.get(f'cp_{form["pos"].pk}_item_{i.pk}_price') or '0' if val: selected[i, None] = val, price @@ -627,7 +627,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): validate_cart_addons.send( sender=self.event, addons={k: v[0] for k, v in selected.items()}, - base_position=form["cartpos"], + base_position=form["pos"], iao=category['iao'] ) except CartError as e: @@ -648,7 +648,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): for (i, v), (c, price) in selected.items(): data.append({ - 'addon_to': f['cartpos'].pk, + 'addon_to': f['pos'].pk, 'item': i.pk, 'variation': v.pk if v else None, 'count': c, diff --git a/src/pretix/presale/forms/order.py b/src/pretix/presale/forms/order.py index ba2759569d..7c59893357 100644 --- a/src/pretix/presale/forms/order.py +++ b/src/pretix/presale/forms/order.py @@ -57,7 +57,7 @@ class OrderPositionChangeForm(forms.Form): pname = str(i) variations = list(i.variations.all()) - if variations: + if variations and event.settings.change_allow_user_variation: current_quotas = ( instance.variation.quotas.filter(subevent=instance.subevent) if instance.variation @@ -126,6 +126,7 @@ class OrderPositionChangeForm(forms.Form): else: choices.append((str(i.pk), '%s' % pname)) self.fields['itemvar'].widget.attrs['disabled'] = True - self.fields['itemvar'].help_text = _('No other variations of this product exist.') + if event.settings.change_allow_user_variation: + self.fields['itemvar'].help_text = _('No other variations of this product exist.') self.fields['itemvar'].choices = choices diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html index 8826ec6a06..97edc2f553 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_addons.html @@ -24,327 +24,19 @@ -
+
- {% if form.cartpos.subevent %} + {% if form.pos.subevent %}

- {{ form.cartpos.subevent.name }} · {{ form.cartpos.subevent.get_date_range_display_as_html }} - {% if form.cartpos.event.settings.show_times %} + {{ form.pos.subevent.name }} · {{ form.pos.subevent.get_date_range_display_as_html }} + {% if form.pos.event.settings.show_times %} - {{ form.cartpos.subevent.date_from|date:"TIME_FORMAT" }} + {{ form.pos.subevent.date_from|date:"TIME_FORMAT" }} {% endif %}

{% endif %} - {% for c in form.categories %} -
- {{ c.category.name }} - {% if c.category.description %} - {{ c.category.description|rich_text }} - {% endif %} - {% if c.min_count == c.max_count %} -

- {% blocktrans trimmed count min_count=c.min_count %} - You need to choose exactly one option from this category. - {% plural %} - You need to choose {{ min_count }} options from this category. - {% endblocktrans %} -

- {% elif c.min_count == 0 and c.max_count >= c.items|length and not c.multi_allowed %} - {% elif c.min_count == 0 %} -

- {% blocktrans trimmed count max_count=c.max_count %} - You can choose {{ max_count }} option from this category. - {% plural %} - You can choose up to {{ max_count }} options from this category. - {% endblocktrans %} -

- {% else %} -

- {% blocktrans trimmed with min_count=c.min_count max_count=c.max_count %} - You can choose between {{ min_count }} and {{ max_count }} options from - this category. - {% endblocktrans %} -

- {% endif %} - {% for item in c.items %} - {% if item.has_variations %} -
-
-
- {% if item.picture %} - - {{ item.name }} - - {% endif %} -
-

{{ item.name }}

- {% if item.description %} -
- {{ item.description|localize|rich_text }} -
- {% endif %} - {% if item.min_per_order and item.min_per_order > 1 %} -

- - {% blocktrans trimmed with num=item.min_per_order %} - minimum amount to order: {{ num }} - {% endblocktrans %} - -

- {% endif %} -
-
-
-

- {% if c.price_included %} - {% trans "free" context "price" %} - {% elif item.free_price %} - {% blocktrans trimmed with price=item.min_price|money:event.currency %} - from {{ price }} - {% endblocktrans %} - {% elif item.min_price != item.max_price %} - - {% blocktrans trimmed with from_price=item.min_price|money:event.currency to_price=item.max_price|money:event.currency %} - from {{ from_price }} to {{ to_price }} - {% endblocktrans %} - - - {% elif not item.min_price and not item.max_price %} - {% else %} - {{ item.min_price|money:event.currency }} - {% endif %} -

-
-
- {% if not event.settings.show_variations_expanded %} - - {% endif %} -
-
-
-
- {% for var in item.available_variations %} -
-
-
{{ var }}
- {% if var.description %} -
- {{ var.description|localize|rich_text }} -
- {% endif %} - {% if item.do_show_quota_left %} - {% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %} - {% endif %} -
-
- {% if not c.price_included %} - {% if var.original_price %} - {% trans "Original price:" %} - {% if event.settings.display_net_prices %} - {{ var.original_price.net|money:event.currency }} - {% else %} - {{ var.original_price.gross|money:event.currency }} - {% endif %} - - {% trans "New price:" %} - {% endif %} - {% if item.free_price %} -
- {{ event.currency }} - -
- {% elif not var.display_price.gross %} - {% elif event.settings.display_net_prices %} - {{ var.display_price.net|money:event.currency }} - {% else %} - {{ var.display_price.gross|money:event.currency }} - {% endif %} - {% if item.original_price or var.original_price %} -
- {% endif %} - {% if item.includes_mixed_tax_rate %} - {% if event.settings.display_net_prices %} - {% trans "plus taxes" %} - {% else %} - {% trans "incl. taxes" %} - {% endif %} - {% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %} - {% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %} - plus {{ rate }}% {{ name }} - {% endblocktrans %} - {% elif var.display_price.rate and var.display_price.gross %} - {% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %} - incl. {{ rate }}% {{ name }} - {% endblocktrans %} - {% endif %} - {% else %} - {% trans "free" context "price" %} - {% endif %} -
- {% if var.cached_availability.0 == 100 or var.initial %} -
- {% if c.max_count == 1 or not c.multi_allowed %} - - {% else %} - - {% endif %} -
- {% else %} - {% include "pretixpresale/event/fragment_availability.html" with price=var.display_price.gross avail=var.cached_availability.0 event=event item=item var=var %} - {% endif %} -
-
- {% endfor %} -
-
- {% else %} -
-
- {% if item.picture %} - - {{ item.name }} - - {% endif %} -
-

{{ item.name }}

- {% if item.description %} -
- {{ item.description|localize|rich_text }} -
- {% endif %} - {% if item.do_show_quota_left %} - {% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %} - {% endif %} - {% if item.min_per_order and item.min_per_order > 1 %} -

- - {% blocktrans trimmed with num=item.min_per_order %} - minimum amount to order: {{ num }} - {% endblocktrans %} - -

- {% endif %} -
-
-
-

- {% if not c.price_included %} - {% if item.original_price %} - {% trans "Original price:" %} - {% if event.settings.display_net_prices %} - {{ item.original_price.net|money:event.currency }} - {% else %} - {{ item.original_price.gross|money:event.currency }} - {% endif %} - - {% trans "New price:" %} - {% endif %} - {% if item.free_price %} -

- {{ event.currency }} - -
- {% elif not item.display_price.gross %} - {% elif event.settings.display_net_prices %} - {{ item.display_price.net|money:event.currency }} - {% else %} - {{ item.display_price.gross|money:event.currency }} - {% endif %} - {% if item.original_price %} - - {% endif %} - {% if item.includes_mixed_tax_rate %} - {% if event.settings.display_net_prices %} - {% trans "plus taxes" %} - {% else %} - {% trans "incl. taxes" %} - {% endif %} - {% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %} - {% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %} - plus {{ rate }}% {{ name }} - {% endblocktrans %} - {% elif item.display_price.rate and item.display_price.gross %} - {% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %} - incl. {{ rate }}% {{ name }} - {% endblocktrans %} - {% endif %} - {% else %} - {% trans "free" context "price" %} - {% endif %} -

-
- {% if item.cached_availability.0 == 100 or item.initial %} -
- {% if c.max_count == 1 or not c.multi_allowed %} - - {% else %} - - {% endif %} -
- {% else %} - {% include "pretixpresale/event/fragment_availability.html" with price=item.display_price.gross avail=item.cached_availability.0 event=event item=item var=0 %} - {% endif %} -
-
- {% endif %} - {% endfor %} -
- {% empty %} - - {% trans "There are no add-ons available for this product." %} - - {% endfor %} + {% include "pretixpresale/event/fragment_addon_choice.html" with form=form %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html b/src/pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html new file mode 100644 index 0000000000..7af88c0abb --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html @@ -0,0 +1,316 @@ +{% load i18n %} +{% load l10n %} +{% load eventurl %} +{% load money %} +{% load thumb %} +{% load eventsignal %} +{% load rich_text %} +{% for c in form.categories %} +
+ {{ c.category.name }} + {% if c.category.description %} + {{ c.category.description|rich_text }} + {% endif %} + {% if c.min_count == c.max_count %} +

+ {% blocktrans trimmed count min_count=c.min_count %} + You need to choose exactly one option from this category. + {% plural %} + You need to choose {{ min_count }} options from this category. + {% endblocktrans %} +

+ {% elif c.min_count == 0 and c.max_count >= c.items|length and not c.multi_allowed %} + {% elif c.min_count == 0 %} +

+ {% blocktrans trimmed count max_count=c.max_count %} + You can choose {{ max_count }} option from this category. + {% plural %} + You can choose up to {{ max_count }} options from this category. + {% endblocktrans %} +

+ {% else %} +

+ {% blocktrans trimmed with min_count=c.min_count max_count=c.max_count %} + You can choose between {{ min_count }} and {{ max_count }} options from + this category. + {% endblocktrans %} +

+ {% endif %} + {% for item in c.items %} + {% if item.has_variations %} +
+
+
+ {% if item.picture %} + + {{ item.name }} + + {% endif %} +
+

{{ item.name }}

+ {% if item.description %} +
+ {{ item.description|localize|rich_text }} +
+ {% endif %} + {% if item.min_per_order and item.min_per_order > 1 %} +

+ + {% blocktrans trimmed with num=item.min_per_order %} + minimum amount to order: {{ num }} + {% endblocktrans %} + +

+ {% endif %} +
+
+
+

+ {% if c.price_included %} + {% trans "free" context "price" %} + {% elif item.free_price %} + {% blocktrans trimmed with price=item.min_price|money:event.currency %} + from {{ price }} + {% endblocktrans %} + {% elif item.min_price != item.max_price %} + + {% blocktrans trimmed with from_price=item.min_price|money:event.currency to_price=item.max_price|money:event.currency %} + from {{ from_price }} to {{ to_price }} + {% endblocktrans %} + + + {% elif not item.min_price and not item.max_price %} + {% else %} + {{ item.min_price|money:event.currency }} + {% endif %} +

+
+
+ {% if not event.settings.show_variations_expanded %} + + {% endif %} +
+
+
+
+ {% for var in item.available_variations %} +
+
+
{{ var }}
+ {% if var.description %} +
+ {{ var.description|localize|rich_text }} +
+ {% endif %} + {% if item.do_show_quota_left %} + {% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %} + {% endif %} +
+
+ {% if not c.price_included %} + {% if var.original_price %} + {% trans "Original price:" %} + {% if event.settings.display_net_prices %} + {{ var.original_price.net|money:event.currency }} + {% else %} + {{ var.original_price.gross|money:event.currency }} + {% endif %} + + {% trans "New price:" %} + {% endif %} + {% if item.free_price %} +
+ {{ event.currency }} + +
+ {% elif not var.display_price.gross %} + {% elif event.settings.display_net_prices %} + {{ var.display_price.net|money:event.currency }} + {% else %} + {{ var.display_price.gross|money:event.currency }} + {% endif %} + {% if item.original_price or var.original_price %} +
+ {% endif %} + {% if item.includes_mixed_tax_rate %} + {% if event.settings.display_net_prices %} + {% trans "plus taxes" %} + {% else %} + {% trans "incl. taxes" %} + {% endif %} + {% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %} + plus {{ rate }}% {{ name }} + {% endblocktrans %} + {% elif var.display_price.rate and var.display_price.gross %} + {% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %} + incl. {{ rate }}% {{ name }} + {% endblocktrans %} + {% endif %} + {% else %} + {% trans "free" context "price" %} + {% endif %} +
+ {% if var.cached_availability.0 == 100 or var.initial %} +
+ {% if c.max_count == 1 or not c.multi_allowed %} + + {% else %} + + {% endif %} +
+ {% else %} + {% include "pretixpresale/event/fragment_availability.html" with price=var.display_price.gross avail=var.cached_availability.0 event=event item=item var=var %} + {% endif %} +
+
+ {% endfor %} +
+
+ {% else %} +
+
+ {% if item.picture %} + + {{ item.name }} + + {% endif %} +
+

{{ item.name }}

+ {% if item.description %} +
+ {{ item.description|localize|rich_text }} +
+ {% endif %} + {% if item.do_show_quota_left %} + {% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %} + {% endif %} + {% if item.min_per_order and item.min_per_order > 1 %} +

+ + {% blocktrans trimmed with num=item.min_per_order %} + minimum amount to order: {{ num }} + {% endblocktrans %} + +

+ {% endif %} +
+
+
+

+ {% if not c.price_included %} + {% if item.original_price %} + {% trans "Original price:" %} + {% if event.settings.display_net_prices %} + {{ item.original_price.net|money:event.currency }} + {% else %} + {{ item.original_price.gross|money:event.currency }} + {% endif %} + + {% trans "New price:" %} + {% endif %} + {% if item.free_price %} +

+ {{ event.currency }} + +
+ {% elif not item.display_price.gross %} + {% elif event.settings.display_net_prices %} + {{ item.display_price.net|money:event.currency }} + {% else %} + {{ item.display_price.gross|money:event.currency }} + {% endif %} + {% if item.original_price %} + + {% endif %} + {% if item.includes_mixed_tax_rate %} + {% if event.settings.display_net_prices %} + {% trans "plus taxes" %} + {% else %} + {% trans "incl. taxes" %} + {% endif %} + {% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %} + plus {{ rate }}% {{ name }} + {% endblocktrans %} + {% elif item.display_price.rate and item.display_price.gross %} + {% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %} + incl. {{ rate }}% {{ name }} + {% endblocktrans %} + {% endif %} + {% else %} + {% trans "free" context "price" %} + {% endif %} +

+
+ {% if item.cached_availability.0 == 100 or item.initial %} +
+ {% if c.max_count == 1 or not c.multi_allowed %} + + {% else %} + + {% endif %} +
+ {% else %} + {% include "pretixpresale/event/fragment_availability.html" with price=item.display_price.gross avail=item.cached_availability.0 event=event item=item var=0 %} + {% endif %} +
+
+ {% endif %} + {% endfor %} +
+{% empty %} + + {% trans "There are no add-ons available for this product." %} + +{% endfor %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order_change.html b/src/pretix/presale/templates/pretixpresale/event/order_change.html index 4a0c3ba58d..70dd4975ec 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order_change.html +++ b/src/pretix/presale/templates/pretixpresale/event/order_change.html @@ -2,7 +2,9 @@ {% load i18n %} {% load bootstrap3 %} {% load rich_text %} -{% block title %}{% trans "Modify order" %}{% endblock %} +{% block title %}{% blocktrans trimmed with code=order.code %} + Change order: {{ code }} +{% endblocktrans %}{% endblock %} {% block content %}

{% blocktrans trimmed with code=order.code %} @@ -11,7 +13,7 @@

{% csrf_token %} - {% for position, positions in formgroups.items %} + {% for position, addon_positions in formgroups.items %}

@@ -21,43 +23,48 @@ {% endif %}

-
+
- {% if position.subevent %} -
- -
-
    - {{ pos.subevent.name }} · {{ pos.subevent.get_date_range_display_as_html }} - {% if pos.event.settings.show_times %} - - {{ pos.subevent.date_from|date:"TIME_FORMAT" }} - {% endif %} -
-
-
- {% endif %} - - {% for p in positions %} - {% if p.pk != position.pk %} - {# Add-Ons #} - + {{ p.item.name }}{% if p.variation %} – {{ p.variation.value }}{% endif %} - {% endif %} - {% if p.attendee_name %} +
+ {% if position.subevent %}
- {{ p.attendee_name }} +
    + {{ pos.subevent.name }} · {{ pos.subevent.get_date_range_display_as_html }} + {% if pos.event.settings.show_times %} + + {{ pos.subevent.date_from|date:"TIME_FORMAT" }} + {% endif %} +
{% endif %} - {% bootstrap_form p.form layout="checkout" %} - {% endfor %} + + {% 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 %} @@ -71,7 +78,7 @@
diff --git a/src/pretix/presale/templates/pretixpresale/event/order_change_confirm.html b/src/pretix/presale/templates/pretixpresale/event/order_change_confirm.html new file mode 100644 index 0000000000..8259134cc4 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/order_change_confirm.html @@ -0,0 +1,219 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load classname %} +{% load eventurl %} +{% load money %} +{% block title %}{% blocktrans trimmed with code=order.code %} + Change order: {{ code }} +{% endblocktrans %}{% endblock %} +{% block content %} +

+ {% blocktrans trimmed with code=order.code %} + Change order: {{ code }} + {% endblocktrans %} +

+ + + {% 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 positionid=op.position.positionid item=op.item.name variation=op.variation.value %} + Add position #{{ positionid }} ({{ item }} – {{ variation }}) + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with positionid=op.position.positionid item=op.item.name %} + Add position #{{ positionid }} ({{ 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 %} +
+ +
+ +
+
+
+
+ +{% endblock %} diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 46611c0178..42d6f44e86 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -38,19 +38,20 @@ import json import mimetypes import os import re -from collections import OrderedDict +from collections import OrderedDict, defaultdict from decimal import Decimal from django import forms from django.conf import settings from django.contrib import messages +from django.core.exceptions import ValidationError from django.core.files import File from django.db import transaction from django.db.models import Exists, OuterRef, Q, Sum from django.http import ( FileResponse, Http404, HttpResponseRedirect, JsonResponse, ) -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404, redirect, render from django.utils.decorators import method_decorator from django.utils.functional import cached_property from django.utils.timezone import now @@ -65,6 +66,7 @@ from pretix.base.models.orders import ( CachedCombinedTicket, InvoiceAddress, OrderFee, OrderPayment, OrderRefund, QuestionAnswer, ) +from pretix.base.models.tax import TaxedPrice from pretix.base.payment import PaymentException from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task, @@ -72,7 +74,8 @@ from pretix.base.services.invoices import ( ) from pretix.base.services.mail import SendMailException from pretix.base.services.orders import ( - OrderChangeManager, OrderError, cancel_order, change_payment_provider, + OrderChangeManager, OrderError, _try_auto_refund, cancel_order, + change_payment_provider, error_messages, ) from pretix.base.services.pricing import get_price from pretix.base.services.tickets import generate, invalidate_cache @@ -90,6 +93,7 @@ from pretix.presale.signals import question_form_fields_overrides from pretix.presale.views import ( CartMixin, EventViewMixin, iframe_entry_view_wrapper, ) +from pretix.presale.views.event import get_grouped_items from pretix.presale.views.robots import NoSearchIndexViewMixin @@ -1165,6 +1169,8 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): def formdict(self): storage = OrderedDict() for pos in self.positions: + if self.request.event.settings.change_allow_user_addons and pos.addon_to_id: + continue if pos.addon_to_id: if pos.addon_to not in storage: storage[pos.addon_to] = [] @@ -1186,10 +1192,11 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): def positions(self): positions = list( self.order.positions.select_related('item', 'item__tax_rule').prefetch_related( - 'item__variations', + 'item__variations', 'addons', ) ) quota_cache = {} + item_cache = {} try: ia = self.order.invoice_address except InvoiceAddress.DoesNotExist: @@ -1198,6 +1205,87 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): 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) + + if p.addon_to_id is None and self.request.event.settings.change_allow_user_addons: + p.addon_form = { + 'pos': p, + 'categories': [] + } + current_addon_products = defaultdict(list) + for a in p.addons.all(): + if a.canceled: + continue + if not a.is_bundled: + current_addon_products[a.item_id, a.variation_id].append(a) + + for iao in p.item.addons.all(): + ckey = '{}-{}'.format(p.subevent.pk if p.subevent else 0, iao.addon_category.pk) + + if ckey not in item_cache: + # Get all items to possibly show + items, _btn = get_grouped_items( + self.request.event, + subevent=p.subevent, + voucher=None, + channel=self.order.sales_channel, + base_qs=iao.addon_category.items, + allow_addons=True, + quota_cache=quota_cache, + memberships=( + self.request.customer.usable_memberships( + for_event=p.subevent or self.request.event, + testmode=self.order.testmode + ) + if self.order.customer else None + ), + ) + item_cache[ckey] = items + else: + items = item_cache[ckey] + + for i in items: + i.allow_waitinglist = False + + if i.has_variations: + for v in i.available_variations: + v.initial = len(current_addon_products[i.pk, v.pk]) + if v.initial and i.free_price: + a = current_addon_products[i.pk, v.pk][0] + v.initial_price = TaxedPrice( + net=a.price - a.tax_value, + gross=a.price, + tax=a.tax_value, + name=a.item.tax_rule.name if a.item.tax_rule else "", + rate=a.tax_rate, + ) + else: + v.initial_price = v.display_price + i.expand = any(v.initial for v in i.available_variations) + else: + i.initial = len(current_addon_products[i.pk, None]) + if i.initial and i.free_price: + a = current_addon_products[i.pk, None][0] + i.initial_price = TaxedPrice( + net=a.price - a.tax_value, + gross=a.price, + tax=a.tax_value, + name=a.item.tax_rule.name if a.item.tax_rule else "", + rate=a.tax_rate, + ) + else: + i.initial_price = i.display_price + + if items: + p.addon_form['categories'].append({ + 'category': iao.addon_category, + 'price_included': iao.price_included, + 'multi_allowed': iao.multi_allowed, + 'min_count': iao.min_count, + 'max_count': iao.max_count, + 'iao': iao, + 'items': [i for i in items if not i.require_voucher] + }) + return positions def _process_change(self, ocm): @@ -1241,7 +1329,56 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): return False return True - def post(self, *args, **kwargs): + def _clean_category(self, form, category): + selected = {} + for i in category['items']: + if i.has_variations: + for v in i.available_variations: + val = int(self.request.POST.get(f'cp_{form["pos"].pk}_variation_{i.pk}_{v.pk}') or '0') + price = self.request.POST.get(f'cp_{form["pos"].pk}_variation_{i.pk}_{v.pk}_price') or '0' + if val: + selected[i, v] = val, price + else: + val = int(self.request.POST.get(f'cp_{form["pos"].pk}_item_{i.pk}') or '0') + price = self.request.POST.get(f'cp_{form["pos"].pk}_item_{i.pk}_price') or '0' + if val: + selected[i, None] = val, price + + if sum(a[0] for a in selected.values()) > category['max_count']: + # TODO: Proper pluralization + raise ValidationError( + _(error_messages['addon_max_count']), + 'addon_max_count', + { + 'base': str(form['pos'].item.name), + 'max': category['max_count'], + 'cat': str(category['category'].name), + } + ) + elif sum(a[0] for a in selected.values()) < category['min_count']: + # TODO: Proper pluralization + raise ValidationError( + _(error_messages['addon_min_count']), + 'addon_min_count', + { + 'base': str(form['pos'].item.name), + 'min': category['min_count'], + 'cat': str(category['category'].name), + } + ) + elif any(sum(v[0] for k, v in selected.items() if k[0] == i) > 1 for i in category['items']) and not category['multi_allowed']: + raise ValidationError( + _(error_messages['addon_no_multi']), + 'addon_no_multi', + { + 'base': str(form['pos'].item.name), + 'cat': str(category['category'].name), + } + ) + + return selected + + def post(self, request, *args, **kwargs): was_paid = self.order.status == Order.STATUS_PAID ocm = OrderChangeManager( self.order, @@ -1249,28 +1386,106 @@ class OrderChange(EventViewMixin, OrderDetailMixin, TemplateView): notify=True, reissue_invoice=True, ) - form_valid = self._process_change(ocm) + + addons_data = [] + for p in self.positions: + if p.addon_to_id or not hasattr(p, 'addon_form'): + continue + for c in p.addon_form['categories']: + try: + selected = self._clean_category(p.addon_form, c) + except ValidationError as e: + messages.error(request, e.message % e.params if e.params else e.message) + return self.get(request, *args, **kwargs) + + for (i, v), (c, price) in selected.items(): + addons_data.append({ + 'addon_to': p.pk, + 'item': i.pk, + 'variation': v.pk if v else None, + 'count': c, + 'price': price, + }) + try: + ocm.set_addons(addons_data) + except OrderError as e: + messages.error(self.request, str(e)) + form_valid = False + else: + form_valid = self._process_change(ocm) if not form_valid: messages.error(self.request, _('An error occurred. Please see the details below.')) else: try: - ocm.commit(check_quotas=True) + self._validate_total_diff(ocm) except OrderError as e: messages.error(self.request, str(e)) - else: - if self.order.status != Order.STATUS_PAID and was_paid: - messages.success(self.request, _('The order has been changed. You can now proceed by paying the open amount of {amount}.').format( - amount=money_filter(self.order.pending_sum, self.request.event.currency) - )) - return redirect(eventreverse(self.request.event, 'presale:event.order.pay.change', kwargs={ - 'order': self.order.code, - 'secret': self.order.secret - })) + if "confirm" in request.POST: + try: + ocm.commit(check_quotas=True) + except OrderError as e: + messages.error(self.request, str(e)) else: - messages.success(self.request, _('The order has been changed.')) + if self.order.pending_sum < 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" + ) + refund_as_giftcard = self.request.event.settings.cancel_allow_user_paid_refund_as_giftcard == 'force' + if auto_refund: + try: + _try_auto_refund(self.order, refund_as_giftcard=refund_as_giftcard) + except OrderError as e: + messages.error(self.request, str(e)) + if self.order.status != Order.STATUS_PAID and was_paid: + messages.success(self.request, _('The order has been changed. You can now proceed by paying the open amount of {amount}.').format( + amount=money_filter(self.order.pending_sum, self.request.event.currency) + )) + return redirect(eventreverse(self.request.event, 'presale:event.order.pay.change', kwargs={ + 'order': self.order.code, + 'secret': self.order.secret + })) + else: + messages.success(self.request, _('The order has been changed.')) + + return redirect(self.get_order_url()) + elif not ocm._operations: + messages.info(self.request, _('You did not make any changes.')) return redirect(self.get_order_url()) + else: + new_pending_sum = self.order.pending_sum + ocm._totaldiff + can_auto_refund = False + if new_pending_sum < Decimal('0.00'): + 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 self.get(*args, **kwargs) + return render(request, 'pretixpresale/event/order_change_confirm.html', { + '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, + }) + + 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': + 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': + 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': + 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: + self.order.set_expires( + now(), + self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True)) + ) + 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.')) diff --git a/src/pretix/static/pretixpresale/scss/_checkout.scss b/src/pretix/static/pretixpresale/scss/_checkout.scss index 9bec3d6fea..61b5aa59d5 100644 --- a/src/pretix/static/pretixpresale/scss/_checkout.scss +++ b/src/pretix/static/pretixpresale/scss/_checkout.scss @@ -109,6 +109,13 @@ font-weight: bold; } } +.form-order-change-main { + border-bottom: 1px solid $hr-border; + background: $panel-footer-bg; + + margin: -15px -15px 15px; + padding: 15px 15px 0; +} .profile-pre-select { background: $panel-footer-bg; margin-top: -15px; @@ -171,4 +178,4 @@ .panel-heading-flex-gap { flex: 1; } -} \ No newline at end of file +} diff --git a/src/tests/presale/test_order_change.py b/src/tests/presale/test_order_change.py new file mode 100644 index 0000000000..9afd140fef --- /dev/null +++ b/src/tests/presale/test_order_change.py @@ -0,0 +1,1337 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of +# the Apache License 2.0 can be obtained at . +# +# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A +# full history of changes and contributors is available at . +# +# This file contains Apache-licensed contributions copyrighted by: Flavia Bastos +# +# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under the License. + +import datetime +from decimal import Decimal + +from bs4 import BeautifulSoup +from django.test import TestCase +from django.utils.timezone import now +from django_scopes import scopes_disabled +from tests.base import extract_form_fields + +from pretix.base.models import ( + Event, Item, ItemAddOn, ItemCategory, ItemVariation, Order, OrderPosition, + Organizer, Question, Quota, SubEventItemVariation, +) +from pretix.base.models.orders import OrderPayment +from pretix.base.reldate import RelativeDate, RelativeDateWrapper + + +class BaseOrdersTest(TestCase): + + @scopes_disabled() + def setUp(self): + super().setUp() + self.orga = Organizer.objects.create(name='CCC', slug='ccc') + self.event = Event.objects.create( + organizer=self.orga, name='30C3', slug='30c3', + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), + presale_end=now() + datetime.timedelta(days=5), + plugins='pretix.plugins.stripe,pretix.plugins.banktransfer,tests.testdummy', + live=True + ) + self.event.settings.set('payment_banktransfer__enabled', True) + self.event.settings.set('ticketoutput_testdummy__enabled', True) + + self.tr = self.event.tax_rules.create(name="VAT", rate=10) + self.category = ItemCategory.objects.create(event=self.event, name="Everything", position=0) + self.quota_shirts = Quota.objects.create(event=self.event, name='Shirts', size=2) + self.shirt = Item.objects.create(event=self.event, name='T-Shirt', category=self.category, default_price=12) + self.quota_shirts.items.add(self.shirt) + self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14, value="Red") + self.shirt_blue = ItemVariation.objects.create(item=self.shirt, value="Blue") + self.quota_shirts.variations.add(self.shirt_red) + self.quota_shirts.variations.add(self.shirt_blue) + self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=5) + self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', + category=self.category, default_price=23, + admission=True) + self.quota_tickets.items.add(self.ticket) + self.event.settings.set('attendee_names_asked', True) + self.question = Question.objects.create(question='Foo', type=Question.TYPE_STRING, event=self.event, + required=False) + self.ticket.questions.add(self.question) + + self.order = Order.objects.create( + status=Order.STATUS_PENDING, + event=self.event, + email='admin@localhost', + datetime=now() - datetime.timedelta(days=3), + expires=now() + datetime.timedelta(days=11), + total=Decimal("23"), + locale='en' + ) + self.ticket_pos = OrderPosition.objects.create( + order=self.order, + item=self.ticket, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Peter"} + ) + self.deleted_pos = OrderPosition.objects.create( + order=self.order, + item=self.ticket, + variation=None, + price=Decimal("23"), + attendee_name_parts={'full_name': "Lukas"}, + canceled=True + ) + + +class OrderChangeVariationTest(BaseOrdersTest): + def test_change_not_allowed(self): + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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' + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_red, + price=Decimal("14"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + shirt_pos.refresh_from_db() + assert shirt_pos.variation == self.shirt_blue + assert shirt_pos.price == Decimal('12.00') + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PENDING + assert self.order.total == Decimal('35.00') + + def test_change_variation_require_higher_price(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'gt' + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_red, + price=Decimal("14"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + assert response.status_code == 200 + assert 'alert-danger' in response.content.decode() + + shirt_pos.variation = self.shirt_blue + shirt_pos.price = Decimal('12.00') + shirt_pos.save() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + shirt_pos.refresh_from_db() + assert shirt_pos.variation == self.shirt_red + assert shirt_pos.price == Decimal('14.00') + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PENDING + assert self.order.total == Decimal('37.00') + + shirt_pos.variation = self.shirt_blue + shirt_pos.price = Decimal('14.00') + shirt_pos.save() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, + follow=True + ) + shirt_pos.refresh_from_db() + assert 'alert-danger' in response.content.decode() + assert shirt_pos.variation == self.shirt_blue + assert shirt_pos.price == Decimal('14.00') + + def test_change_variation_require_higher_equal_price(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'gte' + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_red, + price=Decimal("14"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + assert response.status_code == 200 + assert 'alert-danger' in response.content.decode() + + shirt_pos.variation = self.shirt_blue + shirt_pos.price = Decimal('12.00') + shirt_pos.save() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + shirt_pos.refresh_from_db() + assert shirt_pos.variation == self.shirt_red + assert shirt_pos.price == Decimal('14.00') + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PENDING + assert self.order.total == Decimal('37.00') + + shirt_pos.variation = self.shirt_blue + shirt_pos.price = Decimal('14.00') + shirt_pos.save() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + shirt_pos.refresh_from_db() + assert 'alert-success' in response.content.decode() + assert shirt_pos.variation == self.shirt_red + assert shirt_pos.price == Decimal('14.00') + + def test_change_variation_require_equal_price(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'eq' + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_blue, + price=Decimal("12"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + assert response.status_code == 200 + assert 'alert-danger' in response.content.decode() + + def test_change_variation_require_same_product(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'any' + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_blue, + price=Decimal("12"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.ticket.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + assert response.status_code == 200 + assert 'alert-danger' in response.content.decode() + + def test_change_variation_require_quota(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'any' + + with scopes_disabled(): + q = self.event.quotas.create(name="s2", size=0) + q.items.add(self.shirt) + q.variations.add(self.shirt_red) + + with scopes_disabled(): + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_blue, + price=Decimal("12"), + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + assert response.status_code == 200 + assert 'alert-danger' in response.content.decode() + + q.variations.add(self.shirt_blue) + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + shirt_pos.refresh_from_db() + assert shirt_pos.variation == self.shirt_red + assert shirt_pos.price == Decimal('14.00') + + def test_change_paid_to_pending(self): + self.event.settings.change_allow_user_variation = True + self.event.settings.change_allow_user_price = 'any' + self.order.status = Order.STATUS_PAID + self.order.save() + + with scopes_disabled(): + self.order.payments.create(provider="manual", amount=Decimal('35.00'), state=OrderPayment.PAYMENT_STATE_CONFIRMED) + shirt_pos = OrderPosition.objects.create( + order=self.order, + item=self.shirt, + variation=self.shirt_blue, + price=Decimal("12"), + ) + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + assert 'The order has been changed. You can now proceed by paying the open amount of €2.00.' in response.content.decode() + shirt_pos.refresh_from_db() + assert shirt_pos.variation == self.shirt_red + assert shirt_pos.price == Decimal('14.00') + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PENDING + assert self.order.pending_sum == Decimal('2.00') + + +class OrderChangeAddonsTest(BaseOrdersTest): + + @scopes_disabled() + def setUp(self): + super().setUp() + + self.workshopcat = ItemCategory.objects.create(name="Workshops", is_addon=True, event=self.event) + self.workshopquota = Quota.objects.create(event=self.event, name='Workshop 1', size=5) + self.workshop1 = Item.objects.create(event=self.event, name='Workshop 1', + category=self.workshopcat, default_price=Decimal('12.00'), + tax_rule=self.tr) + self.workshop2 = Item.objects.create(event=self.event, name='Workshop 2', + category=self.workshopcat, default_price=Decimal('12.00'), + tax_rule=self.tr) + self.workshop2a = ItemVariation.objects.create(item=self.workshop2, value='Workshop 2a') + self.workshop2b = ItemVariation.objects.create(item=self.workshop2, value='Workshop 2b') + self.workshopquota.items.add(self.workshop1) + self.workshopquota.items.add(self.workshop2) + self.workshopquota.variations.add(self.workshop2a) + self.workshopquota.variations.add(self.workshop2b) + self.iao = ItemAddOn.objects.create( + base_item=self.ticket, addon_category=self.workshopcat, max_count=1, min_count=0, multi_allowed=False + ) + self.event.settings.change_allow_user_addons = True + + def test_disabled(self): + self.event.settings.change_allow_user_addons = False + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 302 + + def test_no_config(self): + self.iao.base_item = self.shirt + self.iao.save() + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 302 + + def test_no_change(self): + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + }, + follow=True + ) + assert 'alert-info' in response.content.decode() + + def test_add_addon(self): + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]') + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.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' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + new_pos = self.ticket_pos.addons.get() + assert new_pos.item == self.workshop1 + assert new_pos.price == Decimal('12.00') + self.order.refresh_from_db() + assert self.order.total == Decimal('35.00') + + def test_add_addon_free_price(self): + self.workshop1.free_price = True + self.workshop1.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]') + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}': '1', + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}_price': '50.00', + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + new_pos = self.ticket_pos.addons.get() + assert new_pos.item == self.workshop1 + assert new_pos.price == Decimal('50.00') + self.order.refresh_from_db() + assert self.order.total == Decimal('73.00') + + def test_add_addon_free_price_net(self): + self.event.settings.display_net_prices = True + self.workshop1.free_price = True + self.workshop1.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]') + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}': '1', + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}_price': '50.00', + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + new_pos = self.ticket_pos.addons.get() + assert new_pos.item == self.workshop1 + assert new_pos.price == Decimal('55.00') + self.order.refresh_from_db() + assert self.order.total == Decimal('78.00') + + def test_remove_addon(self): + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.total += Decimal("12") + self.order.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]')[0].attrs['checked'] + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + a = self.ticket_pos.addons.get() + assert a.canceled + self.order.refresh_from_db() + assert self.order.total == Decimal('23.00') + + def test_increase_existing_addon_free_price_net(self): + self.event.settings.display_net_prices = True + self.iao.multi_allowed = True + self.iao.max_count = 2 + self.iao.save() + self.workshop1.free_price = True + self.workshop1.save() + + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("55"), + tax_rule=self.tr, + tax_rate=Decimal("10"), + tax_value=Decimal("5"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.total += Decimal("55") + self.order.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]')[0].attrs['value'] == '1' + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}_price]')[0].attrs['value'] == '50.00' + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}': '2', + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}_price': '100.00', + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + # only the price of the new addon is changed! + assert self.ticket_pos.addons.count() == 2 + a = self.ticket_pos.addons.first() + assert a.item == self.workshop1 + assert a.price == Decimal('55.00') + a = self.ticket_pos.addons.last() + assert a.item == self.workshop1 + assert a.price == Decimal('110.00') + + def test_change_addon(self): + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.total += Decimal("12") + self.order.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]')[0].attrs['checked'] + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + # todo: should this keep questions? + a = self.ticket_pos.addons.get(canceled=False) + assert a.item == self.workshop2 + assert a.variation == self.workshop2a + + def test_paid_to_pending_expiry_date(self): + self.order.status = Order.STATUS_PAID + self.order.expires = now() - datetime.timedelta(days=12) + self.order.save() + with scopes_disabled(): + self.order.payments.create( + provider="manual", + amount=self.order.total, + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + ) + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]') + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.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' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + new_pos = self.ticket_pos.addons.get() + assert new_pos.item == self.workshop1 + assert new_pos.price == Decimal('12.00') + self.order.refresh_from_db() + assert self.order.total == Decimal('35.00') + assert self.order.pending_sum == Decimal('12.00') + assert self.order.expires > now() + + def test_quota_sold_out(self): + self.workshopquota.size = 0 + self.workshopquota.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert not doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]') + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.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' + response = 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 + ) + assert 'alert-danger' in response.content.decode() + + with scopes_disabled(): + assert self.ticket_pos.addons.count() == 0 + + def test_quota_sold_out_replace(self): + self.workshopquota.size = 1 + self.workshopquota.save() + + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.total += Decimal("12") + self.order.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]')[0].attrs['checked'] + # TODO: Technically, it is allowed to do this change, although the frontend currently does not allow it + # We test for the backend behaviour anyways + assert not doc.select(f'input[name=cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}]') + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + a = self.ticket_pos.addons.get(canceled=False) + assert a.item == self.workshop2 + assert a.variation == self.workshop2a + + def _assert_ws2a_not_allowed(self): + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 2a' not in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert not doc.select(f'input[name=cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}]') + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '1' + }, + follow=True + ) + assert 'alert-danger' in response.content.decode() or 'alert-info' in response.content.decode() + + with scopes_disabled(): + assert self.ticket_pos.addons.count() == 0 + + def test_voucher_required(self): + self.workshop2.require_voucher = True + self.workshop2.save() + self._assert_ws2a_not_allowed() + + def test_forbidden_require_bundling(self): + self.workshop2.require_bundling = True + self.workshop2.save() + self._assert_ws2a_not_allowed() + + def test_forbidden_sales_channel(self): + self.workshop2.sales_channels = ['pretixpos'] + self.workshop2.save() + self._assert_ws2a_not_allowed() + + def test_forbidden_var_sales_channel(self): + self.workshop2a.sales_channels = ['pretixpos'] + self.workshop2a.save() + self._assert_ws2a_not_allowed() + + def test_forbidden_inactive(self): + self.workshop2.active = False + self.workshop2.save() + self._assert_ws2a_not_allowed() + + def test_forbidden_var_inactive(self): + self.workshop2a.active = False + self.workshop2a.save() + self._assert_ws2a_not_allowed() + + def test_forbidden_over(self): + self.workshop2.available_until = now() - datetime.timedelta(days=3) + self.workshop2.save() + self._assert_ws2a_not_allowed() + + def test_forbidden_var_over(self): + self.workshop2a.available_until = now() - datetime.timedelta(days=3) + self.workshop2a.save() + self._assert_ws2a_not_allowed() + + def test_forbidden_membership(self): + self.workshop2a.require_membership = True + self.workshop2a.save() + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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' + response = 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 + ) + assert 'alert-danger' in response.content.decode() + + @scopes_disabled() + def _subevent_setup(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name="Date", date_from=now()) + self.ticket_pos.subevent = se + self.ticket_pos.save() + self.workshopquota.subevent = se + self.workshopquota.save() + return se + + def test_forbidden_disabled_for_subevent(self): + se = self._subevent_setup() + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 2' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}]') + + SubEventItemVariation.objects.create(subevent=se, variation=self.workshop2a, disabled=True) + + self._assert_ws2a_not_allowed() + + def test_presale_has_ended(self): + self.event.presale_end = now() - datetime.timedelta(days=1) + self.event.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 2a' in response.content.decode() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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 'has ended' in response.content.decode() + + with scopes_disabled(): + assert self.ticket_pos.addons.count() == 0 + + def test_presale_last_payment_term_only_relevant_if_additional_charge(self): + self.order.status = Order.STATUS_PAID + self.order.save() + self._subevent_setup() + self.event.settings.set('payment_term_last', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from', minutes_before=None) + )) + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 2a' in response.content.decode() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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 'no longer being accepted' in response.content.decode() + + with scopes_disabled(): + assert self.ticket_pos.addons.count() == 0 + + self.workshop2a.default_price = Decimal('0.00') + self.workshop2a.save() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + new_pos = self.ticket_pos.addons.get() + assert new_pos.item == self.workshop2 + assert new_pos.price == Decimal('0.00') + self.order.refresh_from_db() + assert self.order.total == Decimal('23.00') + + def test_multi_allowed_and_max_count_enforced(self): + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '2' + }, + follow=True + ) + assert 'alert-danger' in response.content.decode() + + self.iao.max_count = 2 + self.iao.multi_allowed = True + self.iao.save() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '2' + }, + follow=True + ) + assert 'alert-danger' not in response.content.decode() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_variation_{self.workshop2.pk}_{self.workshop2a.pk}': '3' + }, + follow=True + ) + assert 'alert-danger' in response.content.decode() + + def test_min_count_enforced(self): + self.iao.min_count = 1 + self.iao.save() + + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.total += Decimal("12") + self.order.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]')[0].attrs['checked'] + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + }, + follow=True + ) + assert 'alert-danger' in response.content.decode() + + def test_allow_user_price_gte(self): + self.event.settings.change_allow_user_price = 'gte' + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.total += Decimal("12") + self.order.save() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + }, + follow=True + ) + assert 'alert-danger' in response.content.decode() + assert 'reduces' in response.content.decode() + + def test_allow_user_price_eq(self): + self.event.settings.change_allow_user_price = 'eq' + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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_allow_user_price_gt(self): + self.event.settings.change_allow_user_price = 'gt' + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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() + + self.workshop2a.default_price = Decimal('0.00') + self.workshop2a.save() + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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 'increases' in response.content.decode() + + def test_ignore_bundled_positions(self): + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.total += Decimal("12") + OrderPosition.objects.create( + order=self.order, + item=self.workshop2, + variation=self.workshop2a, + is_bundled=True, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.total += Decimal("12") + self.order.save() + + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]')[0].attrs['checked'] + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = 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 + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + a = self.ticket_pos.addons.get(item=self.workshop1) + assert a.canceled + a = self.ticket_pos.addons.get(item=self.workshop2) + assert not a.canceled + self.order.refresh_from_db() + assert self.order.total == Decimal('35.00') + + def test_refund_auto(self): + self.event.settings.cancel_allow_user_paid_refund_as_giftcard = 'off' + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.status = Order.STATUS_PAID + self.order.total += Decimal("12") + self.order.save() + self.order.payments.create(provider='testdummy_partialrefund', amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED) + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {}, 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.canceled + self.order.refresh_from_db() + assert self.order.total == Decimal('23.00') + assert self.order.refunds.exists() + + def test_refund_manually(self): + self.event.settings.cancel_allow_user_paid_refund_as_giftcard = 'manually' + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.status = Order.STATUS_PAID + self.order.total += Decimal("12") + self.order.save() + self.order.payments.create(provider='testdummy_partialrefund', amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED) + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {}, 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.canceled + self.order.refresh_from_db() + assert self.order.total == Decimal('23.00') + assert not self.order.refunds.exists() + + def test_refund_giftcard(self): + self.event.settings.cancel_allow_user_paid_refund_as_giftcard = 'force' + with scopes_disabled(): + OrderPosition.objects.create( + order=self.order, + item=self.workshop1, + variation=None, + price=Decimal("12"), + addon_to=self.ticket_pos, + attendee_name_parts={'full_name': "Peter"} + ) + self.order.status = Order.STATUS_PAID + self.order.total += Decimal("12") + self.order.save() + self.order.payments.create(provider='testdummy_partialrefund', amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED) + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {}, 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.canceled + self.order.refresh_from_db() + assert self.order.total == Decimal('23.00') + r = self.order.refunds.get() + assert r.provider == 'giftcard' diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index 6e4dff8112..d0b1d15ef6 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -1403,294 +1403,3 @@ class OrdersTest(BaseOrdersTest): self.order.secret, a.pk, match.group(1)) ) assert response.status_code == 404 - - def test_change_not_allowed(self): - response = self.client.get( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.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' - - with scopes_disabled(): - shirt_pos = OrderPosition.objects.create( - order=self.order, - item=self.shirt, - variation=self.shirt_red, - price=Decimal("14"), - ) - response = self.client.get( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) - ) - assert response.status_code == 200 - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, follow=True) - self.assertRedirects(response, - '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, - self.order.secret), - target_status_code=200) - shirt_pos.refresh_from_db() - assert shirt_pos.variation == self.shirt_blue - assert shirt_pos.price == Decimal('12.00') - self.order.refresh_from_db() - assert self.order.status == Order.STATUS_PENDING - assert self.order.total == Decimal('35.00') - - def test_change_variation_require_higher_price(self): - self.event.settings.change_allow_user_variation = True - self.event.settings.change_allow_user_price = 'gt' - - with scopes_disabled(): - shirt_pos = OrderPosition.objects.create( - order=self.order, - item=self.shirt, - variation=self.shirt_red, - price=Decimal("14"), - ) - response = self.client.get( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) - ) - assert response.status_code == 200 - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, follow=True) - assert response.status_code == 200 - assert 'alert-danger' in response.content.decode() - - shirt_pos.variation = self.shirt_blue - shirt_pos.price = Decimal('12.00') - shirt_pos.save() - - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), - { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, - follow=True - ) - self.assertRedirects(response, - '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, - self.order.secret), - target_status_code=200) - shirt_pos.refresh_from_db() - assert shirt_pos.variation == self.shirt_red - assert shirt_pos.price == Decimal('14.00') - self.order.refresh_from_db() - assert self.order.status == Order.STATUS_PENDING - assert self.order.total == Decimal('37.00') - - shirt_pos.variation = self.shirt_blue - shirt_pos.price = Decimal('14.00') - shirt_pos.save() - - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), - { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, - follow=True - ) - shirt_pos.refresh_from_db() - assert 'alert-danger' in response.content.decode() - assert shirt_pos.variation == self.shirt_blue - assert shirt_pos.price == Decimal('14.00') - - def test_change_variation_require_higher_equal_price(self): - self.event.settings.change_allow_user_variation = True - self.event.settings.change_allow_user_price = 'gte' - - with scopes_disabled(): - shirt_pos = OrderPosition.objects.create( - order=self.order, - item=self.shirt, - variation=self.shirt_red, - price=Decimal("14"), - ) - response = self.client.get( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) - ) - assert response.status_code == 200 - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, follow=True) - assert response.status_code == 200 - assert 'alert-danger' in response.content.decode() - - shirt_pos.variation = self.shirt_blue - shirt_pos.price = Decimal('12.00') - shirt_pos.save() - - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), - { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, - follow=True - ) - self.assertRedirects(response, - '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, - self.order.secret), - target_status_code=200) - shirt_pos.refresh_from_db() - assert shirt_pos.variation == self.shirt_red - assert shirt_pos.price == Decimal('14.00') - self.order.refresh_from_db() - assert self.order.status == Order.STATUS_PENDING - assert self.order.total == Decimal('37.00') - - shirt_pos.variation = self.shirt_blue - shirt_pos.price = Decimal('14.00') - shirt_pos.save() - - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), - { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, - follow=True - ) - shirt_pos.refresh_from_db() - assert 'alert-success' in response.content.decode() - assert shirt_pos.variation == self.shirt_red - assert shirt_pos.price == Decimal('14.00') - - def test_change_variation_require_equal_price(self): - self.event.settings.change_allow_user_variation = True - self.event.settings.change_allow_user_price = 'eq' - - with scopes_disabled(): - shirt_pos = OrderPosition.objects.create( - order=self.order, - item=self.shirt, - variation=self.shirt_blue, - price=Decimal("12"), - ) - response = self.client.get( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) - ) - assert response.status_code == 200 - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, follow=True) - assert response.status_code == 200 - assert 'alert-danger' in response.content.decode() - - def test_change_variation_require_same_product(self): - self.event.settings.change_allow_user_variation = True - self.event.settings.change_allow_user_price = 'any' - - with scopes_disabled(): - shirt_pos = OrderPosition.objects.create( - order=self.order, - item=self.shirt, - variation=self.shirt_blue, - price=Decimal("12"), - ) - response = self.client.get( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) - ) - assert response.status_code == 200 - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { - f'op-{shirt_pos.pk}-itemvar': f'{self.ticket.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, follow=True) - assert response.status_code == 200 - assert 'alert-danger' in response.content.decode() - - def test_change_variation_require_quota(self): - self.event.settings.change_allow_user_variation = True - self.event.settings.change_allow_user_price = 'any' - - with scopes_disabled(): - q = self.event.quotas.create(name="s2", size=0) - q.items.add(self.shirt) - q.variations.add(self.shirt_red) - - with scopes_disabled(): - shirt_pos = OrderPosition.objects.create( - order=self.order, - item=self.shirt, - variation=self.shirt_blue, - price=Decimal("12"), - ) - response = self.client.get( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) - ) - assert response.status_code == 200 - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, follow=True) - assert response.status_code == 200 - assert 'alert-danger' in response.content.decode() - - q.variations.add(self.shirt_blue) - - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, follow=True) - self.assertRedirects(response, - '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, - self.order.secret), - target_status_code=200) - shirt_pos.refresh_from_db() - assert shirt_pos.variation == self.shirt_red - assert shirt_pos.price == Decimal('14.00') - - def test_change_paid_to_pending(self): - self.event.settings.change_allow_user_variation = True - self.event.settings.change_allow_user_price = 'any' - self.order.status = Order.STATUS_PAID - self.order.save() - - with scopes_disabled(): - self.order.payments.create(provider="manual", amount=Decimal('35.00'), state=OrderPayment.PAYMENT_STATE_CONFIRMED) - shirt_pos = OrderPosition.objects.create( - order=self.order, - item=self.shirt, - variation=self.shirt_blue, - price=Decimal("12"), - ) - - response = self.client.get( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) - ) - assert response.status_code == 200 - response = self.client.post( - '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), - { - f'op-{shirt_pos.pk}-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', - f'op-{self.ticket_pos.pk}-itemvar': f'{self.ticket.pk}', - }, - follow=True - ) - self.assertRedirects(response, - '/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, - self.order.secret), - target_status_code=200) - assert 'The order has been changed. You can now proceed by paying the open amount of €2.00.' in response.content.decode() - shirt_pos.refresh_from_db() - assert shirt_pos.variation == self.shirt_red - assert shirt_pos.price == Decimal('14.00') - self.order.refresh_from_db() - assert self.order.status == Order.STATUS_PENDING - assert self.order.pending_sum == Decimal('2.00')