diff --git a/src/pretix/base/models/cancellation.py b/src/pretix/base/models/cancellation.py index 2a2e68dd43..2b78ccd170 100644 --- a/src/pretix/base/models/cancellation.py +++ b/src/pretix/base/models/cancellation.py @@ -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, + ) diff --git a/src/pretix/base/reldate.py b/src/pretix/base/reldate.py index 510a4b4661..abcb152209 100644 --- a/src/pretix/base/reldate.py +++ b/src/pretix/base/reldate.py @@ -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: diff --git a/src/tests/base/test_self_service_cancellation.py b/src/tests/base/test_self_service_cancellation.py index 711511bdf3..060cf0e303 100644 --- a/src/tests/base/test_self_service_cancellation.py +++ b/src/tests/base/test_self_service_cancellation.py @@ -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")