This commit is contained in:
Lukas Bockstaller
2026-03-19 13:54:48 +01:00
parent 0f2ebb8687
commit accfc843d6
2 changed files with 333 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
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 django.db import models
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.reldate import ModelRelativeDateTimeField
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
@dataclass(frozen=True)
class Ruling:
cancellation_possible: bool
reason: str
Rulings=Dict[str, Ruling]
Fee=Union[AbsoluteFee, RelativeFee]
RuleFn=Callable[[OrderPosition], Rulings]
def merge_rulings(a: Rulings, b: Rulings) -> Rulings:
result = dict(a)
for key, b_inner in b.items():
if key in result:
result[key] = result[key] | b_inner # merge inner dicts
else:
result[key] = b_inner
return result
@dataclass(frozen=True)
class CancellationConsequence:
rule_id: int
rulings: Rulings
order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0))
position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0)))
cancellation_possible: bool=dataclasses.field(init=False)
def __post_init__(self):
object.__setattr__(
self,
'cancellation_possible',
all(ruling.cancellation_possible
for ruling in self.rulings.values()
)
)
@property
def total_fee(self):
return self.position_fee.amount + self.order_fee
def __lt__(self, other):
if not isinstance(other, CancellationConsequence):
return NotImplemented
if self.cancellation_possible == other.cancellation_possible:
return self.total_fee < other.total_fee
else:
return self.cancellation_possible and not other.cancellation_possible
class CancellationRuleQuerySet(models.QuerySet):
def cancellation_possible(self, order: Order):
return all([v[0].cancellation_possible for v in self._evaluate(order)])
def _evaluate(self, order: Order) -> List[List[CancellationConsequence]]:
return [self._evaluate_op(position) for position in order.positions.all()]
def _evaluate_op(self, order_position: OrderPosition) -> List[CancellationConsequence]:
consequences=[rule.apply(order_position) for rule in self]
consequences.sort()
return consequences
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
)
item=models.ForeignKey("Item", on_delete=models.CASCADE, null=True, blank=True)
item_variation=models.ForeignKey("ItemVariation", on_delete=models.CASCADE, null=True, blank=True)
order_status=models.CharField(
max_length=3,
choices=Order.STATUS_CHOICE,
verbose_name=_("Status"),
db_index=True
)
allowed_until=ModelRelativeDateTimeField(null=True, blank=True)
except_after=ModelRelativeDateTimeField(null=True, blank=True)
fee_percentage_per_item=models.DecimalField(
max_digits=10,
decimal_places=2,
validators=[
MaxValueValidator(
limit_value=Decimal("100.00"),
),
MinValueValidator(
limit_value=Decimal("0.00"),
),
],
verbose_name=_("Fee Percentage per Item"),
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"),
default=Decimal("0.00"),
) # wird als sum() kombiniert
fee_absolute_per_order=models.DecimalField(
max_digits=13,
decimal_places=2,
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)
self.rules: List[RuleFn]=[self._rule_order_status, self._rule_time_window,
self._system_rule_not_checked_in]
@staticmethod
def _system_rule_not_checked_in(order_position: OrderPosition) -> Rulings:
if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {"SYSTEM_TICKET_NOT_USED": Ruling(
cancellation_possible=False,
reason=f"Order position was used",
)}
else:
return {"SYSTEM_TICKET_NOT_USED": Ruling(
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}",
)}
else:
return {"TIME_WINDOW": Ruling(
cancellation_possible=False,
reason=f"Cancellation after time window ending on {self.allowed_until}",
)}
def _rule_order_status(self, order_position: OrderPosition) -> Rulings:
if order_position.order.status == self.order_status:
return {"ORDER_STATUS": Ruling(
cancellation_possible=True,
reason=f"Order in required status: '{order_position.order.status}'",
)}
else:
return {"ORDER_STATUS": Ruling(
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])
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,
reference_price=order_position.price)
return CancellationConsequence(
rule_id=self.id,
rulings=rulings,
order_fee=self.fee_absolute_per_order,
position_fee=fee
)

View File

@@ -0,0 +1,102 @@
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 pretix.base.models.cancellation import Ruling
@pytest.fixture(scope='function')
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(),
plugins='pretix.plugins.banktransfer'
)
with scope(organizer=o):
yield event
@pytest.fixture(scope="function")
def ticket(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
return ticket
@pytest.mark.django_db
def test_status_rule(event, ticket):
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,
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):
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,
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'),
)
cr2 = CancellationRule.objects.create(
organizer=event.organizer, event=event, item=ticket,
order_status=Order.STATUS_PAID
)
assert CancellationRule.objects.all().cancellation_possible(o) == True