This commit is contained in:
Lukas Bockstaller
2026-03-23 14:22:14 +01:00
parent 9224c73c7f
commit c1db94dec3
3 changed files with 100 additions and 59 deletions

View File

@@ -31,11 +31,7 @@ class RelativeFee:
return self.reference_price * (self.percentage/100) return self.reference_price * (self.percentage/100)
@dataclass(frozen=True) Fee=Union[AbsoluteFee, RelativeFee]
class CheckRes:
cancellation_possible: bool
reason: str
CheckResult=Dict[str, CheckRes]
@dataclass(frozen=True) @dataclass(frozen=True)
class OrderDiff: class OrderDiff:
@@ -46,8 +42,18 @@ class OrderDiff:
def cancellations(self): def cancellations(self):
return self.prev.difference(self.next) return self.prev.difference(self.next)
@staticmethod
def cancel_all(order: Order) -> "OrderDiff":
return OrderDiff(order=order, prev=set(order.positions.all()), next=set())
@dataclass(frozen=True)
class CheckRes:
cancellation_possible: bool
reason: str
CheckResult=Dict[str, CheckRes]
Fee=Union[AbsoluteFee, RelativeFee]
CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult] CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult]
@@ -60,6 +66,7 @@ def merge_check_results(a: CheckResult, b: CheckResult) -> CheckResult:
result[key] = b_inner result[key] = b_inner
return result return result
@dataclass(frozen=True) @dataclass(frozen=True)
class Ruling: class Ruling:
rule_id: int rule_id: int
@@ -101,24 +108,24 @@ class CancellationRuleQuerySet(models.QuerySet):
return [self._evaluate_op(diff, position) for position in diff.order.positions.all()] return [self._evaluate_op(diff, position) for position in diff.order.positions.all()]
def _evaluate_op(self, diff: OrderDiff, order_position: OrderPosition) -> List[Ruling]: def _evaluate_op(self, diff: OrderDiff, order_position: OrderPosition) -> List[Ruling]:
consequences=[rule.apply(diff, order_position) for rule in self] 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() consequences.sort()
return consequences return consequences
ALLOWED_STATUS_CHARS={char for char, _ in Order.STATUS_CHOICE} # {'n', 'p', 'e', 'c'}
def validate_status_chars(value): def validate_status_chars(value):
invalid=set(value) - ALLOWED_STATUS_CHARS invalid=set(value) - Order.ALLOWED_STATUS_CHARS
if invalid: if invalid:
raise ValidationError( raise ValidationError(
f"Invalid characters: {invalid}. Allowed: {ALLOWED_STATUS_CHARS}" f"Invalid characters: {invalid}. Allowed: {Order.ALLOWED_STATUS_CHARS}"
) )
if len(value) != len(set(value)): if len(value) != len(set(value)):
raise ValidationError("Duplicate characters are not allowed.") raise ValidationError("Duplicate characters are not allowed.")
class CancellationRule(models.Model): class CancellationRule(models.Model):
""" """
@@ -137,17 +144,22 @@ class CancellationRule(models.Model):
related_name="orders", related_name="orders",
on_delete=models.CASCADE on_delete=models.CASCADE
) )
item=models.ForeignKey("Item", on_delete=models.CASCADE, null=True, blank=True) # probably m2m field to avoid duplicating rules items = models.ManyToManyField(
item_variation=models.ForeignKey("ItemVariation", on_delete=models.CASCADE, null=True, blank=True) # probably m2m field to avoid duplicating rules "Item",
verbose_name=_("Items"),
)
variations=models.ManyToManyField(
"ItemVariation",
verbose_name=_("Item 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,
verbose_name=_("Cancellation possible if order is in status"), verbose_name=_("Cancellation possible if order is in status"),
validators=[validate_status_chars] validators=[validate_status_chars],
default="".join(Order.ALLOWED_STATUS_CHARS),
) )
allowed_until=ModelRelativeDateTimeField(null=True, blank=True) allowed_until=ModelRelativeDateTimeField(null=True, blank=True)
except_after=ModelRelativeDateTimeField(null=True, blank=True) except_after=ModelRelativeDateTimeField(null=True, blank=True)
@@ -171,7 +183,6 @@ class CancellationRule(models.Model):
verbose_name=_("Absolute Fee per Item"), verbose_name=_("Absolute Fee per Item"),
default=Decimal("0.00"), default=Decimal("0.00"),
) # wird als sum() kombiniert ) # wird als sum() kombiniert
fee_absolute_per_order=models.DecimalField( fee_absolute_per_order=models.DecimalField(
max_digits=13, max_digits=13,
decimal_places=2, decimal_places=2,
@@ -191,6 +202,11 @@ class CancellationRule(models.Model):
self._system_check_not_checked_in, self._system_check_not_discounted] self._system_check_not_checked_in, self._system_check_not_discounted]
# 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 @staticmethod
def _system_check_not_checked_in(diff: OrderDiff, order_position: OrderPosition) -> CheckResult: def _system_check_not_checked_in(diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
check_id = "SYSTEM_TICKET_NOT_USED" check_id = "SYSTEM_TICKET_NOT_USED"
@@ -218,7 +234,7 @@ class CancellationRule(models.Model):
:param order_position: :param order_position:
:return CheckResults: :return CheckResults:
""" """
check_id = "SYSTEM_TICKET_NOT_USED" check_id = "SYSTEM_TICKET_NOT_DISCOUNTED"
if order_position in diff.cancellations(): if order_position in diff.cancellations():
if order_position.discount is None: if order_position.discount is None:
@@ -273,29 +289,23 @@ class CancellationRule(models.Model):
def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult: def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
check_id = "ORDER_STATUS" check_id = "ORDER_STATUS"
if not self.allowed_until and not self.allowed_until: if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS):
return {check_id: CheckRes( return {check_id: CheckRes(
cancellation_possible=True, cancellation_possible=True,
reason=f"Orders in every status can be cancelled", reason=f"Orders in every status can be cancelled",
)} )}
elif order_position.order.status in self.allowed_if_in_order_status: elif diff.order.status in self.allowed_if_in_order_status:
return {check_id: CheckRes( return {check_id: CheckRes(
cancellation_possible=True, cancellation_possible=True,
reason=f"Order in required status: '{order_position.order.status}'", reason=f"Order in required status: '{diff.order.status}'",
)} )}
else: else:
return {check_id: CheckRes( return {check_id: CheckRes(
cancellation_possible=False, cancellation_possible=False,
reason=f"Order in status '{order_position.order.status}' cannot be canceled", reason=f"Order in status '{diff.order.status}' cannot be canceled",
)} )}
# 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
def apply(self, diff: OrderDiff, order_position: OrderPosition) -> Ruling: def apply(self, diff: OrderDiff, order_position: OrderPosition) -> Ruling:
check_results=reduce(merge_check_results, check_results=reduce(merge_check_results,
[rule(diff, order_position) for rule in self.checks]) [rule(diff, order_position) for rule in self.checks])

