This commit is contained in:
Lukas Bockstaller
2026-03-20 15:15:44 +01:00
parent accfc843d6
commit 1bb2ab28ad
2 changed files with 277 additions and 118 deletions

View File

@@ -2,16 +2,15 @@ import dataclasses
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from functools import reduce from functools import reduce
from django.utils.translation import gettext_lazy as _, pgettext_lazy from typing import Callable, Dict, List, Set, Union
from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.functional import _StrPromise
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager from django_scopes import ScopedManager
from pretix.base.models import Event from pretix.base.models import Event, Order, OrderPosition
from pretix.base.models import OrderPosition, Order
from typing import Dict, Union, Callable, List
from pretix.base.reldate import ModelRelativeDateTimeField from pretix.base.reldate import ModelRelativeDateTimeField
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
@@ -28,33 +27,44 @@ class RelativeFee:
@property @property
def amount(self): def amount(self):
return self.reference_price * self.percentage return self.reference_price * (self.percentage/100)
@dataclass(frozen=True) @dataclass(frozen=True)
class Ruling: class CheckResult:
cancellation_possible: bool cancellation_possible: bool
reason: str reason: str | _StrPromise
Rulings=Dict[str, Ruling] @dataclass(frozen=True)
class OrderDiff:
order: Order
prev: Set[OrderPosition]
next: Set[OrderPosition]
def cancellations(self):
return self.prev.difference(self.next)
CheckResult=Dict[str, CheckResult]
Fee=Union[AbsoluteFee, RelativeFee] Fee=Union[AbsoluteFee, RelativeFee]
RuleFn=Callable[[OrderPosition], Rulings] CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult]
def merge_rulings(a: Rulings, b: Rulings) -> Rulings: def merge_check_results(a: CheckResult, b: CheckResult) -> CheckResult:
result = dict(a) result = dict(a)
for key, b_inner in b.items(): for key, b_inner in b.items():
if key in result: if key in result:
result[key] = result[key] | b_inner # merge inner dicts result[key] = result[key] | b_inner
else: else:
result[key] = b_inner result[key] = b_inner
return result return result
@dataclass(frozen=True) @dataclass(frozen=True)
class CancellationConsequence: class Ruling:
rule_id: int rule_id: int
rulings: Rulings check_results: CheckResult
order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0)) order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0))
position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0))) position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0)))
@@ -65,7 +75,7 @@ class CancellationConsequence:
self, self,
'cancellation_possible', 'cancellation_possible',
all(ruling.cancellation_possible all(ruling.cancellation_possible
for ruling in self.rulings.values() for ruling in self.check_results.values()
) )
) )
@@ -74,7 +84,7 @@ class CancellationConsequence:
return self.position_fee.amount + self.order_fee return self.position_fee.amount + self.order_fee
def __lt__(self, other): def __lt__(self, other):
if not isinstance(other, CancellationConsequence): if not isinstance(other, Ruling):
return NotImplemented return NotImplemented
if self.cancellation_possible == other.cancellation_possible: if self.cancellation_possible == other.cancellation_possible:
@@ -85,12 +95,13 @@ class CancellationConsequence:
class CancellationRuleQuerySet(models.QuerySet): class CancellationRuleQuerySet(models.QuerySet):
def cancellation_possible(self, order: Order): def cancellation_possible(self, order: Order):
return all([v[0].cancellation_possible for v in self._evaluate(order)]) verdicts = [v[0] for v in self._evaluate(order)]
return all(v.cancellation_possible for v in verdicts), verdicts
def _evaluate(self, order: Order) -> List[List[CancellationConsequence]]: def _evaluate(self, order: Order) -> List[List[Ruling]]:
return [self._evaluate_op(position) for position in order.positions.all()] return [self._evaluate_op(position) for position in order.positions.all()]
def _evaluate_op(self, order_position: OrderPosition) -> List[CancellationConsequence]: def _evaluate_op(self, order_position: OrderPosition) -> List[Ruling]:
consequences=[rule.apply(order_position) for rule in self] consequences=[rule.apply(order_position) for rule in self]
consequences.sort() consequences.sort()
return consequences return consequences
@@ -98,6 +109,11 @@ class CancellationRuleQuerySet(models.QuerySet):
class CancellationRule(models.Model): class CancellationRule(models.Model):
"""
"""
organizer=models.ForeignKey( organizer=models.ForeignKey(
"Organizer", "Organizer",
related_name="orders", related_name="orders",
@@ -157,75 +173,132 @@ class CancellationRule(models.Model):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.rules: List[RuleFn]=[self._rule_order_status, self._rule_time_window, self.checks: List[CheckFn]=[self._check_order_status, self._check_time_window,
self._system_rule_not_checked_in] self._system_check_not_checked_in]
self.partial_checks: List[CheckFn]=[self._system_check_not_discounted]
@staticmethod @staticmethod
def _system_rule_not_checked_in(order_position: OrderPosition) -> Rulings: 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(): if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {"SYSTEM_TICKET_NOT_USED": Ruling( return {check_id: CheckResult(
cancellation_possible=False, cancellation_possible=False,
reason=f"Order position was used", reason=f"Order position was used",
)} )}
else: else:
return {"SYSTEM_TICKET_NOT_USED": Ruling( return {check_id: CheckResult(
cancellation_possible=True, cancellation_possible=True,
reason=f"Order position not yet used", reason=f"Order position not yet used",
)} )}
def _rule_time_window(self, order_position: OrderPosition) -> Rulings: @staticmethod
in_allowed_until = self.allowed_until < time_machine_now() def _system_check_not_discounted(diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
in_exemption = self.except_after > time_machine_now() """
if in_allowed_until and not in_exemption: Check that ensures that orders containing discounted order_positions cannot
return {"TIME_WINDOW": Ruling( 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_USED"
if order_position in diff.cancellations():
if order_position.discount is None:
return {check_id: CheckResult(
cancellation_possible=True, cancellation_possible=True,
reason=f"Cancellation in required time window between {self.allowed_until} and {self.except_after}", reason=_("Order position was bought without discount"),
)}
elif in_allowed_until and in_exemption:
return {"TIME_WINDOW": Ruling(
cancellation_possible=False,
reason=f"Cancellation in exemption period after {self.except_after}",
)} )}
else: else:
return {"TIME_WINDOW": Ruling( return {check_id: CheckResult(
cancellation_possible=False, cancellation_possible=False,
reason=f"Cancellation after time window ending on {self.allowed_until}", reason=_("Order position was bought with a discount"),
)}
else:
return {check_id: CheckResult(
cancellation_possible=False,
reason=_("Order position not canceled - check not applicable"),
)} )}
def _rule_order_status(self, order_position: OrderPosition) -> Rulings: def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
if order_position.order.status == self.order_status: check_id = "TIME_WINDOW"
return {"ORDER_STATUS": Ruling(
relevant_event = order_position.subevent or order_position.event
if not self.allowed_until and not self.allowed_until:
return {check_id: CheckResult(
cancellation_possible=True,
reason=f"No time window specified",
)}
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(
relevant_event) if self.except_after else False
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: CheckResult(
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: CheckResult(
cancellation_possible=False,
reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}",
)}
else:
return {check_id: CheckResult(
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:
check_id = "ORDER_STATUS"
if not self.order_status:
return {check_id: CheckResult(
cancellation_possible=True,
reason=f"Orders in every status can be cancelled",
)}
elif order_position.order.status == self.order_status:
return {check_id: CheckResult(
cancellation_possible=True, cancellation_possible=True,
reason=f"Order in required status: '{order_position.order.status}'", reason=f"Order in required status: '{order_position.order.status}'",
)} )}
else: else:
return {"ORDER_STATUS": Ruling( return {check_id: CheckResult(
cancellation_possible=False, cancellation_possible=False,
reason=f"Order in status '{order_position.order.status}' cannot be canceled", reason=f"Order in status '{order_position.order.status}' cannot be canceled",
)} )}
# OrderPositions mit discount dürfen nur storniert werden, wenn alle positions mit dem gleichen discount_grouper storniert werden
# OrderPositions mit Item.min_per_order dürfen nur storniert werden, wenn genug übrig bleiben oder alle des gleichen Items storniert werden # 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 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 # OrderPositions mit is_bundled dürfen nur mit der Parent-Position zusammen storniert werden
# Shipping modul kann storno geshippter Items verhindern # Shipping modul kann storno geshippter Items verhindern
# Backend-Anzeige "welche Regel greift da gerade" in der Order # Backend-Anzeige "welche Regel greift da gerade" in der Order
def apply(self, order_position: OrderPosition) -> CancellationConsequence: def apply(self, diff: OrderDiff, order_position: OrderPosition) -> Ruling:
rulings=reduce(merge_rulings, check_results=reduce(merge_check_results,
[rule(order_position) for rule in self.rules]) [rule(diff, order_position) for rule in self.checks])
if self.fee_percentage_per_item and self.fee_absolute_per_item: if self.fee_percentage_per_item and self.fee_absolute_per_item:
raise NotImplementedError("Should never be reached") raise NotImplementedError("Should never be reached")
elif self.fee_absolute_per_item: elif self.fee_absolute_per_item:
fee=AbsoluteFee(self.fee_absolute_per_item) fee=AbsoluteFee(self.fee_absolute_per_item)
else: else:
fee=RelativeFee(percentage=self.fee_absolute_per_item, fee=RelativeFee(percentage=self.fee_percentage_per_item,
reference_price=order_position.price) reference_price=order_position.price)
return CancellationConsequence( return Ruling(
rule_id=self.id, rule_id=self.id,
rulings=rulings, check_results=check_results,
order_fee=self.fee_absolute_per_order, order_fee=self.fee_absolute_per_order,
position_fee=fee position_fee=fee
) )

View File

@@ -1,71 +1,74 @@
from decimal import Decimal
from pretix.base.models.cancellation import CancellationRule
from pretix.base.models import Order, Event, OrderPosition, Organizer, Item
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from django.utils.timezone import make_aware, now
from django_scopes import scope
import pytest import pytest
from django.utils.timezone import make_aware, now
from django_scopes import scope
from freezegun import freeze_time
from pretix.base.models.cancellation import Ruling from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
from pretix.base.models.cancellation import CancellationRule, Ruling
NOW = now()
DAYS_UNTIL_EVENT=60
EVENT_START = NOW+timedelta(days=DAYS_UNTIL_EVENT)
@pytest.fixture(scope='function')
@pytest.fixture()
def event(): def event():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer') o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create( event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy', organizer=o, name='Dummy', slug='dummy',
date_from=now(), date_from=EVENT_START,
plugins='pretix.plugins.banktransfer' plugins='pretix.plugins.banktransfer'
) )
with scope(organizer=o): return event
yield event
@pytest.fixture(scope="function") @pytest.fixture()
def ticket(event): def item1(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket', return Item.objects.create(event=event, name='Early-bird item1',
default_price=Decimal('23.00'), admission=True) default_price=Decimal('23.00'), admission=True)
return ticket @pytest.fixture()
def order(event):
return Order.objects.create(
@pytest.mark.django_db
def test_status_rule(event, ticket):
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test', code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, locale='en', status=Order.STATUS_PENDING, locale='en',
datetime=now(), expires=now() + timedelta(days=10), datetime=NOW,
total=0, total=0,
sales_channel=event.organizer.sales_channels.get(identifier="web"), sales_channel=event.organizer.sales_channels.get(identifier="web"),
) )
@pytest.mark.django_db
def test_status_rule(event, item1, order):
with scope(organizer=event.organizer, event=event):
op = OrderPosition.objects.create( op = OrderPosition.objects.create(
order=o, item=ticket, variation=None, order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1 price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
) )
cancellation_rule = CancellationRule.objects.create( cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=ticket, organizer=event.organizer, event=event, item=item1,
order_status=Order.STATUS_PENDING order_status=Order.STATUS_PENDING
) )
assert cancellation_rule._rule_order_status(order_position=op) == { assert cancellation_rule._rule_order_status(order_position=op) == {
1: Ruling( 'ORDER_STATUS': Ruling(
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=ticket, organizer=event.organizer, event=event, item=item1,
order_status=Order.STATUS_PAID order_status=Order.STATUS_PAID
) )
assert cancellation_rule._rule_order_status(order_position=op) == { assert cancellation_rule._rule_order_status(order_position=op) == {
2: Ruling( 'ORDER_STATUS': Ruling(
cancellation_possible=False, cancellation_possible=False,
reason="Order in status 'n' cannot be canceled", reason="Order in status 'n' cannot be canceled",
), ),
@@ -73,30 +76,113 @@ def test_status_rule(event, ticket):
@pytest.mark.django_db @pytest.mark.django_db
def test_cancelation_rule_query_set(event, ticket): def test_timing(event, item1, order):
with scope(organizer=event.organizer, event=event): with scope(organizer=event.organizer, event=event):
o = Order.objects.create( order.status = Order.STATUS_PAID
code='FOO', event=event, email='dummy@dummy.test', order.save()
status=Order.STATUS_PENDING, locale='en',
datetime=now(), expires=now() + timedelta(days=10),
total=0,
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
op = OrderPosition.objects.create( OrderPosition.objects.create(
order=o, item=ticket, variation=None, order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1 price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
) )
cr1 = CancellationRule.objects.create( CancellationRule.objects.create(
organizer=event.organizer, event=event, item=ticket, organizer=event.organizer, event=event, item=item1,
allowed_until=now() + timedelta(hours=1),
)
with freeze_time(now()):
possible, verdicts = CancellationRule.objects.all().cancellation_possible(order)
assert possible == True
with freeze_time(now()+timedelta(hours=2)):
possible, verdicts=CancellationRule.objects.all().cancellation_possible(order)
assert possible == False
@pytest.mark.django_db
def test_multiple_limits(event, item1, order):
with (scope(organizer=event.organizer, event=event)):
order.status = Order.STATUS_PAID
order.save()
OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("100.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
# free in the first hour after booking
CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1,
allowed_until=NOW + timedelta(hours=1),
)
# free until 30 days before event
CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1,
allowed_until=EVENT_START - timedelta(days=30),
)
# 50% until 14 days before event
CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1,
allowed_until=EVENT_START - timedelta(days=14),
fee_percentage_per_item=Decimal(50.0)
)
# 80% until 7 days before event
CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1,
allowed_until=EVENT_START - timedelta(days=7),
fee_percentage_per_item=Decimal(80.0)
)
# 100% until 1 day before event
CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1,
allowed_until=EVENT_START - timedelta(days=1),
fee_percentage_per_item=Decimal(100)
)
possible_trace = []
cost_trace = []
for days in range(DAYS_UNTIL_EVENT):
today = NOW + timedelta(days=days)
with freeze_time(today):
possible, verdicts=CancellationRule.objects.all().cancellation_possible(
order)
possible_trace.append(possible)
cost_trace.append(verdicts[0].total_fee)
assert possible_trace == [True] * 59 + [False]
assert cost_trace == [Decimal("0.0000")] * 30 + \
[Decimal("50.0000")] * 16 + \
[Decimal("80.0000")] * 7 + \
[Decimal("100.0000")] * 6 + \
[Decimal("0.0000")]
@pytest.mark.django_db
def test_cancellation_rule_query_set(event, item1, order):
with scope(organizer=event.organizer, event=event):
OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1,
order_status=Order.STATUS_PENDING, fee_absolute_per_order=Decimal('10.00'), order_status=Order.STATUS_PENDING, fee_absolute_per_order=Decimal('10.00'),
) )
cr2 = CancellationRule.objects.create( CancellationRule.objects.create(
organizer=event.organizer, event=event, item=ticket, organizer=event.organizer, event=event, item=item1,
order_status=Order.STATUS_PAID order_status=Order.STATUS_PAID
) )
possible, verdicts = CancellationRule.objects.all().cancellation_possible(order)
assert CancellationRule.objects.all().cancellation_possible(o) == True assert possible == True