mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Allow customers to change add-ons on existing orders (#2283)
This commit is contained in:
@@ -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',
|
||||
|
||||
17
src/pretix/base/migrations/0203_orderposition_is_bundled.py
Normal file
17
src/pretix/base/migrations/0203_orderposition_is_bundled.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
src/pretix/base/templatetags/classname.py
Normal file
29
src/pretix/base/templatetags/classname.py
Normal file
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def classname(obj):
|
||||
return obj.__class__.__name__
|
||||
@@ -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):
|
||||
|
||||
@@ -42,13 +42,37 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Order changes" %}</legend>
|
||||
<div class="alert alert-info">
|
||||
{% 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 %}
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
<ul>
|
||||
<li>{% 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)." %}</li>
|
||||
<li>{% trans "Changing the seat or the event date in an event series will become available in the future, but is not possible now." %}</li>
|
||||
<li>{% 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." %}</li>
|
||||
<li>{% 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." %}</li>
|
||||
<li>{% trans "Customers currently cannot switch to a product variation or add an add-on product that requires them to use a voucher or membership." %}</li>
|
||||
<li>{% trans "Additional constraints and validation steps added by plugins are not enforced." %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% 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" %}
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,327 +24,19 @@
|
||||
<i class="fa fa-angle-down collapse-indicator" aria-hidden="true"></i>
|
||||
</h3>
|
||||
</summary>
|
||||
<div id="cp{{ form.cartpos.pk }}">
|
||||
<div id="cp{{ form.pos.pk }}">
|
||||
<div class="panel-body">
|
||||
{% if form.cartpos.subevent %}
|
||||
{% if form.pos.subevent %}
|
||||
<p>
|
||||
<span class="fa fa-calendar" aria-hidden="true"></span>
|
||||
{{ 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 %}
|
||||
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||
{{ form.cartpos.subevent.date_from|date:"TIME_FORMAT" }}
|
||||
{{ form.pos.subevent.date_from|date:"TIME_FORMAT" }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% for c in form.categories %}
|
||||
<fieldset>
|
||||
<legend>{{ c.category.name }}</legend>
|
||||
{% if c.category.description %}
|
||||
{{ c.category.description|rich_text }}
|
||||
{% endif %}
|
||||
{% if c.min_count == c.max_count %}
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
{% elif c.min_count == 0 and c.max_count >= c.items|length and not c.multi_allowed %}
|
||||
{% elif c.min_count == 0 %}
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% for item in c.items %}
|
||||
{% if item.has_variations %}
|
||||
<article aria-labelledby="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-description"{% endif %} class="item-with-variations{% if event.settings.show_variations_expanded %} details-open{% endif %}" id="item-{{ item.pk }}">
|
||||
<div class="row-fluid product-row headline">
|
||||
<div class="col-md-8 col-xs-12">
|
||||
{% if item.picture %}
|
||||
<a href="{{ item.picture.url }}" class="productpicture"
|
||||
data-title="{{ item.name|force_escape|force_escape }}"
|
||||
{# Yes, double-escape to prevent XSS in lightbox #}
|
||||
data-lightbox="{{ item.id }}">
|
||||
<img src="{{ item.picture|thumb:'60x60^' }}"
|
||||
alt="{{ item.name }}"/>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="product-description {% if item.picture %}with-picture{% endif %}">
|
||||
<h4 id="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-legend">{{ item.name }}</h4>
|
||||
{% if item.description %}
|
||||
<div id="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-description" class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.min_per_order and item.min_per_order > 1 %}
|
||||
<p>
|
||||
<small>
|
||||
{% blocktrans trimmed with num=item.min_per_order %}
|
||||
minimum amount to order: {{ num }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
<p>
|
||||
{% if c.price_included %}
|
||||
<span class="sr-only">{% trans "free" context "price" %}</span>
|
||||
{% elif item.free_price %}
|
||||
{% blocktrans trimmed with price=item.min_price|money:event.currency %}
|
||||
from {{ price }}
|
||||
{% endblocktrans %}
|
||||
{% elif item.min_price != item.max_price %}
|
||||
<span class="sr-only">
|
||||
{% 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 %}
|
||||
</span>
|
||||
<span aria-hidden="true">{{ item.min_price|money:event.currency }} – {{ item.max_price|money:event.currency }}</span>
|
||||
{% elif not item.min_price and not item.max_price %}
|
||||
{% else %}
|
||||
{{ item.min_price|money:event.currency }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 availability-box">
|
||||
{% if not event.settings.show_variations_expanded %}
|
||||
<button type="button" data-toggle="variations" class="btn btn-default btn-block js-only"
|
||||
data-label-alt="{% trans "Hide variants" %}"
|
||||
aria-expanded="false"
|
||||
aria-label="{% blocktrans trimmed with item=item.name count=item.available_variations|length %}Show {{count}} variants of {{item}}{% endblocktrans %}">
|
||||
{% trans "Show variants" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
<div class="variations {% if not event.settings.show_variations_expanded %}variations-collapsed{% endif %}">
|
||||
{% for var in item.available_variations %}
|
||||
<article aria-labelledby="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-{{ var.pk }}-legend"{% if var.description %} aria-describedby="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-{{ var.pk }}-description"{% endif %} class="row-fluid product-row variation">
|
||||
<div class="col-md-8 col-xs-12">
|
||||
<h5 id="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-{{ var.pk }}-legend">{{ var }}</h5>
|
||||
{% if var.description %}
|
||||
<div id="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-{{ var.pk }}-description" class="variation-description">
|
||||
{{ var.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.do_show_quota_left %}
|
||||
{% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
{% if not c.price_included %}
|
||||
{% if var.original_price %}
|
||||
<del><span class="sr-only">{% trans "Original price:" %}</span>
|
||||
{% if event.settings.display_net_prices %}
|
||||
{{ var.original_price.net|money:event.currency }}
|
||||
{% else %}
|
||||
{{ var.original_price.gross|money:event.currency }}
|
||||
{% endif %}
|
||||
</del>
|
||||
<ins><span class="sr-only">{% trans "New price:" %}</span>
|
||||
{% endif %}
|
||||
{% if item.free_price %}
|
||||
<div class="input-group input-group-price">
|
||||
<span class="input-group-addon">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price"
|
||||
placeholder="0"
|
||||
min="{% if event.settings.display_net_prices %}{{ var.display_price.net|money_numberfield:event.currency }}{% else %}{{ var.display_price.gross|money_numberfield:event.currency }}{% endif %}"
|
||||
name="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}_price"
|
||||
title="{% blocktrans trimmed with item=var.value %}Modify price for {{ item }}{% endblocktrans %}"
|
||||
step="any"
|
||||
value="{% if event.settings.display_net_prices %}{{ var.initial_price.net|money_numberfield:event.currency }}{% else %}{{ var.initial_price.gross|money_numberfield:event.currency }}{% endif %}"
|
||||
>
|
||||
</div>
|
||||
{% 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 %}
|
||||
</ins>
|
||||
{% endif %}
|
||||
{% if item.includes_mixed_tax_rate %}
|
||||
{% if event.settings.display_net_prices %}
|
||||
<small>{% trans "plus taxes" %}</small>
|
||||
{% else %}
|
||||
<small>{% trans "incl. taxes" %}</small>
|
||||
{% endif %}
|
||||
{% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% elif var.display_price.rate and var.display_price.gross %}
|
||||
<small>{% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %}
|
||||
incl. {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="sr-only">{% trans "free" context "price" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if var.cached_availability.0 == 100 or var.initial %}
|
||||
<div class="col-md-2 col-xs-6 availability-box available">
|
||||
{% if c.max_count == 1 or not c.multi_allowed %}
|
||||
<label class="item-checkbox-label">
|
||||
<input type="checkbox" value="1"
|
||||
{% if var.initial %}checked="checked"{% endif %}
|
||||
id="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}"
|
||||
name="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}"
|
||||
data-exclusive-prefix="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_"
|
||||
aria-label="{% blocktrans with item=item.name var=var %}Add {{ item }}, {{ var }} to cart{% endblocktrans %}">
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
{% if var.initial %}value="{{ var.initial }}"{% endif %}
|
||||
max="{{ c.max_count }}"
|
||||
id="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}"
|
||||
name="cp_{{ form.cartpos.pk }}_variation_{{ item.id }}_{{ var.id }}"
|
||||
aria-label="{% blocktrans with item=item.name var=var %}Quantity of {{ item }}, {{ var }} to order{% endblocktrans %}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="clearfix"></div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<article aria-labelledby="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-description"{% endif %} class="row-fluid product-row simple">
|
||||
<div class="col-md-8 col-xs-12">
|
||||
{% if item.picture %}
|
||||
<a href="{{ item.picture.url }}" class="productpicture"
|
||||
data-title="{{ item.name|force_escape|force_escape }}"
|
||||
{# Yes, double-escape to prevent XSS in lightbox #}
|
||||
data-lightbox="{{ item.id }}">
|
||||
<img src="{{ item.picture|thumb:'60x60^' }}"
|
||||
alt="{{ item.name }}"/>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="product-description {% if item.picture %}with-picture{% endif %}">
|
||||
<h4 id="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-legend">{{ item.name }}</h4>
|
||||
{% if item.description %}
|
||||
<div id="cp-{{ form.cartpos.pk }}-item-{{ item.pk }}-description" class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% 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 %}
|
||||
<p>
|
||||
<small>
|
||||
{% blocktrans trimmed with num=item.min_per_order %}
|
||||
minimum amount to order: {{ num }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
<p>
|
||||
{% if not c.price_included %}
|
||||
{% if item.original_price %}
|
||||
<del><span class="sr-only">{% trans "Original price:" %}</span>
|
||||
{% if event.settings.display_net_prices %}
|
||||
{{ item.original_price.net|money:event.currency }}
|
||||
{% else %}
|
||||
{{ item.original_price.gross|money:event.currency }}
|
||||
{% endif %}
|
||||
</del>
|
||||
<ins><span class="sr-only">{% trans "New price:" %}</span>
|
||||
{% endif %}
|
||||
{% if item.free_price %}
|
||||
<div class="input-group input-group-price">
|
||||
<span class="input-group-addon">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price" placeholder="0"
|
||||
min="{% if event.settings.display_net_prices %}{{ item.display_price.net|money_numberfield:event.currency }}{% else %}{{ item.display_price.gross|money_numberfield:event.currency }}{% endif %}"
|
||||
name="cp_{{ form.cartpos.pk }}_item_{{ item.id }}_price"
|
||||
title="{% blocktrans trimmed with item=item.name %}Modify price for {{ item }}{% endblocktrans %}"
|
||||
value="{% if event.settings.display_net_prices %}{{ item.initial_price.net|money_numberfield:event.currency }}{% else %}{{ item.initial_price.gross|money_numberfield:event.currency }}{% endif %}"
|
||||
step="any">
|
||||
</div>
|
||||
{% 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 %}
|
||||
</ins>
|
||||
{% endif %}
|
||||
{% if item.includes_mixed_tax_rate %}
|
||||
{% if event.settings.display_net_prices %}
|
||||
<small>{% trans "plus taxes" %}</small>
|
||||
{% else %}
|
||||
<small>{% trans "incl. taxes" %}</small>
|
||||
{% endif %}
|
||||
{% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% elif item.display_price.rate and item.display_price.gross %}
|
||||
<small>{% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %}
|
||||
incl. {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="sr-only">{% trans "free" context "price" %}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if item.cached_availability.0 == 100 or item.initial %}
|
||||
<div class="col-md-2 col-xs-6 availability-box available">
|
||||
{% if c.max_count == 1 or not c.multi_allowed %}
|
||||
<label class="item-checkbox-label">
|
||||
<input type="checkbox" value="1"
|
||||
{% if item.initial %}checked="checked"{% endif %}
|
||||
name="cp_{{ form.cartpos.pk }}_item_{{ item.id }}"
|
||||
id="cp_{{ form.cartpos.pk }}_item_{{ item.id }}"
|
||||
aria-label="{% blocktrans with item=item.name %}Add {{ item }} to cart{% endblocktrans %}"
|
||||
{% if item.description %} aria-describedby="cp-{{ form.cartpos.pk }}-item-{{ item.id }}-description"{% endif %}>
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
max="{{ c.max_count }}"
|
||||
{% if item.initial %}value="{{ item.initial }}"{% endif %}
|
||||
name="cp_{{ form.cartpos.pk }}_item_{{ item.id }}"
|
||||
id="cp_{{ form.cartpos.pk }}_item_{{ item.id }}"
|
||||
aria-label="{% blocktrans with item=item.name %}Quantity of {{ item }} to order{% endblocktrans %}"
|
||||
{% if item.description %} aria-describedby="cp-{{ form.cartpos.pk }}-item-{{ item.id }}-description"{% endif %}>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="clearfix"></div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
{% empty %}
|
||||
<em>
|
||||
{% trans "There are no add-ons available for this product." %}
|
||||
</em>
|
||||
{% endfor %}
|
||||
{% include "pretixpresale/event/fragment_addon_choice.html" with form=form %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
{% load eventurl %}
|
||||
{% load money %}
|
||||
{% load thumb %}
|
||||
{% load eventsignal %}
|
||||
{% load rich_text %}
|
||||
{% for c in form.categories %}
|
||||
<fieldset>
|
||||
<legend>{{ c.category.name }}</legend>
|
||||
{% if c.category.description %}
|
||||
{{ c.category.description|rich_text }}
|
||||
{% endif %}
|
||||
{% if c.min_count == c.max_count %}
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
{% elif c.min_count == 0 and c.max_count >= c.items|length and not c.multi_allowed %}
|
||||
{% elif c.min_count == 0 %}
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% for item in c.items %}
|
||||
{% if item.has_variations %}
|
||||
<article aria-labelledby="cp-{{ form.pos.pk }}-item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="cp-{{ form.pos.pk }}-item-{{ item.pk }}-description"{% endif %} class="item-with-variations{% if event.settings.show_variations_expanded %} details-open{% endif %}" id="item-{{ item.pk }}">
|
||||
<div class="row-fluid product-row headline">
|
||||
<div class="col-md-8 col-xs-12">
|
||||
{% if item.picture %}
|
||||
<a href="{{ item.picture.url }}" class="productpicture"
|
||||
data-title="{{ item.name|force_escape|force_escape }}"
|
||||
{# Yes, double-escape to prevent XSS in lightbox #}
|
||||
data-lightbox="{{ item.id }}">
|
||||
<img src="{{ item.picture|thumb:'60x60^' }}"
|
||||
alt="{{ item.name }}"/>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="product-description {% if item.picture %}with-picture{% endif %}">
|
||||
<h4 id="cp-{{ form.pos.pk }}-item-{{ item.pk }}-legend">{{ item.name }}</h4>
|
||||
{% if item.description %}
|
||||
<div id="cp-{{ form.pos.pk }}-item-{{ item.pk }}-description" class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.min_per_order and item.min_per_order > 1 %}
|
||||
<p>
|
||||
<small>
|
||||
{% blocktrans trimmed with num=item.min_per_order %}
|
||||
minimum amount to order: {{ num }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
<p>
|
||||
{% if c.price_included %}
|
||||
<span class="sr-only">{% trans "free" context "price" %}</span>
|
||||
{% elif item.free_price %}
|
||||
{% blocktrans trimmed with price=item.min_price|money:event.currency %}
|
||||
from {{ price }}
|
||||
{% endblocktrans %}
|
||||
{% elif item.min_price != item.max_price %}
|
||||
<span class="sr-only">
|
||||
{% 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 %}
|
||||
</span>
|
||||
<span aria-hidden="true">{{ item.min_price|money:event.currency }} – {{ item.max_price|money:event.currency }}</span>
|
||||
{% elif not item.min_price and not item.max_price %}
|
||||
{% else %}
|
||||
{{ item.min_price|money:event.currency }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 availability-box">
|
||||
{% if not event.settings.show_variations_expanded %}
|
||||
<button type="button" data-toggle="variations" class="btn btn-default btn-block js-only"
|
||||
data-label-alt="{% trans "Hide variants" %}"
|
||||
aria-expanded="false"
|
||||
aria-label="{% blocktrans trimmed with item=item.name count=item.available_variations|length %}Show {{count}} variants of {{item}}{% endblocktrans %}">
|
||||
{% trans "Show variants" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
<div class="variations {% if not event.settings.show_variations_expanded %}variations-collapsed{% endif %}">
|
||||
{% for var in item.available_variations %}
|
||||
<article aria-labelledby="cp-{{ form.pos.pk }}-item-{{ item.pk }}-{{ var.pk }}-legend"{% if var.description %} aria-describedby="cp-{{ form.pos.pk }}-item-{{ item.pk }}-{{ var.pk }}-description"{% endif %} class="row-fluid product-row variation">
|
||||
<div class="col-md-8 col-xs-12">
|
||||
<h5 id="cp-{{ form.pos.pk }}-item-{{ item.pk }}-{{ var.pk }}-legend">{{ var }}</h5>
|
||||
{% if var.description %}
|
||||
<div id="cp-{{ form.pos.pk }}-item-{{ item.pk }}-{{ var.pk }}-description" class="variation-description">
|
||||
{{ var.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.do_show_quota_left %}
|
||||
{% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
{% if not c.price_included %}
|
||||
{% if var.original_price %}
|
||||
<del><span class="sr-only">{% trans "Original price:" %}</span>
|
||||
{% if event.settings.display_net_prices %}
|
||||
{{ var.original_price.net|money:event.currency }}
|
||||
{% else %}
|
||||
{{ var.original_price.gross|money:event.currency }}
|
||||
{% endif %}
|
||||
</del>
|
||||
<ins><span class="sr-only">{% trans "New price:" %}</span>
|
||||
{% endif %}
|
||||
{% if item.free_price %}
|
||||
<div class="input-group input-group-price">
|
||||
<span class="input-group-addon">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price"
|
||||
placeholder="0"
|
||||
min="{% if event.settings.display_net_prices %}{{ var.display_price.net|money_numberfield:event.currency }}{% else %}{{ var.display_price.gross|money_numberfield:event.currency }}{% endif %}"
|
||||
name="cp_{{ form.pos.pk }}_variation_{{ item.id }}_{{ var.id }}_price"
|
||||
title="{% blocktrans trimmed with item=var.value %}Modify price for {{ item }}{% endblocktrans %}"
|
||||
step="any"
|
||||
value="{% if event.settings.display_net_prices %}{{ var.initial_price.net|money_numberfield:event.currency }}{% else %}{{ var.initial_price.gross|money_numberfield:event.currency }}{% endif %}"
|
||||
>
|
||||
</div>
|
||||
{% 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 %}
|
||||
</ins>
|
||||
{% endif %}
|
||||
{% if item.includes_mixed_tax_rate %}
|
||||
{% if event.settings.display_net_prices %}
|
||||
<small>{% trans "plus taxes" %}</small>
|
||||
{% else %}
|
||||
<small>{% trans "incl. taxes" %}</small>
|
||||
{% endif %}
|
||||
{% elif var.display_price.rate and var.display_price.gross and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% elif var.display_price.rate and var.display_price.gross %}
|
||||
<small>{% blocktrans trimmed with rate=var.display_price.rate|floatformat:-2 name=var.display_price.name %}
|
||||
incl. {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="sr-only">{% trans "free" context "price" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if var.cached_availability.0 == 100 or var.initial %}
|
||||
<div class="col-md-2 col-xs-6 availability-box available">
|
||||
{% if c.max_count == 1 or not c.multi_allowed %}
|
||||
<label class="item-checkbox-label">
|
||||
<input type="checkbox" value="1"
|
||||
{% if var.initial %}checked="checked"{% endif %}
|
||||
id="cp_{{ form.pos.pk }}_variation_{{ item.id }}_{{ var.id }}"
|
||||
name="cp_{{ form.pos.pk }}_variation_{{ item.id }}_{{ var.id }}"
|
||||
data-exclusive-prefix="cp_{{ form.pos.pk }}_variation_{{ item.id }}_"
|
||||
aria-label="{% blocktrans with item=item.name var=var %}Add {{ item }}, {{ var }} to cart{% endblocktrans %}">
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
{% if var.initial %}value="{{ var.initial }}"{% endif %}
|
||||
max="{{ c.max_count }}"
|
||||
id="cp_{{ form.pos.pk }}_variation_{{ item.id }}_{{ var.id }}"
|
||||
name="cp_{{ form.pos.pk }}_variation_{{ item.id }}_{{ var.id }}"
|
||||
aria-label="{% blocktrans with item=item.name var=var %}Quantity of {{ item }}, {{ var }} to order{% endblocktrans %}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="clearfix"></div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<article aria-labelledby="cp-{{ form.pos.pk }}-item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="cp-{{ form.pos.pk }}-item-{{ item.pk }}-description"{% endif %} class="row-fluid product-row simple">
|
||||
<div class="col-md-8 col-xs-12">
|
||||
{% if item.picture %}
|
||||
<a href="{{ item.picture.url }}" class="productpicture"
|
||||
data-title="{{ item.name|force_escape|force_escape }}"
|
||||
{# Yes, double-escape to prevent XSS in lightbox #}
|
||||
data-lightbox="{{ item.id }}">
|
||||
<img src="{{ item.picture|thumb:'60x60^' }}"
|
||||
alt="{{ item.name }}"/>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="product-description {% if item.picture %}with-picture{% endif %}">
|
||||
<h4 id="cp-{{ form.pos.pk }}-item-{{ item.pk }}-legend">{{ item.name }}</h4>
|
||||
{% if item.description %}
|
||||
<div id="cp-{{ form.pos.pk }}-item-{{ item.pk }}-description" class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% 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 %}
|
||||
<p>
|
||||
<small>
|
||||
{% blocktrans trimmed with num=item.min_per_order %}
|
||||
minimum amount to order: {{ num }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
<p>
|
||||
{% if not c.price_included %}
|
||||
{% if item.original_price %}
|
||||
<del><span class="sr-only">{% trans "Original price:" %}</span>
|
||||
{% if event.settings.display_net_prices %}
|
||||
{{ item.original_price.net|money:event.currency }}
|
||||
{% else %}
|
||||
{{ item.original_price.gross|money:event.currency }}
|
||||
{% endif %}
|
||||
</del>
|
||||
<ins><span class="sr-only">{% trans "New price:" %}</span>
|
||||
{% endif %}
|
||||
{% if item.free_price %}
|
||||
<div class="input-group input-group-price">
|
||||
<span class="input-group-addon">{{ event.currency }}</span>
|
||||
<input type="number" class="form-control input-item-price" placeholder="0"
|
||||
min="{% if event.settings.display_net_prices %}{{ item.display_price.net|money_numberfield:event.currency }}{% else %}{{ item.display_price.gross|money_numberfield:event.currency }}{% endif %}"
|
||||
name="cp_{{ form.pos.pk }}_item_{{ item.id }}_price"
|
||||
title="{% blocktrans trimmed with item=item.name %}Modify price for {{ item }}{% endblocktrans %}"
|
||||
value="{% if event.settings.display_net_prices %}{{ item.initial_price.net|money_numberfield:event.currency }}{% else %}{{ item.initial_price.gross|money_numberfield:event.currency }}{% endif %}"
|
||||
step="any">
|
||||
</div>
|
||||
{% 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 %}
|
||||
</ins>
|
||||
{% endif %}
|
||||
{% if item.includes_mixed_tax_rate %}
|
||||
{% if event.settings.display_net_prices %}
|
||||
<small>{% trans "plus taxes" %}</small>
|
||||
{% else %}
|
||||
<small>{% trans "incl. taxes" %}</small>
|
||||
{% endif %}
|
||||
{% elif item.display_price.rate and item.display_price.gross and event.settings.display_net_prices %}
|
||||
<small>{% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% elif item.display_price.rate and item.display_price.gross %}
|
||||
<small>{% blocktrans trimmed with rate=item.display_price.rate|floatformat:-2 name=item.display_price.name %}
|
||||
incl. {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="sr-only">{% trans "free" context "price" %}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if item.cached_availability.0 == 100 or item.initial %}
|
||||
<div class="col-md-2 col-xs-6 availability-box available">
|
||||
{% if c.max_count == 1 or not c.multi_allowed %}
|
||||
<label class="item-checkbox-label">
|
||||
<input type="checkbox" value="1"
|
||||
{% if item.initial %}checked="checked"{% endif %}
|
||||
name="cp_{{ form.pos.pk }}_item_{{ item.id }}"
|
||||
id="cp_{{ form.pos.pk }}_item_{{ item.id }}"
|
||||
aria-label="{% blocktrans with item=item.name %}Add {{ item }} to cart{% endblocktrans %}"
|
||||
{% if item.description %} aria-describedby="cp-{{ form.pos.pk }}-item-{{ item.id }}-description"{% endif %}>
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
max="{{ c.max_count }}"
|
||||
{% if item.initial %}value="{{ item.initial }}"{% endif %}
|
||||
name="cp_{{ form.pos.pk }}_item_{{ item.id }}"
|
||||
id="cp_{{ form.pos.pk }}_item_{{ item.id }}"
|
||||
aria-label="{% blocktrans with item=item.name %}Quantity of {{ item }} to order{% endblocktrans %}"
|
||||
{% if item.description %} aria-describedby="cp-{{ form.pos.pk }}-item-{{ item.id }}-description"{% endif %}>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="clearfix"></div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
{% empty %}
|
||||
<em>
|
||||
{% trans "There are no add-ons available for this product." %}
|
||||
</em>
|
||||
{% endfor %}
|
||||
@@ -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 %}
|
||||
<h2>
|
||||
{% blocktrans trimmed with code=order.code %}
|
||||
@@ -11,7 +13,7 @@
|
||||
</h2>
|
||||
<form method="post" href="">
|
||||
{% csrf_token %}
|
||||
{% for position, positions in formgroups.items %}
|
||||
{% for position, addon_positions in formgroups.items %}
|
||||
<div class="panel panel-default items">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
@@ -21,43 +23,48 @@
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="panel-body addons">
|
||||
<div class="form-order-change form-horizontal">
|
||||
{% if position.subevent %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{% trans "Date" context "subevent" %}
|
||||
</label>
|
||||
<div class="col-md-9 form-control-text">
|
||||
<ul class="addon-list">
|
||||
{{ pos.subevent.name }} · {{ pos.subevent.get_date_range_display_as_html }}
|
||||
{% if pos.event.settings.show_times %}
|
||||
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||
{{ pos.subevent.date_from|date:"TIME_FORMAT" }}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for p in positions %}
|
||||
{% if p.pk != position.pk %}
|
||||
{# Add-Ons #}
|
||||
<legend>+ {{ p.item.name }}{% if p.variation %} – {{ p.variation.value }}{% endif %}</legend>
|
||||
{% endif %}
|
||||
{% if p.attendee_name %}
|
||||
<div class="form-order-change-main">
|
||||
{% if position.subevent %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{% trans "Attendee name" %}
|
||||
{% trans "Date" context "subevent" %}
|
||||
</label>
|
||||
<div class="col-md-9 form-control-text">
|
||||
{{ p.attendee_name }}
|
||||
<ul class="addon-list">
|
||||
{{ pos.subevent.name }} · {{ pos.subevent.get_date_range_display_as_html }}
|
||||
{% if pos.event.settings.show_times %}
|
||||
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||
{{ pos.subevent.date_from|date:"TIME_FORMAT" }}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% bootstrap_form p.form layout="checkout" %}
|
||||
{% endfor %}
|
||||
|
||||
{% for p in addon_positions %}
|
||||
{% if p.pk != position.pk %}
|
||||
{# Add-Ons #}
|
||||
<legend>+ {{ p.item.name }}{% if p.variation %} – {{ p.variation.value }}{% endif %}</legend>
|
||||
{% endif %}
|
||||
{% if p.attendee_name %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{% trans "Attendee name" %}
|
||||
</label>
|
||||
<div class="col-md-9 form-control-text">
|
||||
{{ p.attendee_name }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% bootstrap_form p.form layout="checkout" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% if position.addon_form %}
|
||||
{% include "pretixpresale/event/fragment_addon_choice.html" with form=position.addon_form %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -71,7 +78,7 @@
|
||||
</div>
|
||||
<div class="col-md-4 col-md-offset-4">
|
||||
<button class="btn btn-block btn-primary btn-lg" type="submit">
|
||||
{% trans "Save changes" %}
|
||||
{% trans "Continue" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
|
||||
@@ -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 %}
|
||||
<h2>
|
||||
{% blocktrans trimmed with code=order.code %}
|
||||
Change order: {{ code }}
|
||||
{% endblocktrans %}
|
||||
</h2>
|
||||
|
||||
<form method="post" class="form-horizontal" href="">
|
||||
{% csrf_token %}
|
||||
|
||||
<p>{% trans "Please confirm the following changes to your order." %}</p>
|
||||
<div class="row-fluid">
|
||||
<div class="panel panel-primary">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Change summary" %}
|
||||
</h3>
|
||||
</div>
|
||||
<table class="panel-body table table-hover">
|
||||
{% for op in operations %}
|
||||
{% if op|classname == "ItemOperation" %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if op.position.variation or op.variation %}
|
||||
{% blocktrans trimmed with positionid=op.position.positionid old_item=op.position.item.name old_variation=op.position.variation new_item=op.item.name new_variation=op.variation %}
|
||||
Change position #{{ positionid }} from "{{ old_item }} – {{ old_variation }}
|
||||
" to "{{ new_item }} – {{ new_variation }}"
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with positionid=op.position.positionid old_item=op.position.item.name new_item=op.item.name %}
|
||||
Change position #{{ positionid }} from "{{ old_item }}" to "{{ new_item }}"
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% if op.position.addon_to %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<small>{% blocktrans with positionid=op.position.addon_to.positionid %}
|
||||
Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
</td>
|
||||
</tr>
|
||||
{% elif op|classname == "SubeventOperation" %}
|
||||
<tr>
|
||||
<td>
|
||||
{% blocktrans trimmed with positionid=op.position.positionid old=op.position.subevent new=op.subevent %}
|
||||
Change date of position #{{ positionid }} from "{{ old }}" to "{{ new }}"
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
</td>
|
||||
</tr>
|
||||
{% elif op|classname == "PriceOperation" %}
|
||||
<tr>
|
||||
<td>
|
||||
{% blocktrans trimmed with positionid=op.position.positionid old=op.position.price new=op.price %}
|
||||
Change price of position #{{ positionid }} from {{ old }} to {{ new }}
|
||||
{% endblocktrans %}
|
||||
{% if op.position.addon_to %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<small>{% blocktrans with positionid=op.position.addon_to.positionid %}
|
||||
Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
{{ op.price_diff|money:request.event.currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{% elif op|classname == "AddOperation" %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if op.variation %}
|
||||
{% blocktrans trimmed with 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 %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<small>{% blocktrans with positionid=op.addon_to.positionid %}Add-on product
|
||||
to position #{{ positionid }}{% endblocktrans %}</small>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
{{ op.price.gross|money:request.event.currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{% elif op|classname == "CancelOperation" %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if op.position.variation %}
|
||||
{% blocktrans trimmed with positionid=op.position.positionid item=op.position.item.name variation=op.position.variation.value %}
|
||||
Remove position #{{ positionid }} ({{ item }} – {{ variation }})
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with positionid=op.position.positionid item=op.position.item.name %}
|
||||
Remove position #{{ positionid }} ({{ item }})
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% if op.position.addon_to %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<small>{% blocktrans with positionid=op.position.addon_to.positionid %}
|
||||
Add-on product to position #{{ positionid }}{% endblocktrans %}</small>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
{{ op.price_diff|money:request.event.currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>{% trans "Total price change" %}</strong></td>
|
||||
<td class="text-right flip">
|
||||
<strong>
|
||||
{{ totaldiff|money:request.event.currency }}
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
{% if totaldiff %}
|
||||
<tr>
|
||||
<td><strong>{% trans "New order total" %}</strong></td>
|
||||
<td class="text-right flip">
|
||||
{{ totaldiff|add:order.total|money:request.event.currency }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>{% trans "You already paid" %}</strong></td>
|
||||
<td class="text-right flip">
|
||||
{{ order.payment_refund_sum|money:request.event.currency }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{% if new_pending_sum > 0 %}
|
||||
<strong>{% trans "You will need to pay" %}</strong>
|
||||
<br>
|
||||
<span class="text-muted">
|
||||
{% trans "Your entire order will be considered unpaid until you paid this difference." %}
|
||||
</span>
|
||||
{% else %}
|
||||
<strong>{% trans "You will be refunded" %}</strong>
|
||||
<br>
|
||||
<span class="text-muted">
|
||||
{% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "manually" %}
|
||||
{% trans "The organizer will get in touch with you to clarify the details of your refund." %}
|
||||
{% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %}
|
||||
{% trans "The refund will be issued in form of a gift card that you can use for further purchases." %}
|
||||
{% else %}
|
||||
{% if can_auto_refund %}
|
||||
{% blocktrans trimmed %}
|
||||
The refund amount will automatically be sent back to your original payment method. Depending
|
||||
on the payment method, please allow for up to two weeks before this appears on your
|
||||
statement.
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed %}
|
||||
With the payment method you used, the refund amount <strong>can not be sent back to you
|
||||
automatically</strong>. Instead, the event organizer will need to initiate the transfer
|
||||
manually. Please be patient as this might take a bit longer.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
<strong>
|
||||
{{ new_pending_sum|money:request.event.currency }}
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% for k, l in request.POST.lists %}
|
||||
{% for v in l %}
|
||||
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
<div class="row checkout-button-row">
|
||||
<div class="col-md-4">
|
||||
<a class="btn btn-block btn-default btn-lg"
|
||||
href="{% eventurl request.event "presale:event.order.change" secret=order.secret order=order.code %}">
|
||||
{% trans "Back" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4 col-md-offset-4">
|
||||
<button class="btn btn-block btn-primary btn-lg" type="submit" name="confirm" value="true">
|
||||
{% trans "Perform changes" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user