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 decimal import Decimal
from functools import reduce
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator
from typing import Callable, Dict, List, Set, Union
from django.core.validators import MaxValueValidator, MinValueValidator
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 pretix.base.models import Event
from pretix.base.models import OrderPosition, Order
from typing import Dict, Union, Callable, List
from pretix.base.models import Event, Order, OrderPosition
from pretix.base.reldate import ModelRelativeDateTimeField
from pretix.base.timemachine import time_machine_now
@@ -28,33 +27,44 @@ class RelativeFee:
@property
def amount(self):
return self.reference_price * self.percentage
return self.reference_price * (self.percentage/100)
@dataclass(frozen=True)
class Ruling:
class CheckResult:
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]
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)
for key, b_inner in b.items():
if key in result:
result[key] = result[key] | b_inner # merge inner dicts
result[key] = result[key] | b_inner
else:
result[key] = b_inner
return result
@dataclass(frozen=True)
class CancellationConsequence:
class Ruling:
rule_id: int
rulings: Rulings
check_results: CheckResult
order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0))
position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0)))
@@ -65,7 +75,7 @@ class CancellationConsequence:
self,
'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
def __lt__(self, other):
if not isinstance(other, CancellationConsequence):
if not isinstance(other, Ruling):
return NotImplemented
if self.cancellation_possible == other.cancellation_possible:
@@ -85,12 +95,13 @@ class CancellationConsequence:
class CancellationRuleQuerySet(models.QuerySet):
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()]
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.sort()
return consequences
@@ -98,6 +109,11 @@ class CancellationRuleQuerySet(models.QuerySet):
class CancellationRule(models.Model):
"""
"""
organizer=models.ForeignKey(
"Organizer",
related_name="orders",
@@ -157,75 +173,132 @@ class CancellationRule(models.Model):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.rules: List[RuleFn]=[self._rule_order_status, self._rule_time_window,
self._system_rule_not_checked_in]
self.checks: List[CheckFn]=[self._check_order_status, self._check_time_window,
self._system_check_not_checked_in]
self.partial_checks: List[CheckFn]=[self._system_check_not_discounted]
@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():
return {"SYSTEM_TICKET_NOT_USED": Ruling(
return {check_id: CheckResult(
cancellation_possible=False,
reason=f"Order position was used",
)}
else:
return {"SYSTEM_TICKET_NOT_USED": Ruling(
return {check_id: CheckResult(
cancellation_possible=True,
reason=f"Order position not yet used",
)}
def _rule_time_window(self, order_position: OrderPosition) -> Rulings:
in_allowed_until = self.allowed_until < time_machine_now()
in_exemption = self.except_after > time_machine_now()
if in_allowed_until and not in_exemption:
return {"TIME_WINDOW": Ruling(
cancellation_possible=True,
reason=f"Cancellation in required time window between {self.allowed_until} and {self.except_after}",
)}
elif in_allowed_until and in_exemption:
return {"TIME_WINDOW": Ruling(
cancellation_possible=False,
reason=f"Cancellation in exemption period after {self.except_after}",
)}
@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_USED"
if order_position in diff.cancellations():
if order_position.discount is None:
return {check_id: CheckResult(
cancellation_possible=True,
reason=_("Order position was bought without discount"),
)}
else:
return {check_id: CheckResult(
cancellation_possible=False,
reason=_("Order position was bought with a discount"),
)}
else:
return {"TIME_WINDOW": Ruling(
return {check_id: CheckResult(
cancellation_possible=False,
reason=f"Cancellation after time window ending on {self.allowed_until}",
reason=_("Order position not canceled - check not applicable"),
)}
def _rule_order_status(self, order_position: OrderPosition) -> Rulings:
if order_position.order.status == self.order_status:
return {"ORDER_STATUS": Ruling(
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
check_id = "TIME_WINDOW"
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,
reason=f"Order in required status: '{order_position.order.status}'",
)}
else:
return {"ORDER_STATUS": Ruling(
return {check_id: CheckResult(
cancellation_possible=False,
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 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
# Shipping modul kann storno geshippter Items verhindern
# Backend-Anzeige "welche Regel greift da gerade" in der Order
def apply(self, order_position: OrderPosition) -> CancellationConsequence:
rulings=reduce(merge_rulings,
[rule(order_position) for rule in self.rules])
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_absolute_per_item,
fee=RelativeFee(percentage=self.fee_percentage_per_item,
reference_price=order_position.price)
return CancellationConsequence(
return Ruling(
rule_id=self.id,
rulings=rulings,
check_results=check_results,
order_fee=self.fee_absolute_per_order,
position_fee=fee
)

View File

@@ -1,102 +1,188 @@
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 decimal import Decimal
from zoneinfo import ZoneInfo
from django.utils.timezone import make_aware, now
from django_scopes import scope
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():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(),
date_from=EVENT_START,
plugins='pretix.plugins.banktransfer'
)
with scope(organizer=o):
yield event
return event
@pytest.fixture(scope="function")
def ticket(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket',
@pytest.fixture()
def item1(event):
return Item.objects.create(event=event, name='Early-bird item1',
default_price=Decimal('23.00'), admission=True)
return ticket
@pytest.mark.django_db
def test_status_rule(event, ticket):
o = Order.objects.create(
@pytest.fixture()
def order(event):
return Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, locale='en',
datetime=now(), expires=now() + timedelta(days=10),
datetime=NOW,
total=0,
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
op = OrderPosition.objects.create(
order=o, item=ticket, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=ticket,
order_status=Order.STATUS_PENDING
)
assert cancellation_rule._rule_order_status(order_position=op) == {
1: Ruling(
cancellation_possible=True,
reason="Order in required status: 'n'",
),
}
cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=ticket,
order_status=Order.STATUS_PAID
)
assert cancellation_rule._rule_order_status(order_position=op) == {
2: Ruling(
cancellation_possible=False,
reason="Order in status 'n' cannot be canceled",
),
}
@pytest.mark.django_db
def test_cancelation_rule_query_set(event, ticket):
def test_status_rule(event, item1, order):
with scope(organizer=event.organizer, event=event):
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
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(
order=o, item=ticket, variation=None,
order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
cr1 = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=ticket,
order_status=Order.STATUS_PENDING, fee_absolute_per_order=Decimal('10.00'),
cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1,
order_status=Order.STATUS_PENDING
)
cr2 = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=ticket,
assert cancellation_rule._rule_order_status(order_position=op) == {
'ORDER_STATUS': Ruling(
cancellation_possible=True,
reason="Order in required status: 'n'",
),
}
cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1,
order_status=Order.STATUS_PAID
)
assert cancellation_rule._rule_order_status(order_position=op) == {
'ORDER_STATUS': Ruling(
cancellation_possible=False,
reason="Order in status 'n' cannot be canceled",
),
}
assert CancellationRule.objects.all().cancellation_possible(o) == True
@pytest.mark.django_db
def test_timing(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("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
CancellationRule.objects.create(
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'),
)
CancellationRule.objects.create(
organizer=event.organizer, event=event, item=item1,
order_status=Order.STATUS_PAID
)
possible, verdicts = CancellationRule.objects.all().cancellation_possible(order)
assert possible == True