View File

@@ -203,6 +203,7 @@ 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

@@ -9,6 +9,8 @@ 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, Ruling
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
@@ -51,24 +53,28 @@ def test_status_rule(event, item1, order):
) )
cancellation_rule = CancellationRule.objects.create( cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, organizer=event.organizer, event=event,
order_status=Order.STATUS_PENDING allowed_if_in_order_status=Order.STATUS_PENDING
) )
cancellation_rule.items.set([item1])
assert cancellation_rule._rule_order_status(order_position=op) == { diff = OrderDiff.cancel_all(order)
'ORDER_STATUS': Ruling(
assert cancellation_rule._check_order_status(diff=diff, order_position=op) == {
'ORDER_STATUS': CheckRes(
cancellation_possible=True, cancellation_possible=True,
reason="Order in required status: 'n'", reason="Order in required status: 'n'",
), ),
} }
cancellation_rule = CancellationRule.objects.create( cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, organizer=event.organizer, event=event,
order_status=Order.STATUS_PAID allowed_if_in_order_status=Order.STATUS_PAID
) )
cancellation_rule.items.set([item1])
assert cancellation_rule._rule_order_status(order_position=op) == { assert cancellation_rule._check_order_status(diff=diff, order_position=op) == {
'ORDER_STATUS': Ruling( 'ORDER_STATUS': CheckRes(
cancellation_possible=False, cancellation_possible=False,
reason="Order in status 'n' cannot be canceled", reason="Order in status 'n' cannot be canceled",
), ),
@@ -86,17 +92,21 @@ def test_timing(event, item1, order):
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1 price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
) )
CancellationRule.objects.create( cr1 = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, organizer=event.organizer, event=event,
allowed_until=now() + timedelta(hours=1), allowed_until=now() + timedelta(hours=1),
) )
cr1.items.set([item1])
diff = OrderDiff.cancel_all(order)
with freeze_time(now()): with freeze_time(now()):
possible, verdicts = CancellationRule.objects.all().cancellation_possible(order) possible, verdicts = CancellationRule.objects.all().cancellation_possible(diff)
assert possible == True assert possible == True
with freeze_time(now()+timedelta(hours=2)): with freeze_time(now()+timedelta(hours=2)):
possible, verdicts=CancellationRule.objects.all().cancellation_possible(order) possible, verdicts=CancellationRule.objects.all().cancellation_possible(diff)
assert possible == False assert possible == False
@@ -112,37 +122,52 @@ def test_multiple_limits(event, item1, order):
) )
# free in the first hour after booking # free in the first hour after booking
CancellationRule.objects.create( cr1=CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, organizer=event.organizer, event=event,
allowed_until=NOW + timedelta(hours=1), allowed_until=NOW + timedelta(hours=1),
) )
cr1.items.set([item1])
# free until 30 days before event # free until 30 days before event
CancellationRule.objects.create( cr2 = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=30), allowed_until=EVENT_START - timedelta(days=30),
) )
cr2.items.set([item1])
# 50% until 14 days before event # 50% until 14 days before event
CancellationRule.objects.create( cr3 = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=14), allowed_until=EVENT_START - timedelta(days=14),
fee_percentage_per_item=Decimal(50.0) fee_percentage_per_item=Decimal(50.0)
) )
cr3.items.set([item1])
# 80% until 7 days before event # 80% until 7 days before event
CancellationRule.objects.create( cr4 = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=7), allowed_until=EVENT_START - timedelta(days=7),
fee_percentage_per_item=Decimal(80.0) fee_percentage_per_item=Decimal(80.0)
) )
cr4.items.set([item1])
# 100% until 1 day before event # 100% until 1 day before event
CancellationRule.objects.create( cr5 = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=1), allowed_until=EVENT_START - timedelta(days=1),
fee_percentage_per_item=Decimal(100) fee_percentage_per_item=Decimal(100)
) )
cr5.items.set([item1])
# Cancellation is not allowed at all, but rule doesn't match the item
CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=NOW,
fee_percentage_per_item=Decimal(100)
)
diff = OrderDiff.cancel_all(order)
possible_trace = [] possible_trace = []
cost_trace = [] cost_trace = []
@@ -151,7 +176,7 @@ def test_multiple_limits(event, item1, order):
today = NOW + timedelta(days=days) today = NOW + timedelta(days=days)
with freeze_time(today): with freeze_time(today):
possible, verdicts=CancellationRule.objects.all().cancellation_possible( possible, verdicts=CancellationRule.objects.all().cancellation_possible(
order) diff)
possible_trace.append(possible) possible_trace.append(possible)
cost_trace.append(verdicts[0].total_fee) cost_trace.append(verdicts[0].total_fee)
@@ -173,16 +198,21 @@ def test_cancellation_rule_query_set(event, item1, order):
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1 price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
) )
CancellationRule.objects.create( cr1 = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, organizer=event.organizer, event=event,
order_status=Order.STATUS_PENDING, fee_absolute_per_order=Decimal('10.00'), allowed_if_in_order_status=Order.STATUS_PENDING, fee_absolute_per_order=Decimal('10.00'),
) )
cr1.items.set([item1])
CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1, cr2 = CancellationRule.objects.create(
order_status=Order.STATUS_PAID organizer=event.organizer, event=event,
allowed_if_in_order_status=Order.STATUS_PAID
) )
cr2.items.set([item1])
possible, verdicts = CancellationRule.objects.all().cancellation_possible(order) diff = OrderDiff.cancel_all(order)
possible, verdicts = CancellationRule.objects.all().cancellation_possible(diff)
assert possible == True assert possible == True