implement evaluate_process_rule

This commit is contained in:
Lukas Bockstaller
2026-08-18 16:56:53 +02:00
parent 577b9b5737
commit 4c8187e837
3 changed files with 269 additions and 29 deletions
+123 -20
View File
@@ -1,10 +1,13 @@
import datetime
import operator
from dataclasses import dataclass, field
from decimal import Decimal
from itertools import chain
from typing import (
Any, Callable, Dict, List, Literal, Optional, Protocol, Set,
Tuple, TypeAlias,
Any, Callable, ClassVar, Dict, Final, List, Literal, Optional, Protocol, Set,
TYPE_CHECKING, Tuple, TypeAlias,
)
from django_stubs_ext import StrOrPromise
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -14,7 +17,7 @@ from django.utils.translation import gettext_lazy as _
from pretix.base.decimal import round_decimal
from pretix.base.models import Event, Item, ItemVariation, Order, OrderPosition
from pretix.base.reldate import ModelRelativeDateTimeField
from pretix.base.reldate import ModelRelativeDateTimeField, RelativeDateWrapper
from pretix.base.signals import self_service_cancellation_checks
"""
@@ -75,7 +78,7 @@ class CheckResult:
`cancellation_possible`
"""
id: str
reason: str
reason: StrOrPromise
cancellation_possible: bool
type: Literal['check'] = field(default="check")
@@ -272,6 +275,15 @@ def _send_self_service_cancellation_checks(event: Event) -> List[Tuple[Any, Any]
class CancellationRule(models.Model):
EARLIEST: Final = "EARLIEST"
LATEST: Final = "LATEST"
SUBEVENT_VARIANT_CHOICES = (
(EARLIEST, _("Earliest")),
(LATEST, _("Latest")),
)
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
@@ -286,8 +298,11 @@ class CancellationRule(models.Model):
max_length=15,
)
allowed_until = ModelRelativeDateTimeField(null=True, blank=True)
except_after = ModelRelativeDateTimeField(null=True, blank=True)
allowed_until = ModelRelativeDateTimeField(null=True, blank=True, verbose_name=_("Allowed until"))
except_after = ModelRelativeDateTimeField(null=True, blank=True, verbose_name=_("Except after"))
if TYPE_CHECKING:
allowed_until: Optional[RelativeDateWrapper]
except_after: Optional[RelativeDateWrapper]
# --- position-only fields ---
fee_percentage_per_position = models.DecimalField(
@@ -314,6 +329,19 @@ class CancellationRule(models.Model):
)
# --- process-only fields ---
subevent_variant = models.CharField(
max_length=8,
choices=SUBEVENT_VARIANT_CHOICES,
default=EARLIEST,
verbose_name=_("Subevent variant"),
help_text=_("An order can contain tickets for multiple different events if the event has "
"subevents enabled. This choice controls if the order position for the earliest "
"or the latest point in time in the order is used to determine the allowed until and "
"except after dates.")
)
fee_cancellation_process = models.DecimalField(
max_digits=13,
decimal_places=2,
@@ -331,9 +359,8 @@ class CancellationRule(models.Model):
max_length=15,
)
prefetches: List[Callable[[], Prefetch]] = []
related_selects: List[str] = []
prefetches: ClassVar[List[Callable[[], Prefetch]]] = []
related_selects: ClassVar[List[str]] = []
@staticmethod
def _collect_checks(event: Event, send_fn: Callable[
@@ -472,6 +499,9 @@ class PositionCancellationRule(CancellationRule):
"""
objects = PositionCancellationRuleManager()
prefetches: ClassVar[List[Callable[[], Prefetch]]] = []
related_selects: ClassVar[List[str]] = []
class Meta:
proxy = True
@@ -524,6 +554,9 @@ class ProcessCancellationRule(CancellationRule):
objects = ProcessCancellationRuleManager()
prefetches: ClassVar[List[Callable[[], Prefetch]]] = []
related_selects: ClassVar[List[str]] = []
class Meta:
proxy = True
@@ -532,20 +565,90 @@ class ProcessCancellationRule(CancellationRule):
self.full_clean()
super().save(*args, **kwargs)
@staticmethod
def _resolve_date_field(date_field: RelativeDateWrapper, order: Order,
mode: Literal["EARLIEST", "LATEST"] | str) -> datetime.date | datetime.datetime:
reldate_type = date_field.choice
if mode not in ('EARLIEST', 'LATEST'):
raise ValidationError('Mode is invalid')
if reldate_type == "date":
return date_field.date(order.event)
elif reldate_type == "datetime":
return date_field.datetime(order.event)
if reldate_type.base == "order":
return date_field.datetime(order)
if not order.event.has_subevents:
return date_field.datetime(order.event)
comparators = {
"EARLIEST": operator.lt,
"LATEST": operator.gt,
}
compare = comparators[mode]
base_event = order.event
base_value: None | datetime.date | datetime.date = None
for pos in order.positions.all():
e = pos.subevent if pos.subevent else pos.event
value = getattr(e, reldate_type.attribute)
if value is None:
continue # skip when there is no value
if base_value is None or compare(value, base_value):
base_event = e
base_value = value
return date_field.datetime(base_event)
def evaluate_process_rule(self, order: Order, keep: Set[OrderPosition], position_fees: Decimal) -> \
Optional[RuleResult]:
fee_mode = self.fee_mode
if fee_mode not in (FeeType.MINIMUM, FeeType.ADDITIONAL):
raise ValueError(f"Unexpected fee_mode: {fee_mode!r}")
rule_results = [] # TODO really evaluate rules
check_results: List[CheckResult] = []
fee_type = self.fee_mode
if fee_type not in (FeeType.MINIMUM, FeeType.ADDITIONAL):
raise ValueError(f"Unexpected fee_mode: {fee_type!r}")
now = datetime.datetime.now(tz=datetime.timezone.utc)
return RuleResult.from_process_fee(
id=self.id,
partial_results=rule_results,
fee_type=fee_type,
absolute_fee=self.fee_cancellation_process,
reference_price=position_fees,
)
for param in ('allowed_until', 'except_after'):
value: RelativeDateWrapper | None = getattr(self, param, None)
if value is not None:
if now <= self._resolve_date_field(value, order, self.subevent_variant):
check_results.append(
CheckResult(
id=f"process_rule_{self.id}",
reason=_("{} is earlier than {} cutoff {}".format(now, param, value)),
cancellation_possible=True
)
)
else:
check_results.append(
CheckResult(
id=f"process_rule_{self.id}",
reason=_("{} is later than {} cutoff {}".format(now, param, value)),
cancellation_possible=False
)
)
else:
check_results.append(
CheckResult(
id=f"process_rule_{self.id}",
reason=_("No {} limit defined".format(param)),
cancellation_possible=True
)
)
return RuleResult.from_process_fee(
id=self.id,
partial_results=check_results,
fee_type=fee_mode,
absolute_fee=self.fee_cancellation_process,
reference_price=position_fees,
)
+16 -7
View File
@@ -111,21 +111,21 @@ class RelativeDate:
base_date_name: str = 'event__date_from__'
def __post_init__(self) -> None:
if self.is_after and not self._choice.supports_after:
if self.is_after and not self.choice.supports_after:
raise ValueError(
"The selected base date and attribute combination does not support relative dates placed after the base date"
)
if not self.is_after and not self._choice.supports_before:
if not self.is_after and not self.choice.supports_before:
raise ValueError(
"The selected base date and attribute combination does not support relative dates placed before the base date")
@property
def _choice(self):
def choice(self):
return BaseChoice.find(BASE_CHOICES, self.base_date_name)
@property
def key(self):
return self._choice.key
return self.choice.key
def __eq__(self, o: object) -> bool:
if not isinstance(o, RelativeDate):
@@ -141,7 +141,7 @@ class RelativeDate:
"""
from .models import Event, Order, SubEvent
choice = self._choice
choice = self.choice
if choice.base == "order" and isinstance(base, Order):
event = base.event
@@ -210,13 +210,13 @@ class RelativeDate:
if self.minutes is not None:
return 'RELDATE/minutes/{}/{}/{}'.format( #
self.minutes,
self._choice.key,
self.choice.key,
'after' if self.is_after else '',
)
return 'RELDATE/{}/{}/{}/{}'.format( #
self.days,
self.time.strftime('%H:%M:%S') if self.time else '-',
self._choice.key,
self.choice.key,
'after' if self.is_after else '',
)
@@ -270,6 +270,15 @@ class RelativeDateWrapper:
def __init__(self, data: Union[datetime.datetime, RelativeDate]):
self.data = data
@property
def choice(self) -> Literal["datetime", "date"] | BaseChoice:
if isinstance(self.data, datetime.datetime):
return "datetime"
elif isinstance(self.data, datetime.date):
return "date"
else:
return self.data.choice
def date(self, base: "Event | Order | SubEvent") -> datetime.date:
"""
If the RelativeDateWrapper wraps a RelativeDate object:
@@ -1,5 +1,5 @@
import contextlib
from datetime import timedelta
from datetime import UTC, timedelta, datetime
from decimal import Decimal
from typing import List, Literal, cast
@@ -13,12 +13,14 @@ from pretix.base.models import (
)
from pretix.base.models.cancellation import (
CancellationCheck, CancellationRule, CheckResult, Checks, CheckTypes,
FeeType, PositionResult, ProcessResult, RuleResult,
FeeType, PositionResult, ProcessCancellationRule, ProcessResult, RuleResult,
)
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.base.services.orders import signal_listener_position_not_used
from pretix.helpers import ensure_no_queries
@pytest.fixture
def event():
o = Organizer.objects.create(name='Dummy', slug='dummy')
@@ -295,3 +297,129 @@ def test_ticket_not_used(event, order, order_position, checkin_list):
result = position_not_used_check.evaluate(prefetched_order, keep, order_position)
assert result.cancellation_possible is False
REFERENCE_DT = datetime(2017, 12, 27, 4, 0, 0, tzinfo=UTC)
@pytest.fixture(params=["date", "datetime", "order", "event"])
def rdt_reldate_variants(request):
return request.param
@pytest.fixture
def rdt_reldate(rdt_reldate_variants) -> RelativeDateWrapper:
if rdt_reldate_variants == 'date' or rdt_reldate_variants == 'datetime':
return RelativeDateWrapper.from_string(REFERENCE_DT.isoformat())
elif rdt_reldate_variants == 'order':
return RelativeDateWrapper(
RelativeDate(days=1, time=None, base_date_name='order__datetime', minutes=None, is_after=True))
elif rdt_reldate_variants == 'event':
return RelativeDateWrapper(
RelativeDate(days=1, time=None, base_date_name='event__date_from', minutes=None, is_after=True))
else:
raise ValueError()
@pytest.fixture(params=["single_event", "subevents"])
def rdt_event_variants(request):
return request.param
@pytest.fixture
def rdt_events(rdt_event_variants, event):
if rdt_event_variants == "single_event":
event.date_from = REFERENCE_DT
event.save()
else:
event.has_subevents = True
event.subevents.create(
name='1',
date_from=REFERENCE_DT,
)
event.subevents.create(
name='2',
date_from=REFERENCE_DT + timedelta(days=1),
)
event.subevents.create(
name='3',
date_from=REFERENCE_DT + timedelta(days=2),
)
return event
@pytest.fixture
def rdt_item(rdt_events):
return rdt_events.items.create(
name='Ticket',
category=None, default_price=23,
admission=True
)
@pytest.fixture(params=["EARLIEST", "LATEST"])
def rdt_mode_variants(request):
return request.param
@pytest.fixture
def rdt_order(rdt_events):
o = Order.objects.create(
code='123456', event=rdt_events, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=REFERENCE_DT + timedelta(hours=6), # 6 hours offset mark orders
sales_channel=rdt_events.organizer.sales_channels.get(identifier="web"),
total=14, locale='en'
)
return o
@pytest.fixture
def rdt_order_positions(rdt_event_variants, rdt_events, rdt_item, rdt_order):
if rdt_event_variants == "single_event":
op = OrderPosition.objects.create(
order=rdt_order,
item=rdt_item,
variation=None,
price=Decimal("14"),
)
else:
op = []
for i in range(0, 3):
op.append(OrderPosition.objects.create(
subevent=rdt_events.subevents.all()[i],
order=rdt_order,
item=rdt_item,
variation=None,
price=Decimal("14"),
))
return op
@pytest.mark.django_db
def test_resolve_date_field(rdt_reldate, rdt_reldate_variants, rdt_events, rdt_event_variants, rdt_mode_variants,
rdt_order,
rdt_order_positions):
with scope(organizer=rdt_events.organizer):
date = ProcessCancellationRule._resolve_date_field(rdt_reldate, rdt_order, rdt_mode_variants)
match rdt_reldate_variants:
case "date":
assert date == REFERENCE_DT
case "datetime":
assert date == REFERENCE_DT
case "order":
assert date == REFERENCE_DT + timedelta(days=1) + timedelta(hours=6)
case "event":
if rdt_event_variants == "single_event":
assert date == REFERENCE_DT + timedelta(days=1)
elif rdt_event_variants == "subevents":
if rdt_mode_variants == "EARLIEST":
assert date == REFERENCE_DT + timedelta(days=1)
elif rdt_mode_variants == "LATEST":
assert date == REFERENCE_DT + timedelta(days=1) + timedelta(days=2)
else:
raise ValueError("Variant not known")
else:
raise ValueError("Variant not known")
case _:
raise ValueError("Variant not known")