# 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 decimal import Decimal
from functools import reduce
from typing import Callable, Dict, List, Literal, Optional, Set, Union
from typing import Callable, Dict, List, Literal, Set
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Prefetch
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models import Checkin
from pretix.base.models import Event, Order, OrderPosition
from pretix.base.models import Item
from pretix.base.models import ItemVariation
from pretix.base.models import Event, Item, ItemVariation, Order, OrderPosition
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
@dataclass(frozen=True)
class OrderDiff:
order: Order
@@ -37,17 +27,22 @@ class OrderDiff:
def cancel_all(order: Order) -> "OrderDiff":
return OrderDiff(order=order, prev=set(order.positions.all()), next=set())
@dataclass(frozen=True)
class CancellationCheckResult:
cancellation_possible: bool
reason: str
# Maps Check identifier → cancellation check result
CancellationCheckResultsById = Dict[str, CancellationCheckResult]
CheckFn=Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById]
CheckFn = Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById]
FeeType = Literal['position_fee', 'process_fee']
class Ruling:
"""
A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition.
@@ -69,9 +64,7 @@ class Ruling:
self.results = results
self.fee_type = fee_type
self.fee = fee
self.cancellation_possible = all(ruling.cancellation_possible
for ruling in results.values()
)
self.cancellation_possible = all(ruling.cancellation_possible for ruling in results.values())
@classmethod
def from_absolute_fee(
@@ -103,7 +96,7 @@ class Ruling:
) -> "Ruling":
"""
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 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
@@ -114,7 +107,12 @@ class Ruling:
if fee_type == "process_fee":
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):
if not isinstance(other, Ruling):
@@ -130,7 +128,7 @@ class Ruling:
def validate_status_chars(value):
invalid=set(value) - Order.ALLOWED_STATUS_CHARS
invalid = set(value) - Order.ALLOWED_STATUS_CHARS
if invalid:
raise ValidationError(
f"Invalid characters: {invalid}. Allowed: {Order.ALLOWED_STATUS_CHARS}"
@@ -139,51 +137,48 @@ def validate_status_chars(value):
raise ValidationError("Duplicate characters are not allowed.")
class CancellationRule(models.Model):
event=models.ForeignKey(
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="orders",
on_delete=models.CASCADE
)
all_products=models.BooleanField(
all_products = models.BooleanField(
verbose_name=_("All products and variations"),
default=True,
)
limit_products=models.ManyToManyField(Item, verbose_name=_("Products"), blank=True)
limit_variations=models.ManyToManyField(
limit_products = models.ManyToManyField(Item, verbose_name=_("Products"), blank=True)
limit_variations = models.ManyToManyField(
ItemVariation, blank=True, verbose_name=_("Variations")
)
allowed_if_in_order_status=models.CharField(
allowed_if_in_order_status = models.CharField(
max_length=4,
choices=Order.STATUS_CHOICE,
verbose_name=_("Cancellation possible if order is in status"),
validators=[validate_status_chars],
default="".join(Order.ALLOWED_STATUS_CHARS),
)
allowed_until=ModelRelativeDateTimeField(null=True, blank=True)
except_after=ModelRelativeDateTimeField(null=True, blank=True)
allowed_until = ModelRelativeDateTimeField(null=True, blank=True)
except_after = ModelRelativeDateTimeField(null=True, blank=True)
fee_percentage_per_item=models.DecimalField(
fee_percentage_per_item = models.DecimalField(
max_digits=5,
decimal_places=2,
validators=[MinValueValidator("0.00"), MaxValueValidator("100.00")],
verbose_name=_("Fee Percentage per OrderPosition"),
default=Decimal("0.00"),
) # wird als sum() kombiniert
fee_absolute_per_item=models.DecimalField(
fee_absolute_per_item = models.DecimalField(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute fee per OrderPosition"),
default=Decimal("0.00"),
) # wird als sum() kombiniert
fee_cancellation_process=models.DecimalField(
fee_cancellation_process = models.DecimalField(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute fee per Cancellation"),
@@ -200,13 +195,13 @@ class CancellationRule(models.Model):
if not self.allowed_until and not self.allowed_until:
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"No time window specified",
reason="No time window specified",
)}
relevant_event=order_position.subevent or order_position.event
in_allowed_until=time_machine_now() < self.allowed_until.datetime(
relevant_event = order_position.subevent or order_position.event
in_allowed_until = time_machine_now() < self.allowed_until.datetime(
relevant_event) if self.allowed_until else False
in_exemption=time_machine_now() > self.except_after.datetime(
in_exemption = time_machine_now() > self.except_after.datetime(
relevant_event) if self.except_after else False
if in_allowed_until and not in_exemption:
@@ -226,14 +221,13 @@ class CancellationRule(models.Model):
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:
check_id = "ORDER_STATUS"
if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS):
if diff.order.status == "".join([]):
return {check_id: CancellationCheckResult(
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:
return {check_id: CancellationCheckResult(
@@ -245,3 +239,12 @@ class CancellationRule(models.Model):
cancellation_possible=False,
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_CANCELED, _("canceled")),
)
ALLOWED_STATUS_CHARS={char for char, _ in STATUS_CHOICE} # {'n', 'p', 'e', 'c'}
code = models.CharField(
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.media import MEDIA_TYPES
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,
SeatCategoryMapping, User, Voucher,
)
@@ -1633,7 +1633,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
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',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1647,24 +1647,16 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult:
_positions: Optional[List[OrderPosition]]
_position: Optional[OrderPosition]
def __init__(self):
self._positions = None
self._position = None
@property
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.")
if len(self._positions) != 1:
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
return self._position
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order
@@ -1871,12 +1863,8 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult':
if count < 1:
raise ValueError("Count must be positive")
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult':
if isinstance(seat, str):
if count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat:
seat = None
else:
@@ -1930,14 +1918,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross * count
self._quotadiff.update({q: count for q in new_quotas})
self._totaldiff_guesstimate += price.gross
self._quotadiff.update(new_quotas)
if seat:
self._seatdiff.update([seat])
result = self.AddPositionResult()
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
def split(self, position: OrderPosition):
@@ -2557,9 +2545,6 @@ class OrderChangeManager:
secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation):
new_pos = []
new_logs = []
for i in range(op.count):
pos = OrderPosition.objects.create(
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,
@@ -2569,8 +2554,7 @@ class OrderChangeManager:
is_bundled=op.is_bundled,
)
nextposid += 1
new_pos.append(pos)
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk,
'item': op.item.pk,
'variation': op.variation.pk if op.variation else None,
@@ -2582,10 +2566,8 @@ class OrderChangeManager:
'seat': op.seat.pk if op.seat 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,
}, save=False))
op.result._positions = new_pos
LogEntry.bulk_create_and_postprocess(new_logs)
})
op.result._position = pos
elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position)
@@ -2910,7 +2892,7 @@ class OrderChangeManager:
return total
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(
self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2971,7 +2953,7 @@ class OrderChangeManager:
]) + len([
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:
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:
fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation):
for i in range(op.count):
cp = CartPosition(
event=self.event,
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.
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.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""

View File

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

View File

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