# Conflicts:
#	src/pretix/base/services/orders.py
This commit is contained in:
Lukas Bockstaller
2026-04-02 11:07:17 +02:00
parent c624fcfe41
commit 5ab3b08fca
6 changed files with 94 additions and 114 deletions

View File

@@ -1,30 +1,20 @@
import dataclasses
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from functools import reduce from typing import Callable, Dict, List, Literal, Set
from typing import Callable, Dict, List, Literal, Optional, Set, Union
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Prefetch from django.db.models import Prefetch
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models import Checkin from pretix.base.models import Event, Item, ItemVariation, Order, OrderPosition
from pretix.base.models import Event, Order, OrderPosition
from pretix.base.models import Item
from pretix.base.models import ItemVariation
from pretix.base.reldate import ModelRelativeDateTimeField from pretix.base.reldate import ModelRelativeDateTimeField
from pretix.base.services.orders import CancellationCheck
from pretix.base.signals import self_service_cancellation_checks
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
@dataclass(frozen=True) @dataclass(frozen=True)
class OrderDiff: class OrderDiff:
order: Order order: Order
@@ -37,17 +27,22 @@ class OrderDiff:
def cancel_all(order: Order) -> "OrderDiff": def cancel_all(order: Order) -> "OrderDiff":
return OrderDiff(order=order, prev=set(order.positions.all()), next=set()) return OrderDiff(order=order, prev=set(order.positions.all()), next=set())
@dataclass(frozen=True) @dataclass(frozen=True)
class CancellationCheckResult: class CancellationCheckResult:
cancellation_possible: bool cancellation_possible: bool
reason: str reason: str
# Maps Check identifier → cancellation check result # Maps Check identifier → cancellation check result
CancellationCheckResultsById = Dict[str, CancellationCheckResult] CancellationCheckResultsById = Dict[str, CancellationCheckResult]
CheckFn = Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById] CheckFn = Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById]
FeeType = Literal['position_fee', 'process_fee'] FeeType = Literal['position_fee', 'process_fee']
class Ruling: class Ruling:
""" """
A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition. A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition.
@@ -69,9 +64,7 @@ class Ruling:
self.results = results self.results = results
self.fee_type = fee_type self.fee_type = fee_type
self.fee = fee self.fee = fee
self.cancellation_possible = all(ruling.cancellation_possible self.cancellation_possible = all(ruling.cancellation_possible for ruling in results.values())
for ruling in results.values()
)
@classmethod @classmethod
def from_absolute_fee( def from_absolute_fee(
@@ -103,7 +96,7 @@ class Ruling:
) -> "Ruling": ) -> "Ruling":
""" """
Constructs a Ruling with an absolute fee. Constructs a Ruling with an absolute fee.
:param rule_id: Id of the rule :param rule_id: ID of the rule
:param results: CheckResult object :param results: CheckResult object
:param fee_type: Must be a position_fee as the fee can only be in reference to a position :param fee_type: Must be a position_fee as the fee can only be in reference to a position
:param reference_price: Price of the position to reference :param reference_price: Price of the position to reference
@@ -114,7 +107,12 @@ class Ruling:
if fee_type == "process_fee": if fee_type == "process_fee":
raise ValidationError("Process fee cannot be used with relative fees") raise ValidationError("Process fee cannot be used with relative fees")
return Ruling(rule_id=rule_id, results=results, fee_type=fee_type, fee=round_decimal(reference_price * (percentage/100), currency)) return Ruling(
rule_id=rule_id,
results=results,
fee_type=fee_type,
fee=round_decimal(reference_price * (percentage / 100), currency)
)
def __lt__(self, other): def __lt__(self, other):
if not isinstance(other, Ruling): if not isinstance(other, Ruling):
@@ -139,7 +137,6 @@ def validate_status_chars(value):
raise ValidationError("Duplicate characters are not allowed.") raise ValidationError("Duplicate characters are not allowed.")
class CancellationRule(models.Model): class CancellationRule(models.Model):
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
@@ -157,7 +154,6 @@ class CancellationRule(models.Model):
ItemVariation, blank=True, verbose_name=_("Variations") ItemVariation, blank=True, verbose_name=_("Variations")
) )
allowed_if_in_order_status = models.CharField( allowed_if_in_order_status = models.CharField(
max_length=4, max_length=4,
choices=Order.STATUS_CHOICE, choices=Order.STATUS_CHOICE,
@@ -182,7 +178,6 @@ class CancellationRule(models.Model):
default=Decimal("0.00"), default=Decimal("0.00"),
) # wird als sum() kombiniert ) # wird als sum() kombiniert
fee_cancellation_process = models.DecimalField( fee_cancellation_process = models.DecimalField(
max_digits=13, max_digits=13,
decimal_places=2, decimal_places=2,
@@ -200,7 +195,7 @@ class CancellationRule(models.Model):
if not self.allowed_until and not self.allowed_until: if not self.allowed_until and not self.allowed_until:
return {check_id: CancellationCheckResult( return {check_id: CancellationCheckResult(
cancellation_possible=True, cancellation_possible=True,
reason=f"No time window specified", reason="No time window specified",
)} )}
relevant_event = order_position.subevent or order_position.event relevant_event = order_position.subevent or order_position.event
@@ -226,14 +221,13 @@ class CancellationRule(models.Model):
reason=f"Cancellation after time window ending on {self.allowed_until.datetime(relevant_event)}", reason=f"Cancellation after time window ending on {self.allowed_until.datetime(relevant_event)}",
)} )}
def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById: def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
check_id = "ORDER_STATUS" check_id = "ORDER_STATUS"
if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS): if diff.order.status == "".join([]):
return {check_id: CancellationCheckResult( return {check_id: CancellationCheckResult(
cancellation_possible=True, cancellation_possible=True,
reason=f"Orders in every status can be cancelled", reason="Orders in every status can be cancelled",
)} )}
elif diff.order.status in self.allowed_if_in_order_status: elif diff.order.status in self.allowed_if_in_order_status:
return {check_id: CancellationCheckResult( return {check_id: CancellationCheckResult(
@@ -245,3 +239,12 @@ class CancellationRule(models.Model):
cancellation_possible=False, cancellation_possible=False,
reason=f"Order in status '{diff.order.status}' cannot be canceled", reason=f"Order in status '{diff.order.status}' cannot be canceled",
)} )}
class CancellationCheck:
id: str
prefetches: List[Prefetch] = []
related_selects: List[str] = []
def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResult:
raise NotImplementedError()

