Improve add-on products

This commit is contained in:
Raphael Michel
2017-03-19 14:33:45 +01:00
parent 5bcfb958f0
commit b52f2f5a9e
36 changed files with 802 additions and 131 deletions

View File

@@ -70,6 +70,7 @@ class JSONExporter(BaseExporter):
'attendee_name': position.attendee_name,
'attendee_email': position.attendee_email,
'secret': position.secret,
'addon_to': position.addon_to_id,
'answers': [
{
'question': answer.question_id,

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.6 on 2017-03-18 17:48
# Generated by Django 1.10.7 on 2017-04-14 10:44
from __future__ import unicode_literals
import django.db.models.deletion
@@ -9,7 +9,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0051_auto_20170206_2027'),
('pretixbase', '0055_auto_20170413_1537'),
]
operations = [
@@ -24,7 +24,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='cartposition',
name='addon_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CartPosition'),
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.CartPosition'),
),
migrations.AddField(
model_name='itemcategory',
@@ -34,17 +34,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='orderposition',
name='addon_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.OrderPosition'),
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.OrderPosition'),
),
migrations.AlterField(
model_name='item',
name='allow_cancel',
field=models.BooleanField(default=True, help_text='If this is active and the general event settings allo wit, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting', verbose_name='Allow product to be canceled'),
),
migrations.AlterField(
model_name='item',
name='default_price',
field=models.DecimalField(decimal_places=2, help_text='If this product has multiple variations, you can set different prices for each of the variations. If a variation does not have a special price or if you do not have variations, this price will be used.', max_digits=7, null=True, verbose_name='Default price'),
name='free_price',
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event. This is currently not supported for products that are bought as an add-on to other products.', verbose_name='Free price input'),
),
migrations.AddField(
model_name='itemaddon',
@@ -56,4 +51,8 @@ class Migration(migrations.Migration):
name='base_item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'),
),
migrations.AlterUniqueTogether(
name='itemaddon',
unique_together=set([('base_item', 'addon_category')]),
),
]

View File

@@ -496,13 +496,19 @@ class OrderPosition(AbstractPosition):
from . import Voucher
ops = []
for i, cartpos in enumerate(cp):
cp_mapping = {}
# The sorting key ensures that all addons come directly after the position they refer to
for i, cartpos in enumerate(sorted(cp, key=lambda c: (c.addon_to_id or c.pk, c.addon_to_id or 0))):
op = OrderPosition(order=order)
for f in AbstractPosition._meta.fields:
setattr(op, f.name, getattr(cartpos, f.name))
if f.name == 'addon_to':
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
else:
setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
op.positionid = i + 1
op.save()
cp_mapping[cartpos.pk] = op
for answ in cartpos.answers.all():
answ.orderposition = op
answ.cartposition = None

View File

