mirror of
https://github.com/pretix/pretix.git
synced 2026-08-22 12:42:00 +00:00
implement evaluate_process_rule
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user