Add add-on products

This commit is contained in:
Raphael Michel
2017-03-01 19:31:50 +01:00
parent 3f76be2287
commit 5f52963ce0
14 changed files with 772 additions and 25 deletions

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.6 on 2017-03-18 17:48
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0051_auto_20170206_2027'),
]
operations = [
migrations.CreateModel(
name='ItemAddOn',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('min_count', models.PositiveIntegerField(default=0, verbose_name='Minimum number')),
('max_count', models.PositiveIntegerField(default=1, verbose_name='Maximum number')),
],
),
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'),
),
migrations.AddField(
model_name='itemcategory',
name='is_addon',
field=models.BooleanField(default=False, help_text='If selected, the products belonging to this category are not for sale on their own. They can only be bought in combination with a product that has this category configured as a possible source for add-ons.', verbose_name='Products in this category are add-on products'),
),
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'),
),
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'),
),
migrations.AddField(
model_name='itemaddon',
name='addon_category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addon_to', to='pretixbase.ItemCategory', verbose_name='Category'),
),
migrations.AddField(
model_name='itemaddon',
name='base_item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'),
),
]

View File

@@ -5,6 +5,7 @@ from decimal import Decimal
from typing import Tuple
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Func, Q, Sum
from django.utils.functional import cached_property
@@ -44,6 +45,13 @@ class ItemCategory(LoggedModel):
position = models.IntegerField(
default=0
)
is_addon = models.BooleanField(
default=False,
verbose_name=_('Products in this category are add-on products'),
help_text=_('If selected, the products belonging to this category are not for sale on their own. They can '
'only be bought in combination with a product that has this category configured as a possible '
'source for add-ons.')
)
class Meta:
verbose_name = _("Product category")
@@ -51,6 +59,8 @@ class ItemCategory(LoggedModel):
ordering = ('position', 'id')
def __str__(self):
if self.is_addon:
return _('{category} (Add-On products)').format(category=str(self.name))
return str(self.name)
def delete(self, *args, **kwargs):
@@ -155,7 +165,8 @@ class Item(LoggedModel):
verbose_name=_("Free price input"),
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.")
"additional donations for your event. This is currently not supported for products that are "
"bought as an add-on to other products.")
)
tax_rate = models.DecimalField(
verbose_name=_("Taxes included in percent"),
@@ -377,6 +388,38 @@ class ItemVariation(models.Model):
return self.position < other.position
class ItemAddOn(models.Model):
"""
An instance of this model indicates that buying a ticket of the time ``base_item``
allows you to add up to ``max_count`` items from the category ``addon_category``
to your order that will be associated with the base item.
"""
base_item = models.ForeignKey(
Item,
related_name='addons'
)
addon_category = models.ForeignKey(
ItemCategory,
related_name='addon_to',
verbose_name=_('Category')
)
min_count = models.PositiveIntegerField(
default=0,
verbose_name=_('Minimum number')
)
max_count = models.PositiveIntegerField(
default=1,
verbose_name=_('Maximum number')
)
class Meta:
unique_together = (('base_item', 'addon_category'),)
def clean(self):
if self.max_count < self.min_count:
raise ValidationError(_('The minimum number needs to be lower than the maximum number.'))
class Question(LoggedModel):
"""
A question is an input field that can be used to extend a ticket by custom information,

View File

@@ -428,6 +428,9 @@ class AbstractPosition(models.Model):
voucher = models.ForeignKey(
'Voucher', null=True, blank=True
)
addon_to = models.ForeignKey(
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
)
class Meta:
abstract = True

View File

@@ -1,4 +1,4 @@
from collections import Counter, namedtuple
from collections import Counter, defaultdict, namedtuple
from datetime import timedelta
from decimal import Decimal
from typing import List, Optional
@@ -48,11 +48,17 @@ error_messages = {
'voucher_expired': _('This voucher is expired.'),
'voucher_invalid_item': _('This voucher is not valid for this product.'),
'voucher_required': _('You need a valid voucher code to order this product.'),
'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.'),
}
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas'))
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas'))
@@ -100,9 +106,15 @@ class CartManager:
def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]):
self._items_cache.update(
{i.pk: i for i in self.event.items.prefetch_related('quotas').filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)}
{
i.pk: i
for i
in self.event.items.select_related('category').prefetch_related(
'addons', 'addons__addon_category', 'quotas'
).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)
}
)
self._variations_cache.update(
{v.pk: v for v in
@@ -247,7 +259,8 @@ class CartManager:
price = self._get_price(item, variation, voucher, i.get('price'))
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False
)
self._check_item_constraints(op)
operations.append(op)
@@ -280,6 +293,116 @@ class CartManager:
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
self._operations.append(self.RemoveOperation(position=cp))
def set_addons(self, addons):
self._update_items_cache(
[a['item'] for a in addons],
[a['variation'] for a in addons],
)
current_addons = defaultdict(dict)
input_addons = defaultdict(set)
selected_addons = defaultdict(set)
cpcache = {}
quota_diff = Counter()
operations = []
available_categories = defaultdict(set)
toplevel_cp = self.positions.filter(
addon_to__isnull=True
).prefetch_related(
'addons', 'item__addons', 'item__addons__addon_category'
).select_related('item', 'variation')
for cp in toplevel_cp:
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()}
cpcache[cp.pk] = cp
current_addons[cp] = {
(a.item_id, a.variation_id): a
for a in cp.addons.all()
}
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 self._items_cache or (a['variation'] and a['variation'] not in self._variations_cache):
raise CartError(error_messages['not_for_sale'])
if a['addon_to'] not in cpcache:
raise CartError(error_messages['addon_invalid_base'])
cp = cpcache[a['addon_to']]
item = self._items_cache[a['item']]
variation = self._variations_cache[a['variation']] if a['variation'] is not None else None
if item.category_id not in available_categories[cp.pk]:
raise CartError(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.all()) if variation is None else list(variation.quotas.all())
if not quotas:
raise CartError(error_messages['unavailable'])
if a['item'] in ([_a[0] for _a in input_addons[cp.id]]):
raise CartError(error_messages['addon_duplicate_item'])
input_addons[cp.id].add((a['item'], a['variation']))
selected_addons[cp.id, item.category_id].add((a['item'], a['variation']))
if (a['item'], a['variation']) not in current_addons[cp]:
for quota in quotas:
quota_diff[quota] += 1
price = self._get_price(item, variation, None, None)
op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp
)
self._check_item_constraints(op)
operations.append(op)
for cp in toplevel_cp:
item = cp.item
for iao in item.addons.all():
selected = selected_addons[cp.id, iao.addon_category_id]
if len(selected) > iao.max_count:
# TODO: Proper i18n
# TODO: Proper pluralization
raise CartError(
error_messages['addon_max_count'],
{
'base': str(item.name),
'max': iao.max_count,
'cat': str(iao.addon_category.name),
}
)
elif len(selected) < iao.min_count:
# TODO: Proper i18n
# TODO: Proper pluralization
raise CartError(
error_messages['addon_min_count'],
{
'base': str(item.name),
'min': iao.min_count,
'cat': str(iao.addon_category.name),
}
)
for cp, al in current_addons.items():
for k, v in al.items():
if k not in input_addons[cp.id]:
if v.expires > self.now_dt:
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
for quota in quotas:
quota_diff[quota] -= 1
op = self.RemoveOperation(position=v)
operations.append(op)
self._quota_diff += quota_diff
self._operations += operations
def _get_quota_availability(self):
quotas_ok = {}
for quota, count in self._quota_diff.items():
@@ -388,7 +511,8 @@ class CartManager:
new_cart_positions.append(CartPosition(
event=self.event, item=op.item, variation=op.variation,
price=op.price, expires=self._expiry,
cart_id=self.cart_id, voucher=op.voucher
cart_id=self.cart_id, voucher=op.voucher,
addon_to=op.addon_to if op.addon_to else None
))
elif isinstance(op, self.ExtendOperation):
if available_count == 1:
@@ -423,7 +547,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
:param items: A list of tuple of the form (item id, variation id or None, number, custom_price, voucher)
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
:param session: Session ID of a guest
:param coupon: A coupon that should also be reeemed
:raises CartError: On any error that occured
@@ -446,7 +570,7 @@ def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=Non
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
:param items: A list of tuple of the form (item id, variation id or None, number)
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
:param session: Session ID of a guest
"""
with language(locale):
@@ -460,3 +584,23 @@ def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=Non
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None) -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
:param addons: A list of dicts with the keys addon_to, item, variation
:param session: Session ID of a guest
"""
event = Event.objects.get(id=event)
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
cm.set_addons(addons)
cm.commit()
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy'])

View File

@@ -11,6 +11,7 @@ from pretix.base.forms import I18nFormSet, I18nModelForm
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn
class CategoryForm(I18nModelForm):
@@ -19,7 +20,8 @@ class CategoryForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'name',
'description'
'description',
'is_addon'
]
@@ -208,3 +210,58 @@ class ItemVariationForm(I18nModelForm):
'active',
'default_price',
]
class ItemAddOnsFormSet(I18nFormSet):
def __init__(self, *args, **kwargs):
self.event = kwargs.get('event')
super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
def clean(self):
super().clean()
categories = set()
for i in range(0, self.total_form_count()):
form = self.forms[i]
if self.can_delete:
if self._should_delete_form(form):
# This form is going to be deleted so any of its errors
# should not cause the entire formset to be invalid.
continue
if form.cleaned_data['addon_category'] in categories:
raise ValidationError(_('You added the same add-on category twice'))
categories.add(form.cleaned_data['addon_category'])
@property
def empty_form(self):
self.is_valid()
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
locales=self.locales,
event=self.event
)
self.add_fields(form, None)
return form
class ItemAddOnForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['addon_category'].queryset = self.event.categories.all()
class Meta:
model = ItemAddOn
localized_fields = '__all__'
fields = [
'addon_category',
'min_count',
'max_count',
]

View File

@@ -95,6 +95,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.item.variation.added': _('The variation "{value}" has been created.'),
'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'),
'pretix.event.item.variation.changed': _('The variation "{value}" has been modified.'),
'pretix.event.item.addons.added': _('An add-on has been added to this product.'),
'pretix.event.item.addons.removed': _('An add-on has been removed from this product.'),
'pretix.event.item.addons.changed': _('An add-on has been changed on this product.'),
'pretix.event.quota.added': _('The quota has been added.'),
'pretix.event.quota.deleted': _('The quota has been deleted.'),
'pretix.event.quota.changed': _('The quota has been modified.'),

View File

@@ -0,0 +1,86 @@
{% extends "pretixcontrol/item/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% block inside %}
<p>
{% blocktrans trimmed %}
With add-ons, you can specify products that can be bought as an addition to this product. For example, if
you host a conference with a base conference ticket and a number of workshops, you could define the
workshops as add-ons to the conference ticket. With this configuration, the workshops cannot be bought
on their own but only in combination with a conference ticket. You can here specify categories of products
that can be used as add-ons to this product. You can also specify the minimum and maximum number of
add-ons of the given category that can or need to be chosen. The user can buy every add-on from the
category at most once. If an add-on product has multiple variations, only one of them can be bought.
{% endblocktrans %}
</p>
<form class="form-horizontal branches" method="post" action="">
{% csrf_token %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Add-On" %}</h3>
</div>
<div class="col-sm-4">
<button type="button" class="btn btn-xs btn-danger pull-right" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_form_errors form %}
{% bootstrap_field form.addon_category layout='horizontal' %}
{% bootstrap_field form.min_count layout='horizontal' %}
{% bootstrap_field form.max_count layout='horizontal' %}
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="panel panel-default" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="panel-heading">
<div class="row">
<div class="col-sm-8">
<h3 class="panel-title">{% trans "Add-On" %}</h3>
</div>
<div class="col-sm-4">
<button type="button" class="btn btn-xs btn-danger pull-right" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.addon_category layout='horizontal' %}
{% bootstrap_field formset.empty_form.min_count layout='horizontal' %}
{% bootstrap_field formset.empty_form.max_count layout='horizontal' %}
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new add-on" %}</button>
</p>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -4,20 +4,25 @@
{% block content %}
{% if object.id %}
<h1>{% trans "Modify product:" %} {{ object.name }}</h1>
{% if object.has_variations %}
<ul class="nav nav-pills">
<li {% if "event.item" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.item' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
{% trans "General information" %}
</a>
</li>
<li {% if "event.item.variations" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
{% trans "Variations" %}
{% if object.has_variations %}
<li {% if "event.item.variations" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
{% trans "Variations" %}
</a>
</li>
{% endif %}
<li {% if "event.item.addons" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.item.addons' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
{% trans "Add-Ons" %}
</a>
</li>
</ul>
{% endif %}
{% else %}
<h1>{% trans "Create product" %}</h1>
<p>{% blocktrans trimmed %}
@@ -26,12 +31,12 @@
{% endif %}
{% if object.id and not object.quotas.exists %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
{% blocktrans trimmed %}
Please note that your product will <strong>not</strong> be available for sale until you have added your
item to an existing or newly created quota.
{% endblocktrans %}
{% endblocktrans %}
</div>
{% endif %}
{% block inside %}
{% endblock %}
{% block inside %}
{% endblock %}
{% endblock %}

View File

@@ -13,6 +13,7 @@
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.description layout="horizontal" %}
{% bootstrap_field form.is_addon layout="horizontal" %}
</fieldset>
</div>
{% if category %}

View File

@@ -61,6 +61,8 @@ urlpatterns = [
url(r'^items/(?P<item>\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'),
url(r'^items/(?P<item>\d+)/variations$', item.ItemVariations.as_view(),
name='event.item.variations'),
url(r'^items/(?P<item>\d+)/addons', item.ItemAddOns.as_view(),
name='event.item.addons'),
url(r'^items/(?P<item>\d+)/up$', item.item_move_up, name='event.items.up'),
url(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'),
url(r'^items/(?P<item>\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'),

View File

@@ -21,9 +21,11 @@ from pretix.base.models import (
CachedTicket, Item, ItemCategory, ItemVariation, Order, Question,
QuestionAnswer, QuestionOption, Quota, Voucher,
)
from pretix.base.models.items import ItemAddOn
from pretix.control.forms.item import (
CategoryForm, ItemCreateForm, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemCreateForm,
ItemUpdateForm, ItemVariationForm, ItemVariationsFormSet, QuestionForm,
QuestionOptionForm, QuotaForm,
)
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
@@ -927,6 +929,74 @@ class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView
return context
class ItemAddOns(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/item/addons.html'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.item = None
@cached_property
def formset(self):
formsetclass = inlineformset_factory(
Item, ItemAddOn,
form=ItemAddOnForm, formset=ItemAddOnsFormSet,
can_order=False, can_delete=True, extra=0
)
return formsetclass(self.request.POST if self.request.method == "POST" else None,
queryset=ItemAddOn.objects.filter(base_item=self.get_object()),
event=self.request.event)
def post(self, request, *args, **kwargs):
with transaction.atomic():
if self.formset.is_valid():
for form in self.formset.deleted_forms:
if not form.instance.pk:
continue
self.get_object().log_action(
'pretix.event.item.addons.removed', user=self.request.user, data={
'category': form.instance.addon_category.pk
}
)
form.instance.delete()
form.instance.pk = None
forms = [
ef for ef in self.formset.extra_forms + self.formset.initial_forms
if ef not in self.formset.deleted_forms
]
for i, form in enumerate(forms):
form.instance.base_item = self.get_object()
created = not form.instance.pk
form.save()
if form.has_changed():
change_data = {k: form.cleaned_data.get(k) for k in form.changed_data}
change_data['id'] = form.instance.pk
self.get_object().log_action(
'pretix.event.item.addons.changed' if not created else
'pretix.event.item.addons.added',
user=self.request.user, data=change_data
)
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
return self.get(request, *args, **kwargs)
def get_success_url(self) -> str:
return reverse('control:event.item.addons', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'item': self.get_object().id,
})
def get_context_data(self, **kwargs) -> dict:
self.object = self.get_object()
context = super().get_context_data(**kwargs)
context['formset'] = self.formset
return context
class ItemDelete(EventPermissionRequiredMixin, DeleteView):
model = Item
template_name = 'pretixcontrol/item/delete.html'

View File

@@ -11,14 +11,17 @@ from django.views.generic.base import TemplateResponseMixin
from pretix.base.models import Order
from pretix.base.models.orders import InvoiceAddress
from pretix.base.services.cart import set_cart_addons
from pretix.base.services.orders import perform_order
from pretix.base.signals import register_payment_providers
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.forms.checkout import ContactForm, InvoiceAddressForm
from pretix.presale.forms.checkout import (
AddOnsForm, ContactForm, InvoiceAddressForm,
)
from pretix.presale.signals import (
checkout_confirm_messages, checkout_flow_steps, order_meta_from_request,
)
from pretix.presale.views import CartMixin, get_cart_total
from pretix.presale.views import CartMixin, get_cart, get_cart_total
from pretix.presale.views.async import AsyncAction
from pretix.presale.views.questions import QuestionsViewMixin
@@ -126,6 +129,116 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
raise NotImplementedError()
class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
priority = 40
identifier = "addons"
template_name = "pretixpresale/event/checkout_addons.html"
task = set_cart_addons
known_errortypes = ['CartError']
def is_applicable(self, request):
return get_cart(request).filter(item__addons__isnull=False).exists()
def is_completed(self, request, warn=False):
for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
):
a = cartpos.addons.all()
for iao in cartpos.item.addons.all():
found = len([1 for p in a if p.item.category_id == iao.addon_category_id])
if found < iao.min_count or found > iao.max_count:
return False
return True
@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.
"""
formset = []
for cartpos in get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation'
):
current_addon_products = {
a.item_id: a.variation_id for a in cartpos.addons.all()
}
formsetentry = {
'cartpos': cartpos,
'item': cartpos.item,
'variation': cartpos.variation,
'categories': []
}
for iao in cartpos.item.addons.all():
category = {
'category': iao.addon_category,
'min_count': iao.min_count,
'max_count': iao.max_count,
'form': AddOnsForm(
event=self.request.event,
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)
)
}
if len(category['form'].fields) > 0:
formsetentry['categories'].append(category)
formset.append(formsetentry)
return formset
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['forms'] = self.forms
return ctx
def get_success_message(self, value):
return None
def get_success_url(self, value):
return self.get_next_url(self.request)
def get_error_url(self):
return self.get_step_url()
def get(self, request):
self.request = request
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
return TemplateFlowStep.get(self, request)
def post(self, request, *args, **kwargs):
self.request = request
is_valid = True
data = []
for f in self.forms:
for c in f['categories']:
is_valid = is_valid and c['form'].is_valid()
if c['form'].is_valid():
for k, v in c['form'].cleaned_data.items():
itemid = int(k[5:])
if v is True:
data.append({
'addon_to': f['cartpos'].pk,
'item': itemid,
'variation': None
})
elif v:
data.append({
'addon_to': f['cartpos'].pk,
'item': itemid,
'variation': int(v)
})
if not is_valid:
return self.get(request, *args, **kwargs)
return self.do(self.request.event.id, data, self.request.session.session_key)
class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
priority = 50
identifier = "questions"
@@ -395,6 +508,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
DEFAULT_FLOW = (
AddOnsStep,
QuestionsStep,
PaymentStep,
ConfirmStep

View File

@@ -2,9 +2,12 @@ from decimal import Decimal
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count, Prefetch, Q
from django.utils.formats import number_format
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Question
from pretix.base.models import ItemVariation, Question
from pretix.base.models.orders import InvoiceAddress
@@ -141,3 +144,90 @@ class QuestionsForm(forms.Form):
# Cache the answer object for later use
field.answer = answers[0]
self.fields['question_%s' % q.id] = field
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):
if isinstance(item_or_variation, ItemVariation):
variation = item_or_variation
item = item_or_variation.item
price = variation.price
price_net = variation.net_price
label = variation.value
else:
item = item_or_variation
price = item.default_price
price_net = item.default_price_net
label = item.name
if not item.tax_rate or not price:
return '{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(
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(
name=label, currency=event.currency, price=number_format(price),
taxes=number_format(item.tax_rate)
)
def __init__(self, *args, **kwargs):
"""
Takes additional keyword arguments:
:param category: The category to choose from
:param event: The event this belongs to
"""
category = kwargs.pop('category')
event = kwargs.pop('event')
current_addons = kwargs.pop('initial')
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')
for i in items:
if i.has_variations:
field = forms.ChoiceField(
choices=[('', '')] + [
(
v.pk,
self._label(event, v)
) for v in i.available_variations
],
label=i.name,
required=False,
widget=forms.RadioSelect,
initial=current_addons.get(i.pk)
)
else:
field = forms.BooleanField(
label=self._label(event, i),
required=False,
initial=i.pk in current_addons
)
self.fields['item_%s' % i.pk] = field