@@ -54,6 +54,7 @@ error_messages = {
'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_only': _('One of the products you selected can only be bought as an add-on to another project.'),
}
@@ -127,9 +128,10 @@ class CartManager:
)
def _check_max_cart_size(self):
cartsize = self.positions.count()
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation)])
cartsize = self.positions.filter(addon_to__isnull=True).count()
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
not op.position.addon_to_id])
if cartsize > int(self.event.settings.max_items_per_order):
# TODO: i18n plurals
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
@@ -149,6 +151,9 @@ class CartManager:
raise CartError(error_messages['voucher_invalid_item'])
if isinstance(op, self.AddOperation):
if op.item.category and op.item.category.is_addon and not op.addon_to:
raise CartError(error_messages['addon_only'])
if op.item.max_per_order or op.item.min_per_order:
new_total = (
len([1 for p in self.positions if p.item_id == op.item.pk]) +
@@ -297,19 +302,21 @@ class CartManager:
[a['variation'] for a in addons],
)
current_addons = defaultdict(dict)
input_addons = defaultdict(set)
selected_addons = defaultdict(set)
cpcache = {}
quota_diff = Counter()
# Prepare various containers to hold data later
current_addons = defaultdict(dict) # CartPos -> currently attached add-ons
input_addons = defaultdict(set) # CartPos -> add-ons according to input
selected_addons = defaultdict(set) # CartPos -> final desired set of add-ons
cpcache = {} # CartPos.pk -> CartPos
quota_diff = Counter() # Quota -> Number of usages
operations = []
available_categories = defaultdict(set)
available_categories = defaultdict(set) # CartPos -> Category IDs to choose from
toplevel_cp = self.positions.filter(
addon_to__isnull=True
).prefetch_related(
'addons', 'item__addons', 'item__addons__addon_category'
).select_related('item', 'variation')
# Prefill some of the cache containers
for cp in toplevel_cp:
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()}
cpcache[cp.pk] = cp
@@ -318,6 +325,7 @@ class CartManager:
for a in cp.addons.all()
}
# 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
@@ -325,6 +333,7 @@ class CartManager:
if a['item'] not in self._items_cache or (a['variation'] and a['variation'] not in self._variations_cache):
raise CartError(error_messages['not_for_sale'])
# Only attach addons to things that are actually in this user's cart
if a['addon_to'] not in cpcache:
raise CartError(error_messages['addon_invalid_base'])
@@ -340,6 +349,7 @@ class CartManager:
if not quotas:
raise CartError(error_messages['unavailable'])
# Every item can be attached to very CartPosition at most once
if a['item'] in ([_a[0] for _a in input_addons[cp.id]]):
raise CartError(error_messages['addon_duplicate_item'])
@@ -347,6 +357,7 @@ class CartManager:
selected_addons[cp.id, item.category_id].add((a['item'], a['variation']))
if (a['item'], a['variation']) not in current_addons[cp]:
# This add-on is new, add it to the cart
for quota in quotas:
quota_diff[quota] += 1
@@ -359,6 +370,7 @@ class CartManager:
self._check_item_constraints(op)
operations.append(op)
# Check constraints on the add-on combinations
for cp in toplevel_cp:
item = cp.item
for iao in item.addons.all():
@@ -386,6 +398,7 @@ class CartManager:
}
)
# Detect removed add-ons and create RemoveOperations
for cp, al in current_addons.items():
for k, v in al.items():
if k not in input_addons[cp.id]:

View File

@@ -72,6 +72,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
desc = str(p.item.name)
if p.variation:
desc += " - " + str(p.variation.value)
if p.addon_to_id:
desc = " + " + desc
InvoiceLine.objects.create(
invoice=invoice, description=desc,
gross_value=p.price, tax_value=p.tax_value,

View File

@@ -562,6 +562,7 @@ class OrderChangeManager:
'new_item': op.item.pk,
'new_variation': op.variation.pk if op.variation else None,
'old_price': op.position.price,
'addon_to': op.position.addon_to_id,
'new_price': op.price
})
op.position.item = op.item
@@ -574,18 +575,29 @@ class OrderChangeManager:
'position': op.position.pk,
'positionid': op.position.positionid,
'old_price': op.position.price,
'addon_to': op.position.addon_to_id,
'new_price': op.price
})
op.position.price = op.price
op.position._calculate_tax()
op.position.save()
elif isinstance(op, self.CancelOperation):
for opa in op.position.addons.all():
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
'position': opa.pk,
'positionid': opa.positionid,
'old_item': opa.item.pk,
'old_variation': opa.variation.pk if opa.variation else None,
'addon_to': opa.addon_to_id,
'old_price': opa.price,
})
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_item': op.position.item.pk,
'old_variation': op.position.variation.pk if op.position.variation else None,
'old_price': op.position.price,
'addon_to': None,
})
op.position.delete()

View File

