diff --git a/src/pretix/base/models/cancellation.py b/src/pretix/base/models/cancellation.py index f63b76f882..e7a4b776aa 100644 --- a/src/pretix/base/models/cancellation.py +++ b/src/pretix/base/models/cancellation.py @@ -1,8 +1,9 @@ import datetime import operator -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from decimal import Decimal +from django.core.serializers.json import DjangoJSONEncoder from django.utils.timezone import make_aware from itertools import chain from typing import ( @@ -85,6 +86,11 @@ class CheckResult: cancellation_possible: bool type: Literal['check'] = field(default="check") + @classmethod + def from_dict(cls, data: dict) -> "CheckResult": + return cls(**data) + + @dataclass(frozen=True) class RuleResult: @@ -106,6 +112,15 @@ class RuleResult: type: Literal['rule'] = field(default="rule") + @classmethod + def from_dict(cls, data: dict) -> "RuleResult": + return cls( + id=data["id"], + partial_results=[CheckResult.from_dict(r) for r in data["partial_results"]], + fee_type=FeeType(data["fee_type"]), + fee=Decimal(data["fee"]), + ) + @property def cancellation_possible(self) -> bool: return all(result.cancellation_possible for result in self.partial_results) @@ -219,6 +234,19 @@ class PositionResult: position_check_results: Dict[int, List[CheckResult]] position_rule_results: Dict[int, List[RuleResult]] + @classmethod + def from_dict(cls, data: dict) -> "PositionResult": + return cls( + position_check_results={ + int(pos_id): [CheckResult.from_dict(r) for r in results] + for pos_id, results in data["position_check_results"].items() + }, + position_rule_results={ + int(pos_id): [RuleResult.from_dict(r) for r in results] + for pos_id, results in data["position_rule_results"].items() + }, + ) + @property def cancellation_possible(self) -> bool: def ok(results: List[CheckResult] | List[RuleResult]) -> bool: @@ -248,6 +276,13 @@ class ProcessResult: process_check_results: List[CheckResult] process_rule_results: List[RuleResult] + @classmethod + def from_dict(cls, data: dict) -> "ProcessResult": + return cls( + process_check_results=[CheckResult.from_dict(r) for r in data["process_check_results"]], + process_rule_results=[RuleResult.from_dict(r) for r in data["process_rule_results"]], + ) + @property def cancellation_possible(self) -> bool: results: List[CheckResult | RuleResult] = [*self.process_check_results] @@ -270,18 +305,145 @@ class CancellationResult: position_result: PositionResult process_result: ProcessResult - order: Order - keep: Set[OrderPosition] - check_ts: datetime.datetime - - # TODO add state choice - # TODO make this a model - + @classmethod + def from_dict(cls, data: dict) -> "CancellationResult": + return cls( + position_result=PositionResult.from_dict(data["position_result"]), + process_result=ProcessResult.from_dict(data["process_result"]), + ) @property def cancellation_possible(self) -> bool: return self.position_result.cancellation_possible and self.process_result.cancellation_possible + +class Cancellation(models.Model): + REQUESTED: Final = "REQUESTED" + PERFORMED: Final = "PERFORMED" + + CANCELLATION_STATE = ( + (REQUESTED, _("Requested")), + (PERFORMED, _("Performed")), + ) + + event = models.ForeignKey( + Event, + verbose_name=_("Event"), + related_name="cancellations", + on_delete=models.CASCADE + ) + order = models.ForeignKey( + Order, + verbose_name=_("Order"), + related_name="cancellations", + on_delete=models.CASCADE + ) + keep = models.ManyToManyField( + to=OrderPosition, + verbose_name=_("Positions to keep"), + ) + evaluation_ts = models.DateTimeField( + verbose_name=_("Cancellation datetime"), + auto_now_add=True, + ) + + cancellation_state = models.CharField( + max_length=8, + choices=CANCELLATION_STATE, + default=REQUESTED, + verbose_name=_("State of the cancellation"), + ) + + _result = models.JSONField(default=dict, db_column="result", encoder=DjangoJSONEncoder) + + @property + def result(self) -> CancellationResult: + return CancellationResult.from_dict(self._result) + + @result.setter + def result(self, value: CancellationResult): + if not isinstance(value, CancellationResult): + raise TypeError("result must be a CancellationResult instance") + if self._result: + raise ValueError("result is write-once and has already been set") + self._result = asdict(value) + + @property + def possible(self) -> bool: + return self.result.cancellation_possible + + @staticmethod + def evaluate(event: Event, order: Order, keep: Set[OrderPosition], + check_ts: datetime.datetime) -> "Cancellation": + + # TODO check that all keep entries belong to order + # TODO exclude cancelled positions + + # collect all checks, position_rules and process_rules that are applicable + checks = CancellationRule.collect_checks(event=event) + position_rules: QuerySet[PositionCancellationRule] = PositionCancellationRule.objects.filter( + event=event).with_rule_data().all() + process_rules: QuerySet[ProcessCancellationRule] = ProcessCancellationRule.objects.filter( + event=event).with_rule_data().all() + + order = CancellationRule.prefetch_order(event, order, checks) + + # keep track of all decisions so we can explain them in the logs + position_check_results: Dict[int, List[CheckResult]] = {} + position_rule_results: Dict[int, List[RuleResult]] = {} + + # perform all position checks and position rules + for position in order.positions.all(): + position_check_results[position.id] = [] + position_rule_results[position.id] = [] + + # skip this position if customer doesn't want to cancel + if position in keep: + continue + + # evaluate the system/plugin checks for the position + for check in checks.position: + position_check_results[position.id].append( + check.evaluate(order=order, keep=keep, position=position, check_ts=check_ts)) + + # evaluate all customer specified rules for this position + for rule in position_rules: + result = rule.evaluate_position_rule(order, keep, position, check_ts) + if result is not None: + position_rule_results[position.id].append(result) + + position_results = PositionResult(position_check_results=position_check_results, + position_rule_results=position_rule_results) + + # we need the current fee_value to select the cheapest process rule + temp_position_fees = position_results.fee_value + + # again keep track of all decisions so we can explain them in the logs + process_check_results: List[CheckResult] = [] + process_rule_results: List[RuleResult] = [] + + # evaluate all system/plugin provided checks for the cancellation process + for check in checks.process: + process_check_results.append(check.evaluate(order=order, keep=keep, position=None, check_ts=check_ts)) + + # evaluate all customer specified rules for the cancellation process + for rule in process_rules: + result = rule.evaluate_process_rule(order, keep, temp_position_fees, check_ts) + if result is not None: + process_rule_results.append(result) + + process_result = ProcessResult(process_check_results=process_check_results, + process_rule_results=process_rule_results) + + res = CancellationResult(position_result=position_results, process_result=process_result) + + c = Cancellation(event=event, order=order, result=res, evaluation_ts=check_ts) + c.save() + c.keep.add(*keep) + + return c + + def prepare(self): # TODO: store the cancellation id in the session storage pass @@ -295,6 +457,20 @@ def _send_self_service_cancellation_checks(event: Event) -> List[Tuple[Any, Any] return self_service_cancellation_checks.send(sender=event) +class CancellationRuleQuerySet(models.QuerySet): + def with_rule_data(self): + model = self.model + qs = self.prefetch_related(*[p() for p in model.rule_prefetches]) + if model.rule_related_selects: + qs = qs.select_related(*model.rule_related_selects) + return qs + + +class CancellationRuleManager(models.Manager.from_queryset(CancellationRuleQuerySet)): + check_type: ClassVar[CheckTypes] + + def get_queryset(self): + return super().get_queryset().filter(type=self.check_type).order_by("pk") class CancellationRule(models.Model): EARLIEST: Final = "EARLIEST" @@ -391,7 +567,7 @@ class CancellationRule(models.Model): @staticmethod - def _collect_checks(event: Event, send_fn: Callable[ + def collect_checks(event: Event, send_fn: Callable[ [Event], List[Tuple[Any, Any]]] = _send_self_service_cancellation_checks) -> Checks: position_checks: List[CancellationCheck] = [] process_checks: List[CancellationCheck] = [] @@ -415,7 +591,7 @@ class CancellationRule(models.Model): return Checks(position=position_checks, process=process_checks) @staticmethod - def _prefetch_order(event: Event, order: Order, checks: Checks) -> Order: + def prefetch_order(event: Event, order: Order, checks: Checks) -> Order: prefetches = [pref() for pref in [*checks.prefetches, *PositionCancellationRule.prefetches, *ProcessCancellationRule.prefetches]] @@ -431,72 +607,28 @@ class CancellationRule(models.Model): return qs.get(event=event, id=order.id) @staticmethod - def evaluate(event: Event, order: Order, keep: Set[OrderPosition], - check_ts: datetime.datetime) -> "CancellationResult": + def _resolve_date_field_common( + date_field: RelativeDateWrapper, + order: Order, + resolve_subevent: Callable[[Any], Any], + ) -> datetime.date | datetime.datetime: + reldate_type = date_field.choice - # TODO check that all keep entries belong to order - # TODO exclude cancelled positions + if reldate_type == "date": + return make_aware( + datetime.datetime.combine(date_field.date(order.event), datetime.time(hour=23, minute=59, second=59)), + order.event.timezone, + ) + elif reldate_type == "datetime": + return date_field.datetime(order.event) - # collect all checks, position_rules and process_rules that are applicable - checks = CancellationRule._collect_checks(event=event) - position_rules: QuerySet[PositionCancellationRule] = PositionCancellationRule.objects.filter( - event=event).with_rule_data().all() - process_rules: QuerySet[ProcessCancellationRule] = ProcessCancellationRule.objects.filter( - event=event).with_rule_data().all() + if reldate_type.base == "order": + return date_field.datetime(order) - order = CancellationRule._prefetch_order(event, order, checks) - - - # keep track of all decisions so we can explain them in the logs - position_check_results: Dict[int, List[CheckResult]] = {} - position_rule_results: Dict[int, List[RuleResult]] = {} - - # perform all position checks and position rules - for position in order.positions.all(): - position_check_results[position.id] = [] - position_rule_results[position.id] = [] - - # skip this position if customer doesn't want to cancel - if position in keep: - continue - - # evaluate the system/plugin checks for the position - for check in checks.position: - position_check_results[position.id].append( - check.evaluate(order=order, keep=keep, position=position, check_ts=check_ts)) - - # evaluate all customer specified rules for this position - for rule in position_rules: - result = rule.evaluate_position_rule(order, keep, position, check_ts) - if result is not None: - position_rule_results[position.id].append(result) - - position_results = PositionResult(position_check_results=position_check_results, - position_rule_results=position_rule_results) - - # we need the current fee_value to select the cheapest process rule - temp_position_fees = position_results.fee_value - - # again keep track of all decisions so we can explain them in the logs - process_check_results: List[CheckResult] = [] - process_rule_results: List[RuleResult] = [] - - # evaluate all system/plugin provided checks for the cancellation process - for check in checks.process: - process_check_results.append(check.evaluate(order=order, keep=keep, position=None, check_ts=check_ts)) - - # evaluate all customer specified rules for the cancellation process - for rule in process_rules: - result = rule.evaluate_process_rule(order, keep, temp_position_fees, check_ts) - if result is not None: - process_rule_results.append(result) - - process_result = ProcessResult(process_check_results=process_check_results, - process_rule_results=process_rule_results) - - return CancellationResult(position_result=position_results, process_result=process_result, - check_ts=check_ts, order=order, keep=keep) + if not order.event.has_subevents: + return date_field.datetime(order.event) + return date_field.datetime(resolve_subevent(reldate_type)) def clean(self): @@ -531,17 +663,10 @@ class CancellationRule(models.Model): raise ValidationError(errors) -class PositionCancellationRuleQuerySet(models.QuerySet): - def with_rule_data(self): - qs = self.prefetch_related(*[p() for p in PositionCancellationRule.rule_prefetches]) - if PositionCancellationRule.rule_related_selects: - qs = qs.select_related(*PositionCancellationRule.rule_related_selects) - return qs +class PositionCancellationRuleManager(CancellationRuleManager): + check_type = CheckTypes.POSITION -class PositionCancellationRuleManager(models.Manager.from_queryset(PositionCancellationRuleQuerySet)): - def get_queryset(self): - return super().get_queryset().filter(type=CheckTypes.POSITION).order_by("pk") class PositionCancellationRule(CancellationRule): @@ -596,23 +721,9 @@ class PositionCancellationRule(CancellationRule): @staticmethod def _resolve_date_field(date_field: RelativeDateWrapper, order: Order, position: OrderPosition) -> datetime.date | datetime.datetime: - reldate_type = date_field.choice - - if reldate_type == "date": - return make_aware( - datetime.datetime.combine(date_field.date(order.event), datetime.time(hour=23, minute=59, second=59)), - order.event.timezone, - ) - 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) - - return date_field.datetime(position.subevent) + return CancellationRule._resolve_date_field_common( + date_field, order, resolve_subevent=lambda _reldate_type: position.subevent + ) def _evaluate_cancellation_moment(self, position: OrderPosition, check_ts: datetime.datetime) -> List[CheckResult]: with ensure_no_queries(): @@ -679,17 +790,8 @@ class PositionCancellationRule(CancellationRule): ) -class ProcessCancellationRuleQuerySet(models.QuerySet): - def with_rule_data(self): - qs = self.prefetch_related(*[p() for p in ProcessCancellationRule.rule_prefetches]) - if ProcessCancellationRule.rule_related_selects: - qs = qs.select_related(*ProcessCancellationRule.rule_related_selects) - return qs - - -class ProcessCancellationRuleManager(models.Manager.from_queryset(ProcessCancellationRuleQuerySet)): - def get_queryset(self): - return super().get_queryset().filter(type=CheckTypes.PROCESS).order_by("pk") +class ProcessCancellationRuleManager(CancellationRuleManager): + check_type = CheckTypes.PROCESS class ProcessCancellationRule(CancellationRule): @@ -719,46 +821,29 @@ class ProcessCancellationRule(CancellationRule): @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 make_aware( - datetime.datetime.combine(date_field.date(order.event), datetime.time(hour=23, minute=59, second=59)), - order.event.timezone, - ) - 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) + def resolve_subevent(reldate_type): + base_event = order.event + base_value: None | 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 base_event - 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) + return CancellationRule._resolve_date_field_common(date_field, order, resolve_subevent) def _evaluate_cancellation_moment(self, order: Order, check_ts: datetime.datetime) -> List[CheckResult]: with ensure_no_queries(): diff --git a/src/tests/base/test_self_service_cancellation.py b/src/tests/base/test_self_service_cancellation.py index 9306a45f00..e41b273ff6 100644 --- a/src/tests/base/test_self_service_cancellation.py +++ b/src/tests/base/test_self_service_cancellation.py @@ -12,7 +12,7 @@ from pretix.base.models import ( Checkin, Event, Order, OrderPosition, Organizer, ) from pretix.base.models.cancellation import ( - CancellationCheck, CancellationRule, CheckResult, Checks, CheckTypes, + Cancellation, CancellationCheck, CancellationRule, CheckResult, Checks, CheckTypes, FeeType, PositionCancellationRule, PositionResult, ProcessCancellationRule, ProcessResult, RuleResult, ) from pretix.base.reldate import RelativeDate, RelativeDateWrapper @@ -329,7 +329,7 @@ class TestCancellationRule: return [("", res) for res in received] with raises: - checks = CancellationRule._collect_checks(event=event, send_fn=send_fn) + checks = CancellationRule.collect_checks(event=event, send_fn=send_fn) for pos in process_checks: assert received[pos] in checks.process @@ -343,7 +343,7 @@ class TestCancellationRule: def test_prefetch_no_checks_collected(self, event, order): checks = Checks(position=[], process=[]) with scope(organizer=event.organizer): - prefetched_order = CancellationRule._prefetch_order(event, order, checks) + prefetched_order = CancellationRule.prefetch_order(event, order, checks) assert prefetched_order.id == order.id @pytest.mark.django_db @@ -360,7 +360,7 @@ class TestCancellationRule: ) with scope(organizer=event.organizer): - prefetched_order = CancellationRule._prefetch_order(event, order, checks) + prefetched_order = CancellationRule.prefetch_order(event, order, checks) assert prefetched_order.id == order.id class TestChecks: @@ -372,7 +372,7 @@ class TestCancellationRule: keep = set() with scope(organizer=event.organizer): - prefetched_order = CancellationRule._prefetch_order(event, order, checks) + prefetched_order = CancellationRule.prefetch_order(event, order, checks) with ensure_no_queries(): result = position_not_used_check.evaluate(prefetched_order, keep, order_position, datetime.now(tz=UTC)) @@ -383,7 +383,7 @@ class TestCancellationRule: position=order_position, successful=True ) - prefetched_order = CancellationRule._prefetch_order(event, order, checks) + prefetched_order = CancellationRule.prefetch_order(event, order, checks) with ensure_no_queries(): result = position_not_used_check.evaluate(prefetched_order, keep, order_position, @@ -710,16 +710,16 @@ class TestCancellationRule: allowed_until=RelativeDateWrapper(reference_ts - timedelta(days=1))) with scope(organizer=event.organizer): - res = CancellationRule.evaluate(event, order, keep=set(), check_ts=check_ts) + cancellation = Cancellation.evaluate(event, order, keep=set(), check_ts=check_ts) - assert res.cancellation_possible == True + assert cancellation.possible == True - position_rule_results = res.position_result.position_rule_results[1] + position_rule_results = cancellation.result.position_result.position_rule_results[1] assert len(position_rule_results) == 2 assert position_rule_results[0].cancellation_possible == True assert position_rule_results[1].cancellation_possible == False - process_rule_results = res.process_result.process_rule_results + process_rule_results = cancellation.result.process_result.process_rule_results assert len(process_rule_results) == 2 assert process_rule_results[0].cancellation_possible == True assert process_rule_results[1].cancellation_possible == False