move checks to classes and add distinction between process_fees and position fees

This commit is contained in:
Lukas Bockstaller
2026-04-01 18:07:05 +02:00
parent 7ffadb87b3
commit c624fcfe41
4 changed files with 321 additions and 181 deletions

View File

@@ -2,42 +2,33 @@ import dataclasses
from dataclasses import dataclass
from decimal import Decimal
from functools import reduce
from typing import Callable, Dict, List, Set, Union
from typing import Callable, Dict, List, Literal, Optional, Set, Union
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.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 AbsoluteFee:
amount: Decimal
@dataclass(frozen=True)
class RelativeFee:
reference_price: Decimal
percentage: Decimal
@property
def amount(self):
return self.reference_price * (self.percentage/100)
Fee=Union[AbsoluteFee, RelativeFee]
@dataclass(frozen=True)
class OrderDiff:
order: Order
prev: Set[OrderPosition]
next: Set[OrderPosition]
keep: Set[OrderPosition]
def cancellations(self):
return self.prev.difference(self.next)
@@ -47,75 +38,97 @@ class OrderDiff:
return OrderDiff(order=order, prev=set(order.positions.all()), next=set())
@dataclass(frozen=True)
class CheckRes:
class CancellationCheckResult:
cancellation_possible: bool
reason: str
CheckResult=Dict[str, CheckRes]
# Maps Check identifier → cancellation check result
CancellationCheckResultsById = Dict[str, CancellationCheckResult]
CheckFn=Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById]
FeeType = Literal['position_fee', 'process_fee']
CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult]
def merge_check_results(a: CheckResult, b: CheckResult) -> CheckResult:
result = dict(a)
for key, b_inner in b.items():
if key in result:
result[key] = result[key] | b_inner
else:
result[key] = b_inner
return result
@dataclass(frozen=True)
class Ruling:
"""
A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition.
"""
rule_id: int
results: CheckResult
order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0))
position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0)))
results: CancellationCheckResultsById
fee_type: FeeType
fee: Decimal
cancellation_possible: bool
cancellation_possible: bool=dataclasses.field(init=False)
def __post_init__(self):
object.__setattr__(
def __init__(
self,
'cancellation_possible',
all(ruling.cancellation_possible
for ruling in self.results.values()
)
rule_id: int,
results: CancellationCheckResultsById,
fee_type: FeeType,
fee: Decimal
):
self.rule_id = rule_id
self.results = results
self.fee_type = fee_type
self.fee = fee
self.cancellation_possible = all(ruling.cancellation_possible
for ruling in results.values()
)
@property
def total_fee(self):
return self.position_fee.amount + self.order_fee
@classmethod
def from_absolute_fee(
cls,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: FeeType,
absolute_fee: Decimal
) -> "Ruling":
"""
Constructs a Ruling with an absolute fee.
:param rule_id: Id of the rule
:param results: CheckResult object
:param fee_type: If the fee is calculated for a position or process fee
:param absolute_fee: amount of the fee
:return:
"""
return Ruling(rule_id=rule_id, results=results, fee_type=fee_type, fee=absolute_fee)
@classmethod
def from_relative_fee(
cls,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: Literal['position_fee'],
reference_price: Decimal,
percentage: Decimal,
currency: str
) -> "Ruling":
"""
Constructs a Ruling with an absolute fee.
: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
:param percentage: Percentage of the reference_price set as the fee
:param currency: Currency of the reference_price, used for correct rounding of the fee
:return:
"""
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))
def __lt__(self, other):
if not isinstance(other, Ruling):
return NotImplemented
if self.fee_type != other.fee_type:
return NotImplemented
if self.cancellation_possible == other.cancellation_possible:
return self.total_fee < other.total_fee
return self.fee < other.fee
else:
return self.cancellation_possible and not other.cancellation_possible
class CancellationRuleQuerySet(models.QuerySet):
def cancellation_possible(self, diff: OrderDiff):
verdicts = [v[0] for v in self._evaluate(diff)]
return all(v.cancellation_possible for v in verdicts), verdicts
def _evaluate(self, diff: OrderDiff) -> List[List[Ruling]]:
return [self._evaluate_op(diff, position) for position in diff.order.positions.all()]
def _evaluate_op(self, diff: OrderDiff, order_position: OrderPosition) -> List[Ruling]:
consequences = []
for rule in self:
if order_position.item in rule.items.all() or order_position.variation in rule.variations.all():
consequences.append(rule.apply(diff, order_position))
consequences.sort()
return consequences
def validate_status_chars(value):
invalid=set(value) - Order.ALLOWED_STATUS_CHARS
if invalid:
@@ -126,33 +139,25 @@ def validate_status_chars(value):
raise ValidationError("Duplicate characters are not allowed.")
class CancellationRule(models.Model):
"""
"""
organizer=models.ForeignKey(
"Organizer",
related_name="orders",
on_delete=models.CASCADE,
null=True,
blank=True,
)
event=models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="orders",
on_delete=models.CASCADE
)
items = models.ManyToManyField(
"Item",
verbose_name=_("Items"),
all_products=models.BooleanField(
verbose_name=_("All products and variations"),
default=True,
)
variations=models.ManyToManyField(
"ItemVariation",
verbose_name=_("Item variations"),
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(
max_length=4,
choices=Order.STATUS_CHOICE,
@@ -166,92 +171,34 @@ class CancellationRule(models.Model):
fee_percentage_per_item=models.DecimalField(
max_digits=5,
decimal_places=2,
max_value=Decimal("100.00"),
min_value=Decimal("0.00"),
verbose_name=_("Fee Percentage per Item"),
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(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute Fee per Item"),
verbose_name=_("Absolute fee per OrderPosition"),
default=Decimal("0.00"),
) # wird als sum() kombiniert
fee_absolute_per_order=models.DecimalField(
fee_cancellation_process=models.DecimalField(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute Fee per Cancellation"),
verbose_name=_("Absolute fee per Cancellation"),
default=Decimal("0.00"),
) # wird als max() kombiniert
objects=ScopedManager(CancellationRuleQuerySet.as_manager().__class__, organizer='organizer',
event='event')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# erstmal festgelegte List an Stornoregeln, weitere checks können zukünftig über ein
# Signal eingesammelt und an CancellationRule.__init__ übergeben werden
# Ermöglicht dann: "Shipping modul kann storno geshippter Items verhindern"
self.checks: List[CheckFn]=[self._check_order_status, self._check_time_window,
self._system_check_not_checked_in, self._system_check_not_discounted]
self.checks: List[CheckFn] = [self._check_order_status, self._check_time_window]
# TODO weitere System Checks
# OrderPositions mit Item.min_per_order dürfen nur storniert werden, wenn genug übrig bleiben oder alle des gleichen Items storniert werden
# OrderPositions mit addon_to != None dürfen nur über den bestehenden Add-On-Flow storniert werden
# OrderPositions mit is_bundled dürfen nur mit der Parent-Position zusammen storniert werden
@staticmethod
def _system_check_not_checked_in(diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
check_id = "SYSTEM_TICKET_NOT_USED"
if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {check_id: CheckRes(
cancellation_possible=False,
reason=f"Order position was used",
)}
else:
return {check_id: CheckRes(
cancellation_possible=True,
reason=f"Order position not yet used",
)}
@staticmethod
def _system_check_not_discounted(diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
"""
Check that ensures that orders containing discounted order_positions cannot
be canceled partially.
This is a stop-gap solution until the `discount_grouper` attribute for
AbstractPositions is introduced, allowing us to be more grannular
:param diff:
:param order_position:
:return CheckResults:
"""
check_id = "SYSTEM_TICKET_NOT_DISCOUNTED"
if order_position in diff.cancellations():
if order_position.discount_id is None:
return {check_id: CheckRes(
cancellation_possible=True,
reason=_("Order position was bought without discount"),
)}
else:
return {check_id: CheckRes(
cancellation_possible=False,
reason=_("Order position was bought with a discount"),
)}
else:
return {check_id: CheckRes(
cancellation_possible=False,
reason=_("Order position not canceled - check not applicable"),
)}
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
check_id = "TIME_WINDOW"
if not self.allowed_until and not self.allowed_until:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"No time window specified",
)}
@@ -264,57 +211,37 @@ class CancellationRule(models.Model):
if in_allowed_until and not in_exemption:
except_after_message = f" and not after {self.except_after.datetime(relevant_event)}" if self.except_after else ""
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Cancellation in required time window before {self.allowed_until.datetime(relevant_event)}{except_after_message}",
)}
elif in_allowed_until and in_exemption:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}",
)}
else:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Cancellation after time window ending on {self.allowed_until.datetime(relevant_event)}",
)}
def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
check_id = "ORDER_STATUS"
if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS):
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Orders in every status can be cancelled",
)}
elif diff.order.status in self.allowed_if_in_order_status:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Order in required status: '{diff.order.status}'",
)}
else:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Order in status '{diff.order.status}' cannot be canceled",
)}
def apply(self, diff: OrderDiff, order_position: OrderPosition) -> Ruling:
check_results=reduce(merge_check_results,
[rule(diff, order_position) for rule in self.checks])
if self.fee_percentage_per_item and self.fee_absolute_per_item:
raise NotImplementedError("Should never be reached")
elif self.fee_absolute_per_item:
fee=AbsoluteFee(self.fee_absolute_per_item)
else:
fee=RelativeFee(percentage=self.fee_percentage_per_item,
reference_price=order_position.price)
return Ruling(
rule_id=self.id,
results=check_results,
order_fee=self.fee_absolute_per_order,
position_fee=fee
)