View File

@@ -0,0 +1,70 @@
{% extends "pretixpresale/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Checkout" %}{% endblock %}
{% block content %}
<h2>{% trans "Checkout" %}</h2>
<p>
{% trans "For some of the products in your cart, you can choose additional options before you continue." %}
</p>
<form class="form-horizontal" method="post" data-asynctask>
{% csrf_token %}
<div class="panel-group" id="questions_group">
{% for form in forms %}
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#cp{{ form.pos.id }}">
<strong>{{ form.item.name }}</strong>
{% if form.variation %}
{{ form.variation }}
{% endif %}
</a>
</h4>
</div>
<div id="cp{{ form.pos.id }}"
class="panel-collapse collapsed in">
<div class="panel-body">
{% for c in form.categories %}
<fieldset>
<legend>{{ c.category.name }}</legend>
<p>
{% if c.min_count == c.max_count %}
{% blocktrans trimmed with min_count=c.min_count %}
You need to choose {{ min_count }} options from this category.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with min_count=c.min_count max_count=c.max_count %}
You can choose between {{ min_count }} and {{ max_count }} options from
this category.
{% endblocktrans %}
{% endif %}
</p>
{% bootstrap_form c.form layout="horizontal" %}
</fieldset>
{% empty %}
<em>
{% trans "There are no add-ons available for this product." %}
</em>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{{ prev_url }}">
{% trans "Go back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Continue" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}