@@ -159,6 +159,10 @@ DEFAULTS = {
'default': None,
'type': datetime
},
'ticket_download_addons': {
'default': 'False',
'type': bool
},
'last_order_modification_date': {
'default': None,
'type': datetime

View File

@@ -51,10 +51,15 @@ class BaseTicketOutput:
This method should generate a download file and return a tuple consisting of a
filename, a file type and file content. The extension will be taken from the filename
which is otherwise ignored.
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
"""
with tempfile.TemporaryDirectory() as d:
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for pos in order.positions.all():
if pos.addon_to_id and not self.event.settings.ticket_download_addons:
continue
fname, __, content = self.generate(pos)
zipf.writestr('{}-{}{}'.format(
order.code, pos.positionid, os.path.splitext(fname)[1]

View File

@@ -257,7 +257,8 @@ class EventSettingsForm(SettingsForm):
)
max_items_per_order = forms.IntegerField(
min_value=1,
label=_("Maximum number of items per order")
label=_("Maximum number of items per order"),
help_text=_("Add-on products will not be counted.")
)
reservation_time = forms.IntegerField(
min_value=0,
@@ -607,6 +608,10 @@ class TicketSettingsForm(SettingsForm):
required=True,
widget=forms.DateTimeInput(attrs={'class': 'datetimepicker'})
)
ticket_download_addons = forms.BooleanField(
label=_("Offer to download tickets separately for add-on products"),
required=False
)
def prepare_fields(self):
# See clean()

View File

@@ -265,3 +265,7 @@ class ItemAddOnForm(I18nModelForm):
'min_count',
'max_count',
]
help_texts = {
'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all '
'available add-ons are sold out.')
}

View File

@@ -76,6 +76,9 @@ class VoucherForm(I18nModelForm):
else:
self.instance.variation = None
self.instance.quota = None
if self.instance.item.category and self.instance.item.category.is_addon:
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
else:
self.instance.quota = Quota.objects.get(pk=quotaid, event=self.instance.event)
self.instance.item = None

View File

@@ -9,6 +9,7 @@
{% bootstrap_form_errors form %}
{% bootstrap_field form.ticket_download layout="horizontal" %}
{% bootstrap_field form.ticket_download_date layout="horizontal" %}
{% bootstrap_field form.ticket_download_addons layout="horizontal" %}
{% for provider in providers %}
<div class="panel panel-default ticketoutput-panel">
<div class="panel-heading">

View File

@@ -48,6 +48,13 @@
{% if position.variation %}
{{ position.variation }}
{% endif %}
{% if position.addon_to %}
<em>
{% blocktrans trimmed with posid=position.addon_to.positionid %}
Add-On to position #{{ posid }}
{% endblocktrans %}
</em>
{% endif %}
</h3>
</div>
<div class="panel-body">
@@ -89,6 +96,11 @@
<input name="{{ position.form.prefix }}-operation" type="radio" value="cancel"
{% if position.form.operation.value == "cancel" %}checked="checked"{% endif %}>
{% trans "Remove from order" %}
{% if position.addons.exists %}
<em class="text-danger">
{% trans "Removing this position will also remove all add-ons to this position." %}
</em>
{% endif %}
</label>
</div>
</div>

View File

@@ -166,7 +166,11 @@
{% for line in items.positions %}
<div class="row-fluid product-row">
<div class="col-md-9 col-xs-6">
#{{ line.positionid }}
{% if line.addon_to %}
<span class="addon-signifier">+</span>
{% else %}
#{{ line.positionid }}
{% endif %}
<strong>{{ line.item.name }}</strong>
{% if line.variation %}
{{ line.variation }}

View File

@@ -3,6 +3,7 @@ import io
from collections import OrderedDict
from django import forms
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext as _, ugettext_lazy
from pretix.base.exporter import BaseExporter
@@ -70,10 +71,10 @@ class CSVCheckinList(BaseCheckinList):
order__event=self.event, item_id__in=form_data['items']
).prefetch_related(
'answers', 'answers__question'
).select_related('order', 'item', 'variation')
).select_related('order', 'item', 'variation', 'addon_to')
if form_data['sort'] == 'name':
qs = qs.order_by('attendee_name')
qs = qs.order_by(Coalesce('attendee_name', 'addon_to__attendee_name'))
elif form_data['sort'] == 'code':
qs = qs.order_by('order__code')
@@ -100,7 +101,7 @@ class CSVCheckinList(BaseCheckinList):
for op in qs:
row = [
op.order.code,
op.attendee_name,
op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''),
str(op.item.name) + (" " + str(op.variation.value) if op.variation else ""),
op.price,
]
@@ -109,7 +110,7 @@ class CSVCheckinList(BaseCheckinList):
if form_data['secrets']:
row.append(op.secret)
if self.event.settings.attendee_emails_asked:
row.append(op.attendee_email)
row.append(op.attendee_email or (op.addon_to.attendee_name if op.addon_to else ''))
acache = {}
for a in op.answers.all():
acache[a.question_id] = str(a)

View File

@@ -74,7 +74,7 @@ class ApiRedeemView(ApiView):
try:
with transaction.atomic():
created = False
op = OrderPosition.objects.select_related('item', 'variation', 'order').get(
op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get(
order__event=self.event, secret=secret
)
if op.order.status == Order.STATUS_PAID:
@@ -105,7 +105,7 @@ class ApiRedeemView(ApiView):
'order': op.order.code,
'item': str(op.item),
'variation': str(op.variation) if op.variation else None,
'attendee_name': op.attendee_name
'attendee_name': op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''),
}
except OrderPosition.DoesNotExist:
@@ -136,7 +136,7 @@ class ApiSearchView(ApiView):
'order': op.order.code,
'item': str(op.item),
'variation': str(op.variation) if op.variation else None,
'attendee_name': op.attendee_name,
'attendee_name': op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''),
'redeemed': bool(op.checkins.all()),
'paid': op.order.status == Order.STATUS_PAID,
} for op in ops

View File

@@ -91,6 +91,8 @@ class PdfTicketOutput(BaseTicketOutput):
buffer = BytesIO()
p = self._create_canvas(buffer)
for op in order.positions.all():
if op.addon_to_id and not self.event.settings.ticket_download_addons:
continue
self._draw_page(p, op, order)
p.save()
outbuffer = self._render_with_background(buffer)

View File

@@ -153,11 +153,12 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
@cached_property
def forms(self):
"""
A list of forms with one form for each cart position that has questions
the user can answer. All forms have a custom prefix, so that they can all be
submitted at once.
A list of forms with one form for each cart position that can have add-ons.
All forms have a custom prefix, so that they can all be submitted at once.
"""
formset = []
quota_cache = {}
item_cache = {}
for cartpos in get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation'
):
@@ -180,7 +181,9 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
prefix='{}_{}'.format(cartpos.pk, iao.addon_category.pk),
category=iao.addon_category,
initial=current_addon_products,
data=(self.request.POST if self.request.method == 'POST' else None)
data=(self.request.POST if self.request.method == 'POST' else None),
quota_cache=quota_cache,
item_cache=item_cache
)
}
@@ -324,7 +327,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['forms'] = self.forms
ctx['formgroups'] = self.formdict.items()
ctx['contact_form'] = self.contact_form
ctx['invoice_form'] = self.invoice_form
return ctx

View File

@@ -59,8 +59,8 @@ class QuestionsForm(forms.Form):
:param cartpos: The cart position the form should be for
:param event: The event this belongs to
"""
cartpos = kwargs.pop('cartpos', None)
orderpos = kwargs.pop('orderpos', None)
cartpos = self.cartpos = kwargs.pop('cartpos', None)
orderpos = self.orderpos = kwargs.pop('orderpos', None)
item = cartpos.item if cartpos else orderpos.item
questions = list(item.questions.all())
event = kwargs.pop('event')
@@ -151,7 +151,7 @@ class AddOnsForm(forms.Form):
This form class is responsible for selecting add-ons to a product in the cart.
"""
def _label(self, event, item_or_variation):
def _label(self, event, item_or_variation, avail):
if isinstance(item_or_variation, ItemVariation):
variation = item_or_variation
item = item_or_variation.item
@@ -165,69 +165,89 @@ class AddOnsForm(forms.Form):
label = item.name
if not item.tax_rate or not price:
return '{name} (+ {currency} {price})'.format(
n = '{name} (+ {currency} {price})'.format(
name=label, currency=event.currency, price=number_format(price)
)
elif event.settings.display_net_prices:
return '{name} (+ {currency} {price} plus {taxes}% taxes)'.format(
n = '{name} (+ {currency} {price} plus {taxes}% taxes)'.format(
name=label, currency=event.currency, price=number_format(price_net),
taxes=number_format(item.tax_rate)
)
else:
return '{name} (+ {currency} {price} incl. {taxes}% taxes)'.format(
n = '{name} (+ {currency} {price} incl. {taxes}% taxes)'.format(
name=label, currency=event.currency, price=number_format(price),
taxes=number_format(item.tax_rate)
)
if avail[0] < 20:
n += ' {}'.format(_('SOLD OUT'))
elif avail[0] < 100:
n += ' {}'.format(_('Currently unavailable'))
return n
def __init__(self, *args, **kwargs):
"""
Takes additional keyword arguments:
:param category: The category to choose from
:param event: The event this belongs to
:param initial: The current set of add-ons
:param quota_cache: A shared dictionary for quota caching
:param item_cache: A shared dictionary for item/category caching
"""
category = kwargs.pop('category')
event = kwargs.pop('event')
current_addons = kwargs.pop('initial')
quota_cache = kwargs.pop('quota_cache')
item_cache = kwargs.pop('item_cache')
super().__init__(*args, **kwargs)
items = category.items.filter(
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(hide_without_voucher=False)
).prefetch_related(
'variations__quotas', # for .availability()
Prefetch('quotas', queryset=event.quotas.all()),
Prefetch('variations', to_attr='available_variations',
queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).distinct()),
).annotate(
quotac=Count('quotas'),
has_variations=Count('variations')
).filter(
quotac__gt=0
).order_by('category__position', 'category_id', 'position', 'name')
if category.pk not in item_cache:
# Get all items to possibly show
items = category.items.filter(
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(hide_without_voucher=False)
).prefetch_related(
'variations__quotas', # for .availability()
Prefetch('quotas', queryset=event.quotas.all()),
Prefetch('variations', to_attr='available_variations',
queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).distinct()),
).annotate(
quotac=Count('quotas'),
has_variations=Count('variations')
).filter(
quotac__gt=0
).order_by('category__position', 'category_id', 'position', 'name')
item_cache[category.pk] = items
else:
items = item_cache[category.pk]
for i in items:
if i.has_variations:
choices = [('', '')]
for v in i.available_variations:
cached_availability = v.check_quotas(_cache=quota_cache)
choices.append((v.pk, self._label(event, v, cached_availability)))
field = forms.ChoiceField(
choices=[('', '')] + [
(
v.pk,
self._label(event, v)
) for v in i.available_variations
],
choices=choices,
label=i.name,
required=False,
widget=forms.RadioSelect,
help_text=i.description,
initial=current_addons.get(i.pk)
)
else:
cached_availability = i.check_quotas(_cache=quota_cache)
field = forms.BooleanField(
label=self._label(event, i),
label=self._label(event, i, cached_availability),
required=False,
initial=i.pk in current_addons
initial=i.pk in current_addons,
help_text=i.description
)
self.fields['item_%s' % i.pk] = field

View File

@@ -33,6 +33,10 @@
{% blocktrans trimmed with min_count=c.min_count %}
You need to choose {{ min_count }} options from this category.
{% endblocktrans %}
{% elif c.min_count == 0 %}
{% blocktrans trimmed with max_count=c.max_count %}
You can to 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

View File

@@ -42,14 +42,14 @@
</div>
{% endif %}
{% for form in forms %}
{% for pos, forms in formgroups %}
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#cp{{ form.pos.id }}">
<strong>{{ form.pos.item.name }}</strong>
{% if form.pos.variation %}
{{ form.pos.variation }}
<a data-toggle="collapse" href="#cp{{ pos.id }}">
<strong>{{ pos.item.name }}</strong>
{% if pos.variation %}
{{ pos.variation }}
{% endif %}
</a>
{% if forloop.counter > 1 %}
@@ -59,10 +59,16 @@
{% endif %}
</h4>
</div>
<div id="cp{{ form.pos.id }}"
<div id="cp{{ pos.id }}"
class="panel-collapse collapsed in">
<div class="panel-body" data-idx="{{ forloop.counter0 }}">
{% bootstrap_form form layout="horizontal" %}
{% for form in forms %}
{% if form.pos.item != pos.item %}
{# Add-Ons #}
<legend>+ {{ form.pos.item }}</legend>
{% endif %}
{% bootstrap_form form layout="horizontal" %}
{% endfor %}
</div>
</div>
</div>

View File

@@ -3,6 +3,9 @@
{% for line in cart.positions %}
<div class="row cart-row {% if download %}has-downloads{% endif %}">
<div class="product">
{% if line.addon_to %}
<span class="addon-signifier">+</span>
{% endif %}
<strong>{{ line.item.name }}</strong>
{% if line.variation %}
{{ line.variation }}
@@ -32,12 +35,23 @@
{% if download %}
<div class="download-desktop">
{% for b in download_buttons %}
<a href="{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.id %}"
class="btn btn-default btn-sm" data-asyncdownload>
<span class="fa fa-download"></span> {{ b.text }}
</a>
{% endfor %}
{% if not line.addon_to or event.settings.ticket_download_addons %}
{% for b in download_buttons %}
<a href="{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.id %}"
class="btn btn-default btn-sm" data-asyncdownload>
<span class="fa fa-download"></span> {{ b.text }}
</a>
{% endfor %}
{% endif %}
</div>
{% elif line.addon_to %}
<div class="count">&nbsp;</div>
<div class="singleprice price">
{% if event.settings.display_net_prices %}
{{ event.currency }} {{ line.net_price|floatformat:2 }}
{% else %}
{{ event.currency }} {{ line.price|floatformat:2 }}
{% endif %}
</div>
{% else %}
<div class="count">
@@ -67,7 +81,9 @@
<input type="hidden" name="price_{{ line.item.id }}"
value="{% if event.settings.display_net_prices %}{{ line.net_price }}{% else %}{{ line.price }}{% endif %}" />
{% endif %}
<button class="btn btn-mini btn-link"><i class="fa fa-plus"></i></button>
<button class="btn btn-mini btn-link">
<i class="fa fa-plus"></i>
</button>
</form>
{% endif %}
</div>
@@ -98,12 +114,14 @@
</div>
{% if download %}
<div class="download-mobile">
{% for b in download_buttons %}
<a href="{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.id %}"
class="btn btn-default btn-sm" data-asyncdownload>
<span class="fa fa-download"></span> {{ b.text }}
</a>
{% endfor %}
{% if not line.addon_to or event.settings.ticket_download_addons %}
{% for b in download_buttons %}
<a href="{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.id %}"
class="btn btn-default btn-sm" data-asyncdownload>
<span class="fa fa-download"></span> {{ b.text }}
</a>
{% endfor %}
{% endif %}
</div>
{% endif %}
<div class="clearfix"></div>

View File

@@ -36,23 +36,28 @@
</div>
</div>
{% endif %}
{% for form in forms %}
{% for pos, forms in formgroups %}
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#cp{{ form.pos.id }}"
data-parent="#questions_accordion">
<strong>{{ form.pos.item }}</strong>
{% if form.pos.variation %}
{{ form.pos.variation }}
<a data-toggle="collapse" href="#cp{{ pos.id }}">
<strong>{{ pos.item.name }}</strong>
{% if pos.variation %}
{{ pos.variation }}
{% endif %}
</a>
</h4>
</div>
<div id="cp{{ form.pos.id }}"
class="panel-collapse collapsed {% if forloop.counter0 == 0 %}in{% endif %}">
<div id="cp{{ pos.id }}"
class="panel-collapse collapsed in">
<div class="panel-body">
{% bootstrap_form form layout="horizontal" %}
{% for form in forms %}
{% if form.pos.item != pos.item %}
{# Add-Ons #}
<legend>+ {{ form.pos.item }}</legend>
{% endif %}
{% bootstrap_form form layout="horizontal" %}
{% endfor %}
</div>
</div>
</div>

View File

@@ -18,7 +18,7 @@ event_patterns = [
url(r'^cart/clear$', pretix.presale.views.cart.CartClear.as_view(), name='event.cart.clear'),
url(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'),
url(r'^checkout/start$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout.start'),
url(r'^redeem$', pretix.presale.views.cart.RedeemView.as_view(),
url(r'^redeem/?$', pretix.presale.views.cart.RedeemView.as_view(),
name='event.redeem'),
url(r'^checkout/(?P<step>[^/]+)/$', pretix.presale.views.checkout.CheckoutView.as_view(),
name='event.checkout'),

View File

@@ -29,35 +29,45 @@ class CartMixin:
cartpos = queryset.order_by(
'item', 'variation'
).select_related(
'item', 'variation'
'item', 'variation', 'addon_to'
).prefetch_related(
*prefetch
)
else:
cartpos = self.positions
lcp = list(cartpos)
has_addons = {cp.addon_to.pk for cp in lcp if cp.addon_to}
# Group items of the same variation
# We do this by list manipulations instead of a GROUP BY query, as
# Django is unable to join related models in a .values() query
def keyfunc(pos):
if isinstance(pos, OrderPosition):
i = pos.positionid
if pos.addon_to:
i = pos.addon_to.positionid
else:
i = pos.positionid
else:
i = pos.pk
if downloads:
return i, pos.pk, 0, 0, 0, 0,
if pos.addon_to:
i = pos.addon_to.pk
else:
i = pos.pk
has_attendee_data = pos.item.admission and (
self.request.event.settings.attendee_names_asked
or self.request.event.settings.attendee_emails_asked
)
addon_penalty = 1 if pos.addon_to else 0
if downloads or pos.pk in has_addons or pos.addon_to:
return i, addon_penalty, pos.pk, 0, 0, 0, 0,
if answers and (has_attendee_data or pos.item.questions.all()):
return i, pos.pk, 0, 0, 0, 0,
return 0, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0)
return i, addon_penalty, pos.pk, 0, 0, 0, 0,
return 0, addon_penalty, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0)
positions = []
for k, g in groupby(sorted(list(cartpos), key=keyfunc), key=keyfunc):
for k, g in groupby(sorted(lcp, key=keyfunc), key=keyfunc):
g = list(g)
group = g[0]
group.count = len(g)

View File

@@ -171,6 +171,7 @@ class RedeemView(EventViewMixin, TemplateView):
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& ~Q(category__is_addon=True)
)
vouchq = Q(hide_without_voucher=False)

View File

@@ -46,6 +46,7 @@ def get_grouped_items(event):
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(hide_without_voucher=False)
& ~Q(category__is_addon=True)
).select_related(
'category', # for re-grouping
).prefetch_related(

View File

@@ -3,7 +3,7 @@ from datetime import timedelta
from django.contrib import messages
from django.db import transaction
from django.db.models import Sum
from django.http import FileResponse, Http404, HttpResponse
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import redirect, render
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -392,9 +392,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template
@cached_property
def positions(self):
return list(self.order.positions.order_by(
'item', 'variation'
).select_related(
return list(self.order.positions.select_related(
'item', 'variation'
).prefetch_related(
'variation', 'item__questions', 'answers'
@@ -445,7 +443,7 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order
ctx['forms'] = self.forms
ctx['formgroups'] = self.formdict.items()
ctx['invoice_form'] = self.invoice_form
return ctx
@@ -501,6 +499,12 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View):
class OrderDownload(EventViewMixin, OrderDetailMixin, View):
def get_self_url(self):
return eventreverse(self.request.event,
'presale:event.order.download' if 'position' in self.kwargs
else 'presale:event.order.download.combined',
kwargs=self.kwargs)
@cached_property
def output(self):
responses = register_ticket_outputs.send(self.request.event)
@@ -516,20 +520,30 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View):
except OrderPosition.DoesNotExist:
return None
def error(self, msg):
messages.error(self.request, msg)
if "ajax" in self.request.POST or "ajax" in self.request.GET:
return JsonResponse({
'ready': True,
'success': False,
'redirect': self.get_order_url(),
'message': msg,
})
return redirect(self.get_order_url())
def get(self, request, *args, **kwargs):
if not self.output or not self.output.is_enabled:
messages.error(request, _('You requested an invalid ticket output type.'))
return redirect(self.get_order_url())
return self.error(_('You requested an invalid ticket output type.'))
if not self.order or ('position' in kwargs and not self.order_position):
raise Http404(_('Unknown order code or not authorized to access this order.'))
if self.order.status != Order.STATUS_PAID:
messages.error(request, _('Order is not paid.'))
return redirect(self.get_order_url())
return self.error(_('Order is not paid.'))
if (not self.request.event.settings.ticket_download
or (self.request.event.settings.ticket_download_date is not None
and now() < self.request.event.settings.ticket_download_date)):
messages.error(request, _('Ticket download is not (yet) enabled.'))
return redirect(self.get_order_url())
return self.error(_('Ticket download is not (yet) enabled.'))
if 'position' in kwargs and (self.order_position.addon_to and not self.request.event.settings.ticket_download_addons):
return self.error(_('Ticket download is not enabled for add-on products.'))
if 'position' in kwargs:
return self._download_position()
@@ -555,7 +569,11 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View):
generate_order.apply_async(args=(self.order.id, self.output.identifier))
if 'ajax' in self.request.GET:
return HttpResponse('1' if ct and ct.file else '0')
return JsonResponse({
'ready': bool(ct and ct.file),
'success': False,
'redirect': self.get_self_url()
})
elif not ct.file:
return render(self.request, "pretixbase/cachedfiles/pending.html", {})
else:
@@ -584,7 +602,11 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View):
generate.apply_async(args=(self.order_position.id, self.output.identifier))
if 'ajax' in self.request.GET:
return HttpResponse('1' if ct and ct.file else '0')
return JsonResponse({
'ready': bool(ct and ct.file),
'success': False,
'redirect': self.get_self_url()
})
elif not ct.file:
return render(self.request, "pretixbase/cachedfiles/pending.html", {})
else:

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
from django import forms
from django.utils.functional import cached_property
@@ -8,8 +10,18 @@ from pretix.presale.views import get_cart
class QuestionsViewMixin:
@staticmethod
def _keyfunc(pos):
# Sort addons after the item they are an addon to
if isinstance(pos, OrderPosition):
i = pos.addon_to.positionid if pos.addon_to else pos.positionid
else:
i = pos.addon_to.pk if pos.addon_to else pos.pk
addon_penalty = 1 if pos.addon_to else 0
return i, addon_penalty, pos.pk
def _positions_for_questions(self):
return get_cart(self.request)
return sorted(get_cart(self.request), key=self._keyfunc)
@cached_property
def forms(self):
@@ -32,6 +44,17 @@ class QuestionsViewMixin:
formlist.append(form)
return formlist
@cached_property
def formdict(self):
storage = defaultdict(list)
for f in self.forms:
pos = f.cartpos or f.orderpos
if pos.addon_to_id:
storage[pos.addon_to].append(f)
else:
storage[pos].append(f)
return storage
def save(self):
failed = False
for form in self.forms:

View File

@@ -11,15 +11,16 @@ function async_dl_check() {
'success': async_dl_check_callback,
'error': async_dl_check_error,
'context': this,
'dataType': 'json'
}
);
}
function async_dl_check_callback(data, jqXHR, status) {
"use strict";
if (data == 1) {
if (data.ready && data.redirect) {
$("body").data('ajaxing', false);
location.href = async_dl_url;
location.href = data.redirect;
waitingDialog.hide();
return;
}

View File

@@ -66,6 +66,12 @@ nav.navbar .danger, nav.navbar .danger:hover, nav.navbar .danger:active {
padding-left: 20px;
}
}
.addon-signifier {
display: inline-block;
padding-left: 10px;
font-weight: bold;
}
}
h1 .btn-sm {
@@ -220,4 +226,3 @@ body.loading #wrapper {
.action-col-2 {
min-width: 95px;
}

View File

@@ -58,6 +58,11 @@
&.has-downloads .product {
width: percentage((5 / $grid-columns));
}
.addon-signifier {
display: inline-block;
padding-left: 10px;
font-weight: bold;
}
}
@media(max-width: $screen-sm-max) {
@@ -75,4 +80,4 @@
clear: both;
}
}
}
}