View File

@@ -203,7 +203,6 @@ class Order(LockModel, LoggedModel):
(STATUS_EXPIRED, _("expired")), (STATUS_EXPIRED, _("expired")),
(STATUS_CANCELED, _("canceled")), (STATUS_CANCELED, _("canceled")),
) )
ALLOWED_STATUS_CHARS={char for char, _ in STATUS_CHOICE} # {'n', 'p', 'e', 'c'}
code = models.CharField( code = models.CharField(
max_length=16, max_length=16,

View File

@@ -71,7 +71,7 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry, CartPosition, Checkin, Device, Event, GiftCard, Item, ItemVariation, LogEntry,
Membership, Order, OrderPayment, OrderPosition, Quota, Seat, Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher, SeatCategoryMapping, User, Voucher,
) )
@@ -1633,7 +1633,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled', 'result', 'count')) 'valid_from', 'valid_until', 'is_bundled', 'result'))
SplitOperation = namedtuple('SplitOperation', ('position',)) SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1647,24 +1647,16 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple()) ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult: class AddPositionResult:
_positions: Optional[List[OrderPosition]] _position: Optional[OrderPosition]
def __init__(self): def __init__(self):
self._positions = None self._position = None
@property @property
def position(self) -> OrderPosition: def position(self) -> OrderPosition:
if self._positions is None: if self._position is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
if len(self._positions) != 1: return self._position
raise RuntimeError("More than one position created.")
return self._positions[0]
@property
def positions(self) -> List[OrderPosition]:
if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._positions
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False): def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order self.order = order
@@ -1871,12 +1863,8 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult': valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult':
if count < 1:
raise ValueError("Count must be positive")
if isinstance(seat, str): if isinstance(seat, str):
if count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat: if not seat:
seat = None seat = None
else: else:
@@ -1930,14 +1918,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross * count self._totaldiff_guesstimate += price.gross
self._quotadiff.update({q: count for q in new_quotas}) self._quotadiff.update(new_quotas)
if seat: if seat:
self._seatdiff.update([seat]) self._seatdiff.update([seat])
result = self.AddPositionResult() result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled, result, count)) valid_from, valid_until, is_bundled, result))
return result return result
def split(self, position: OrderPosition): def split(self, position: OrderPosition):
@@ -2557,9 +2545,6 @@ class OrderChangeManager:
secret_dirty.remove(position) secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret']) position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
new_pos = []
new_logs = []
for i in range(op.count):
pos = OrderPosition.objects.create( pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to, item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
@@ -2569,8 +2554,7 @@ class OrderChangeManager:
is_bundled=op.is_bundled, is_bundled=op.is_bundled,
) )
nextposid += 1 nextposid += 1
new_pos.append(pos) self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk, 'position': pos.pk,
'item': op.item.pk, 'item': op.item.pk,
'variation': op.variation.pk if op.variation else None, 'variation': op.variation.pk if op.variation else None,
@@ -2582,10 +2566,8 @@ class OrderChangeManager:
'seat': op.seat.pk if op.seat else None, 'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None, 'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None, 'valid_until': op.valid_until.isoformat() if op.valid_until else None,
}, save=False)) })
op.result._position = pos
op.result._positions = new_pos
LogEntry.bulk_create_and_postprocess(new_logs)
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position) position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position) split_positions.append(position)
@@ -2910,7 +2892,7 @@ class OrderChangeManager:
return total return total
def _check_order_size(self): def _check_order_size(self):
if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
raise OrderError( raise OrderError(
self.error_messages['max_order_size'] % { self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE, 'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2971,7 +2953,7 @@ class OrderChangeManager:
]) + len([ ]) + len([
o for o in self._operations if isinstance(o, self.SplitOperation) o for o in self._operations if isinstance(o, self.SplitOperation)
]) ])
adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)]) adds = len([o for o in self._operations if isinstance(o, self.AddOperation)])
if current > 0 and current - cancels + adds < 1: if current > 0 and current - cancels + adds < 1:
raise OrderError(self.error_messages['complete_cancel']) raise OrderError(self.error_messages['complete_cancel'])
@@ -3018,7 +3000,6 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart: elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position]) fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
for i in range(op.count):
cp = CartPosition( cp = CartPosition(
event=self.event, event=self.event,
item=op.item, item=op.item,

View File

@@ -1212,8 +1212,5 @@ self_service_cancellation_checks = EventPluginSignal()
This signal is sent out to collect checks to approve or deny a self service cancellation. This signal is sent out to collect checks to approve or deny a self service cancellation.
You are expected to return a class instance that implements CancellationCheck. You are expected to return a class instance that implements CancellationCheck.
It is is expected that that the CheckFn will not issue any further queries. It is is expected that that the CheckFn will not issue any further queries.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """

View File

@@ -21,6 +21,7 @@
# #
import contextlib import contextlib
import logging import logging
from django.conf import settings from django.conf import settings
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db import connection, transaction from django.db import connection, transaction
@@ -31,6 +32,7 @@ from django.utils.functional import lazy
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DummyRollbackException(Exception): class DummyRollbackException(Exception):
pass pass

View File

@@ -8,9 +8,7 @@ from django_scopes import scope
from freezegun import freeze_time from freezegun import freeze_time
from pretix.base.models import Event, Item, Order, OrderPosition, Organizer from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
from pretix.base.models.cancellation import CancellationRule, Ruling from pretix.base.models.cancellation import CancellationRule, OrderDiff
from pretix.base.models.cancellation import CheckRes
from pretix.base.models.cancellation import OrderDiff
NOW = now() NOW = now()
DAYS_UNTIL_EVENT=60 DAYS_UNTIL_EVENT=60