mirror of
https://github.com/pretix/pretix.git
synced 2026-05-04 15:04:03 +00:00
Improve add-on products
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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')]),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -159,6 +159,10 @@ DEFAULTS = {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
},
|
||||
'ticket_download_addons': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'last_order_modification_date': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user