Order changes: Do not allow to double-book add-ons

This commit is contained in:
Raphael Michel
2026-01-26 18:35:35 +01:00
parent 65fe7b3396
commit 0df504e844
4 changed files with 66 additions and 37 deletions

View File

@@ -2094,6 +2094,43 @@ class OrderChangeManager:
) )
item_counts[item] += 1 item_counts[item] += 1
# 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
is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not item.all_sales_channels and
not item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
# "Re-select" add-on
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
continue
if a.checkins.filter(list__consider_tickets_used=True).exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
'addon': str(a.item.name),
}
)
self.cancel(a)
item_counts[a.item] -= 1
# Check constraints on the add-on combinations # Check constraints on the add-on combinations
for op in toplevel_op: for op in toplevel_op:
item = op.item item = op.item
@@ -2126,41 +2163,6 @@ class OrderChangeManager:
} }
) )
# 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
is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not item.all_sales_channels and
not item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
continue
if a.checkins.filter(list__consider_tickets_used=True).exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
'addon': str(a.item.name),
}
)
self.cancel(a)
item_counts[a.item] -= 1
for item, count in item_counts.items(): for item, count in item_counts.items():
if count == 0: if count == 0:
continue continue

View File

@@ -372,6 +372,19 @@
</article> </article>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if c.items_missing %}
<div class="product-appendix-row">
<p class="text-muted">
{% trans "There are currently selected products in this add-on category that currently cannot be changed because they are not on sale:" %}
</p>
<ul class="text-muted">
{% for itemvar, count in c.items_missing.items %}
<li>{{ count }}x {{ itemvar.0 }}{% if itemvar.1 %} {{ itemvar.1 }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</fieldset> </fieldset>
{% endwith %} {% endwith %}
{% empty %} {% empty %}

View File

@@ -40,7 +40,7 @@ import logging
import mimetypes import mimetypes
import os import os
import re import re
from collections import OrderedDict, defaultdict from collections import Counter, OrderedDict, defaultdict
from decimal import Decimal from decimal import Decimal
from urllib.parse import quote from urllib.parse import quote
@@ -1431,11 +1431,13 @@ class OrderChangeMixin:
'categories': [] 'categories': []
} }
current_addon_products = defaultdict(list) current_addon_products = defaultdict(list)
current_addon_products_missing = Counter()
for a in p.addons.all(): for a in p.addons.all():
if a.canceled: if a.canceled:
continue continue
if not a.is_bundled: if not a.is_bundled:
current_addon_products[a.item_id, a.variation_id].append(a) current_addon_products[a.item_id, a.variation_id].append(a)
current_addon_products_missing[a.item, a.variation] += 1
for iao in p.item.addons.all(): for iao in p.item.addons.all():
ckey = '{}-{}'.format(p.subevent.pk if p.subevent else 0, iao.addon_category.pk) ckey = '{}-{}'.format(p.subevent.pk if p.subevent else 0, iao.addon_category.pk)
@@ -1473,6 +1475,7 @@ class OrderChangeMixin:
if i.has_variations: if i.has_variations:
for v in i.available_variations: for v in i.available_variations:
v.initial = len(current_addon_products[i.pk, v.pk]) v.initial = len(current_addon_products[i.pk, v.pk])
current_addon_products_missing[i, v] = 0
if v.initial and i.free_price: if v.initial and i.free_price:
a = current_addon_products[i.pk, v.pk][0] a = current_addon_products[i.pk, v.pk][0]
v.initial_price = TaxedPrice( v.initial_price = TaxedPrice(
@@ -1488,6 +1491,7 @@ class OrderChangeMixin:
i.expand = any(v.initial for v in i.available_variations) i.expand = any(v.initial for v in i.available_variations)
else: else:
i.initial = len(current_addon_products[i.pk, None]) i.initial = len(current_addon_products[i.pk, None])
current_addon_products_missing[i, None] = 0
if i.initial and i.free_price: if i.initial and i.free_price:
a = current_addon_products[i.pk, None][0] a = current_addon_products[i.pk, None][0]
i.initial_price = TaxedPrice( i.initial_price = TaxedPrice(
@@ -1509,7 +1513,8 @@ class OrderChangeMixin:
'min_count': iao.min_count, 'min_count': iao.min_count,
'max_count': iao.max_count, 'max_count': iao.max_count,
'iao': iao, 'iao': iao,
'items': [i for i in items if not i.require_voucher] 'items': [i for i in items if not i.require_voucher],
'items_missing': {k: v for k, v in current_addon_products_missing.items() if v},
}) })
return positions return positions

View File

@@ -99,6 +99,15 @@
} }
} }
.product-appendix-row {
border-top: 1px solid $table-border-color;
border-bottom: 1px solid $table-border-color;
padding: 1.25*$line-height-computed 0;
& > :last-child {
margin-bottom: 0;
}
}
article.item-with-variations { article.item-with-variations {
margin: 0 -15px; margin: 0 -15px;
padding: 0 15px; padding: 0 15px;