View File

@@ -41,8 +41,11 @@ from collections import Counter, defaultdict, namedtuple
from datetime import datetime, time, timedelta
from decimal import Decimal
from functools import reduce
from itertools import chain
from time import sleep
from typing import Dict
from typing import List, Optional
from typing import Set
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
@@ -53,6 +56,7 @@ from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
Value,
)
from django.db.models import Prefetch
from django.db.models.functions import Coalesce, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
@@ -71,6 +75,16 @@ from pretix.base.models import (
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher,
)
from pretix.base.models import Checkin
from pretix.base.models.cancellation import AbsoluteFee, CancellationCheckResult
from pretix.base.models.cancellation import CancellationRule
from pretix.base.models.cancellation import CheckFn
from pretix.base.models.cancellation import CheckRes
from pretix.base.models.cancellation import OrderDiff
from pretix.base.models.cancellation import RelativeFee
from pretix.base.models.cancellation import Ruling
from pretix.base.models.cancellation import assert_no_queries
from pretix.base.models.cancellation import merge_check_results
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
@@ -105,9 +119,10 @@ from pretix.base.signals import (
order_reactivated, order_split, order_valid_if_pending, periodic_task,
validate_order,
)
from pretix.base.signals import self_service_cancellation_checks
from pretix.base.timemachine import time_machine_now, time_machine_now_assigned
from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers import OF_SELF, ensure_no_queries
from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval
from pretix.testutils.middleware import debugflags_var
@@ -3525,3 +3540,171 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
'customer': order.customer_id,
}
)
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
class OrderPositionNotUsedCheck(CancellationCheck):
id = "SYSTEM_TICKET_NOT_USED"
prefetches = [
Prefetch(
'checkins',
queryset=Checkin.objects.filter(list__consider_tickets_used=True),
to_attr='used_checkins' # stores result in a list attribute
)
]
related_selects = []
def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResultsById:
if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Order position was used",
)}
else:
return {self.id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Order position not yet used",
)}
@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_used")
def cancellation_checks_not_used(sender: Event):
return OrderPositionNotUsedCheck()
class NotDiscountedCheck(CancellationCheck):
"""
Check that ensures that orders containing discounted order_positions cannot
be canceled partially.
This is a stop-gap solution until the `discount_grouper` attribute for
AbstractPositions is introduced, allowing us to be more grannular
"""
id = "SYSTEM_NO_DISCOUNTED_ORDER_POSITIONS"
prefetches = [
]
related_selects = []
def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResultsById:
cancellations = Set(order.positions).difference(keep)
if order_position in cancellations:
if order_position.discount_id is None:
return {self.id: CancellationCheckResult(
cancellation_possible=True,
reason=_("Order position was bought without discount"),
)}
else:
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason=_("Order position was bought with a discount"),
)}
else:
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason=_("Order position not canceled - check not applicable"),
)}
@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_discountend")
def cancellation_checks_not_discounted(sender: Event):
return NotDiscountedCheck()
# TODO weitere System Checks
# OrderPositions mit Item.min_per_order dürfen nur storniert werden, wenn genug übrig bleiben oder alle des gleichen Items storniert werden
# OrderPositions mit addon_to != None dürfen nur über den bestehenden Add-On-Flow storniert werden
# OrderPositions mit is_bundled dürfen nur mit der Parent-Position zusammen storniert werden
# TODO transaktion
def self_service_cancel(order: Order, keep: Set[OrderPosition], dry_run: bool):
"""
:param order:
:param keep:
:param dry_run:
:return:
"""
cancellation_checks: List[CancellationCheck] = [resp for recv, resp in self_service_cancellation_checks.send(event=order.event)]
position_rules = CancellationRule.objects.filter(event=order.event).filter("fee_cancellation_process"==Decimal("0.00")).all()
process_rules = CancellationRule.objects.filter(event=order.event).filter("fee_cancellation_process"!=Decimal("0.00")).all()
# Todo get prefetches/selects from rules as well
prefetches = list(chain.from_iterable([cc.prefetches for cc in cancellation_checks]))
related_selects = list(chain.from_iterable(cc.related_selects for cc in cancellation_checks))
per_position_rulings: Dict[int, List[Ruling]] = {}
prefetched_order = Order.objects.select_related(related_selects).prefetch_related(*prefetches).get(pk=order.pk)
# All queries should be done by now
with ensure_no_queries():
for position in prefetched_order.positions:
position_rulings = []
system_check_results = [cc.check(prefetched_order, keep, position) for cc in cancellation_checks]
for rule in position_rules:
check_results = [check(prefetched_order, keep, position) for check in rule.checks]
if rule.fee_percentage_per_item and rule.fee_absolute_per_item:
raise NotImplementedError("Should never be reached")
elif rule.fee_absolute_per_item != Decimal(0.00):
position_rulings.append(
Ruling.from_absolute_fee(
rule_id=rule.id,
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
fee_type='position_fee',
absolute_fee=rule.fee_absolute_per_item
)
)
else:
position_rulings.append(
Ruling.from_relative_fee(
rule_id=rule.id,
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
fee_type='position_fee',
reference_price=position.price,
percentage=rule.fee_absolute_per_item,
currency=order.event.currency
)
)
position_rulings.sort()
per_position_rulings[position.id] = position_rulings
effective_position_rulings = [op_rulings[0] for op_rulings in per_position_rulings.values()]
process_rulings: List[Ruling] = []
for rule in process_rules:
check_results = [check(prefetched_order, keep, position) for check in rule.checks]
process_rulings.append(Ruling.from_absolute_fee(
rule_id=rule.id,
results=reduce(lambda a, b: a | b, [*check_results], {}),
fee_type='process_fee',
absolute_fee=rule.fee_cancellation_process
))
process_rulings.sort()
effective_process_ruling = process_rulings[0]
cancellation_possible = all([r.cancellation_possible for r in effective_rulings])
# TODO zusammenführen der Rulings
if dry_run:
return cancellation_possible, rulings
else:
...

View File

@@ -1206,3 +1206,14 @@ This signal is sent out each time the information for a Device is modified.
Both the original and updated versions of the Device are included to allow
receivers to see what has been updated.
"""
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

@@ -20,7 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import contextlib
import logging
from django.conf import settings
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db import connection, transaction
@@ -29,6 +29,7 @@ from django.db.models import (
)
from django.utils.functional import lazy
logger = logging.getLogger(__name__)
class DummyRollbackException(Exception):
pass
@@ -280,3 +281,21 @@ def get_deterministic_ordering(model, ordering):
# on the primary key to provide total ordering.
ordering.append("-pk")
return ordering
@contextlib.contextmanager
def ensure_no_queries():
"""
Ensures that no database queries are being made in that context.
Raises a RuntimeError if running in DEBUG mode, otherwise logs
an error.
:return:
"""
def blocker(*args, **kwargs):
if settings.DEBUG:
raise RuntimeError(f"Unexpected DB query: {args[0]}")
else:
logger.error("Unexpected DB query: %s", args[0])
with connection.execute_wrapper(blocker):
yield