diff --git a/src/pretix/base/apps.py b/src/pretix/base/apps.py index 1852c0c352..17abea3fc6 100644 --- a/src/pretix/base/apps.py +++ b/src/pretix/base/apps.py @@ -46,7 +46,10 @@ class PretixBaseConfig(AppConfig): from .invoicing import pdf, transmission, email, peppol, national # NOQA from . import notifications # NOQA from . import email # NOQA - from .services import auth, checkin, currencies, datasync, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA + from .services import ( # NOQA + auth, checkin, currencies, datasync, export, mail, tickets, cart, modelimport, orders, + invoices, cleanup, update_check, quotas, notifications, vouchers, cancellation, + ) from .models import _transactions # NOQA from django.conf import settings diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 3657cfb4c2..23c004450a 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -22,6 +22,7 @@ from ..settings import GlobalSettingsObject_SettingsStore from .auth import U2FDevice, User, WebAuthnDevice from .base import CachedFile, LoggedModel, cachedfile_name +from .cancellation import Cancellation, CancellationRule from .checkin import Checkin, CheckinList from .currencies import ExchangeRate from .customers import Customer diff --git a/src/pretix/base/models/cancellation.py b/src/pretix/base/models/cancellation.py index 1e94ae19f1..d6e05f33b8 100644 --- a/src/pretix/base/models/cancellation.py +++ b/src/pretix/base/models/cancellation.py @@ -21,319 +21,28 @@ # import datetime import operator -from dataclasses import asdict, dataclass, field +from dataclasses import asdict from decimal import Decimal -from itertools import chain from typing import ( - TYPE_CHECKING, Any, Callable, ClassVar, Dict, Final, List, Literal, - Optional, Protocol, Set, Tuple, TypeAlias, + TYPE_CHECKING, Any, Callable, ClassVar, Final, List, Literal, Optional, + Set, ) from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Prefetch, QuerySet +from django.db.models import Prefetch from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ -from pretix.base.decimal import round_decimal +from pretix.base.cancellation import ( + CancellationResult, CheckResult, CheckTypes, FeeType, RuleResult, +) from pretix.base.models import Event, Item, ItemVariation, Order, OrderPosition from pretix.base.reldate import ModelRelativeDateTimeField, RelativeDateWrapper -from pretix.base.signals import self_service_cancellation_checks from pretix.helpers import ensure_no_queries -""" -Supporting self-service cancellation requires us to do two main things: -1. uphold the business logic of pretix and the installed plugins -2. charge the customer the appropriate fees for their cancellation - -Number 1 is a question of bringing enough checks into place and prevent a -cancellation if one of them is violated. -Checks need to subclass `CancellationCheck` and can be provided via the new -`self_service_cancellation_checks` signal. - -Number 2 is trickier because organizers will have complex^(TM) cancellation -fee structures and expressing these in an understandable way is a challenge. -Especially when taking support cases into consideration that have to debug -certain behaviour long after. -The cancellation fees are computed via `CancellationRules`. - -When a customer triggers a self service cancellation, we will: -1. Positions - a. Evaluate all `CancellationChecks` that are concerned with individual positions - b. Evaluate all `CancellationRules` that are concerned with individual positions and compute the fees - c. Choose for each position the cheapest `CancellationRules` position result available -2. Process - a. Evaluate all `CancellationChecks` that are concerned with the process of cancellation - b. Evaluate all `CancellationRules` that are concerned with the process of cancellation - c. Choose the cheapest `CancellationRules` process result available -3. Return all results for Checks and Rules - -Step 1c. and 2c. are kept separate intentionally. -The alternative of finding the cheapest cancellation option overall (process and position) would -require us to check the full combinatorics of possible process and position fees, resulting -in unfeasible runtime behaviour, and if we would optimize it in difficult to explain non-optimal -situations. -""" - - -class FeeType(models.TextChoices): - """ - Process fees can be added on top of all position fees, or they - can set a floor for the minimum cancellation fee that this will incur. - """ - MINIMUM = "min_process_fee", _("Minimum total fee") - ADDITIONAL = "add_process_fee", _("Additional fee") - POSITION = "position_fee", _("Position fee") - - -class CheckTypes(models.TextChoices): - POSITION = "position", _("Order Position Cancellation Rule") - PROCESS = "process", _("Cancellation Process Rule") - - -@dataclass(frozen=True) -class CheckResult: - """ - Result of an individual cancellation check. - The check result only encodes if the check allows or disallows cancellation via - `cancellation_possible` - """ - id: str - reason: str - 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: - """ - Result of evaluating a CancellationRule. - A rule can consist out of multiple different checks, each partial_result is recorded individually. - - A RuleResult encodes both the feasibility of a cancellation via `cancellation_possible` and - the resulting consequences in form of fees which can be expressed as: - - absolute position fees of a fixed amount - - relative position fees of a percentage of the position price - - minimum process fees, the total cancellation fee across all positions and the process must be at least this - - additional process fee, an additional processing fee is charged in addition to the per position fees - """ - id: int - partial_results: List[CheckResult] - fee_type: FeeType - fee: Decimal - - 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) - - @classmethod - def from_absolute_fee( - cls, - id: int, - partial_results: List[CheckResult], - fee_type: Literal[FeeType.POSITION], - absolute_fee: Decimal - ) -> "RuleResult": - return RuleResult(id=id, partial_results=partial_results, fee_type=fee_type, fee=absolute_fee) - - @classmethod - def from_relative_fee( - cls, - id: int, - partial_results: List[CheckResult], - fee_type: Literal[FeeType.POSITION], - position_price: Decimal, - percentage: Decimal, - currency: str - ) -> "RuleResult": - return RuleResult(id=id, partial_results=partial_results, fee_type=fee_type, - fee=round_decimal(position_price * (percentage / 100), currency)) - - @classmethod - def from_process_fee( - cls, - id: int, - partial_results: List[CheckResult], - fee_type: Literal[FeeType.MINIMUM, FeeType.ADDITIONAL], - absolute_fee: Decimal, - reference_price: Decimal - ) -> "RuleResult": - if fee_type == FeeType.MINIMUM: - if reference_price < absolute_fee: - fee = absolute_fee - reference_price - else: - fee = Decimal(0) - elif fee_type == FeeType.ADDITIONAL: - fee = absolute_fee - else: - raise ValueError("Unknown fee type") - - return RuleResult(id=id, partial_results=partial_results, fee_type=fee_type, fee=fee) - - def __lt__(self, other: object) -> bool: - if not isinstance(other, RuleResult): - return NotImplemented - - if self.cancellation_possible == other.cancellation_possible: - return self.fee < other.fee - else: - return self.cancellation_possible and not other.cancellation_possible - - -@dataclass(frozen=True) -class Checks: - position: List["CancellationCheck"] - process: List["CancellationCheck"] - - @property - def prefetches(self) -> List[Callable[[], Prefetch]]: - return list(chain.from_iterable( - check.prefetches for check in [*self.position, *self.process] - )) - - @property - def related_selects(self) -> List[str]: - return list(chain.from_iterable( - check.related_selects for check in [*self.position, *self.process] - )) - - -PositionSet: TypeAlias = Set[OrderPosition] - - -class PositionCheckFn(Protocol): - def __call__(self, order: Order, keep: PositionSet, position: OrderPosition, check_ts: datetime.datetime, - /) -> Optional[CheckResult]: - ... - - -class ProcessCheckFn(Protocol): - def __call__(self, order: Order, keep: PositionSet, check_ts: datetime.datetime, /) -> Optional[CheckResult]: - ... - - -@dataclass(frozen=True) -class CancellationCheck: - id: str - type: CheckTypes - check_fn: PositionCheckFn | ProcessCheckFn = field(compare=False) - prefetches: List[Callable[[], Prefetch]] = field(default_factory=list) - related_selects: List[str] = field(default_factory=list) - - def evaluate(self, order: Order, keep: PositionSet, - position: OrderPosition | None, check_ts: datetime.datetime) -> Optional[CheckResult]: - if position and self.type == CheckTypes.POSITION: - return self.check_fn(order, keep, position, check_ts) - elif position is None and self.type == CheckTypes.PROCESS: - return self.check_fn(order, keep, check_ts) - else: - raise ValidationError("Type of the rule doesn't match the check_fn") - - -@dataclass(frozen=True) -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: - return all([val.cancellation_possible for val in results]) if results else True - - return all( - ok(results) - for d in - (self.position_check_results, - {key: [min(pos_res)] for key, pos_res in self.position_rule_results.items() if pos_res}) - for results in d.values() - ) - - @property - def fee_value(self) -> Decimal: - fee_value = Decimal("0.00") - for pos_id, results in self.position_rule_results.items(): - if len(results) > 0: - best_option = min(results) - if best_option.cancellation_possible: - fee_value += best_option.fee - return fee_value - - -@dataclass(frozen=True) -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] - if self.process_rule_results: - results.append(min(self.process_rule_results)) - return all(res.cancellation_possible for res in results) - - @property - def fee_value(self) -> Decimal: - if not self.process_rule_results: - return Decimal("0.00") - best_option = min(self.process_rule_results) - if best_option.cancellation_possible: - return best_option.fee - return Decimal("0.00") - - -@dataclass(frozen=True) -class CancellationResult: - position_result: PositionResult - process_result: ProcessResult - - @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): CREATED: Final = "CREATED" @@ -394,100 +103,6 @@ class Cancellation(models.Model): def possible(self) -> bool: return self.result.cancellation_possible - @staticmethod - def evaluate(event: Event, order: Order, keep: Set[OrderPosition], - check_ts: datetime.datetime) -> "CancellationResult": - - # validate that all keep order positions are part of the order - for p in keep: - if p.order_id != order.id: - raise ValidationError("OrderPosition {} does not belong to order {}".format(p.code, order.code)) - - # exclude canceled positions - for p in order.positions.all(): - if p.canceled: - keep.add(p) - - # 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: - res = check.evaluate(order=order, keep=keep, position=position, check_ts=check_ts) - if res is not None: - position_check_results[position.id].append(res) - - # 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: - res = check.evaluate(order=order, keep=keep, position=None, check_ts=check_ts) - if res is not None: - process_check_results.append(res) - - # 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) - - return res - - @staticmethod - def prepare(event: Event, order: Order, keep: Set[OrderPosition], - check_ts: datetime.datetime) -> "Cancellation": - res = Cancellation.evaluate(event=event, order=order, keep=set(), check_ts=check_ts) - c = Cancellation(event=event, order=order, result=res, evaluation_ts=check_ts) - c.save() - c.keep.add(*keep) - return c - - def execute(self): - # TODO load the cancellation verdict from the id and perform the actions - pass - - -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): @@ -598,48 +213,6 @@ class CancellationRule(models.Model): ), ] - @staticmethod - 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] = [] - - seen = set() - for recv, resp in send_fn(event): - if resp is None: - continue - - if not isinstance(resp, CancellationCheck): - raise ValueError('self_service_cancellation_checks received response of wrong type') - if resp.id in seen: - raise ValueError('self_service_cancellation_checks received multiple responses with the id') - seen.add(resp.id) - - if resp.type == CheckTypes.POSITION: - position_checks.append(resp) - if resp.type == CheckTypes.PROCESS: - process_checks.append(resp) - - return Checks(position=position_checks, process=process_checks) - - @staticmethod - def prefetch_order(event: Event, order: Order, checks: Checks) -> Order: - prefetches = [pref() for pref in [*checks.prefetches, - *PositionCancellationRule.prefetches, - *ProcessCancellationRule.prefetches]] - - related_selects = {*checks.related_selects, - *PositionCancellationRule.related_selects, - *ProcessCancellationRule.related_selects} - - qs = Order.objects.prefetch_related(*prefetches) - if related_selects: - qs = qs.select_related(*related_selects) - - return qs.get(event=event, id=order.id) - @staticmethod def _resolve_date_field_common( date_field: RelativeDateWrapper, diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 0242541a87..9bf11c8745 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -754,9 +754,8 @@ class Order(LockModel, LoggedModel): """ Returns whether or not this order can be canceled by the user. """ - from pretix.base.models.cancellation import Cancellation - res = Cancellation.evaluate(event=self.event, order=self, keep=set(), - check_ts=datetime.now(tz=ZoneInfo(self.event.settings.timezone))) + from ..services.cancellation import evaluate_cancellation + res = evaluate_cancellation(event=self.event, order=self, keep=set(), check_ts=time_machine_now()) return res.cancellation_possible def propose_auto_refunds(self, amount: Decimal, payments: list=None): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 4636cae4f6..cd00b64c29 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -50,7 +50,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import models, transaction from django.db.models import ( - Count, Exists, F, IntegerField, Max, Min, OuterRef, Prefetch, Q, QuerySet, + Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Subquery, Sum, Value, ) from django.db.models.functions import Cast, Greatest @@ -71,9 +71,6 @@ from pretix.base.models import ( Membership, Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher, ) -from pretix.base.models.cancellation import ( - Cancellation, CancellationCheck, CheckResult, CheckTypes, PositionSet, -) from pretix.base.models.event import Event_SettingsStore, SubEvent from pretix.base.models.orders import ( BlockedTicketSecret, CheckoutSession, InvoiceAddress, OrderFee, @@ -106,7 +103,7 @@ from pretix.base.signals import ( order_approved, order_canceled, order_changed, order_denied, order_expired, order_expiry_changed, order_fee_calculation, order_paid, order_placed, order_reactivated, order_split, order_valid_if_pending, periodic_task, - self_service_cancellation_checks, validate_order, + validate_order, ) from pretix.base.timemachine import time_machine_now, time_machine_now_assigned from pretix.celery_app import app @@ -3640,239 +3637,3 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs): 'customer': order.customer_id, } ) - - -def position_not_used_cancellation_check(order: Order, keep: PositionSet, position: OrderPosition, - check_ts: datetime): - for pos in order.positions.all(): - if pos == position and position not in keep: - for checkin in pos.all_checkins.all(): - if checkin.successful and checkin.list.consider_tickets_used: - return CheckResult( - id="pretixbase_position_not_used", - reason=f"Position used in Checkin {checkin}", - cancellation_possible=False, - ) - else: - return CheckResult( - id="pretixbase_position_not_used", - reason="Position not up for cancellation", - cancellation_possible=True, - ) - - return CheckResult( - id="pretixbase_position_not_used", - reason="Ticket not used", - cancellation_possible=True, - ) - - -@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_position_not_used") -def signal_listener_position_not_used(sender: Event, **kwargs): - return CancellationCheck(id="pretixbase_position_not_used", - type=CheckTypes.POSITION, - check_fn=position_not_used_cancellation_check, - prefetches=[ - lambda: Prefetch('all_positions__all_checkins__list', ) - ]) - - -def position_not_blocked_check(order: Order, keep: PositionSet, position: OrderPosition, - check_ts: datetime): - for pos in order.positions.all(): - if pos == position and position not in keep: - return CheckResult( - id="pretixbase_position_not_blocked", - reason="Position is blocked" if pos.blocked else "Position is not blocked", - cancellation_possible=not pos.blocked, - ) - return None - - -@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_position_not_blocked") -def signal_listener_position_not_blocked(sender: Event, **kwargs): - return CancellationCheck(id="pretixbase_position_not_blocked", - type=CheckTypes.POSITION, - check_fn=position_not_blocked_check, - prefetches=[ - lambda: Prefetch('all_positions', ) - ]) - - -def position_giftcard_not_used(order: Order, keep: PositionSet, position: OrderPosition, - check_ts: datetime): - for pos in order.positions.all(): - if pos == position and position not in keep: - if len(pos.issued_gift_cards.all()) > 0: - for gc in pos.issued_gift_cards.all(): - if gc.value != pos.price: - return CheckResult( - id="pretixbase_position_giftcard_not_used", - reason="Issued giftcard was used", - cancellation_possible=False, - ) - return CheckResult( - id="pretixbase_position_giftcard_not_used", - reason="Issued giftcard was not used", - cancellation_possible=True, - ) - return None - - -@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_position_giftcard_not_used") -def signal_listener_position_giftcard_not_used(sender: Event, **kwargs): - return CancellationCheck(id="pretixbase_position_giftcard_not_used", - type=CheckTypes.POSITION, - check_fn=position_giftcard_not_used, - prefetches=[ - lambda: Prefetch('all_positions__issued_gift_cards', ) - ]) - - -def position_membership_not_used(order: Order, keep: PositionSet, position: OrderPosition, - check_ts: datetime): - for pos in order.positions.all(): - if pos == position and position not in keep: - if len(pos.granted_memberships.all()) > 0: - for membership in pos.granted_memberships.all(): - if membership.usages > 0: - return CheckResult( - id="pretixbase_position_membership_not_used", - reason="Membership was already used", - cancellation_possible=False, - ) - return CheckResult( - id="pretixbase_position_giftcard_not_used", - reason="Included Membership was not used", - cancellation_possible=True, - ) - else: - return CheckResult( - id="pretixbase_position_membership_not_used", - reason="No membership included", - cancellation_possible=True, - ) - return None - - -@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_position_membership_not_used") -def signal_position_membership_not_used(sender: Event, **kwargs): - return CancellationCheck(id="pretixbase_position_membership_not_used", - type=CheckTypes.POSITION, - check_fn=position_membership_not_used, - prefetches=[ - lambda: Prefetch('all_positions__granted_memberships', - queryset=Membership.objects.with_usages(), ) - ]) - - -def position_item_allow_cancel(order: Order, keep: PositionSet, position: OrderPosition, - check_ts: datetime): - for pos in order.positions.all(): - if pos == position and position not in keep: - return CheckResult( - id="pretixbase_position_item_allow_cancel", - reason="Item can be canceled" if pos.item.allow_cancel else "Item cannot be canceled", - cancellation_possible=pos.item.allow_cancel, - ) - return None - - -@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_position_item_allow_cancel") -def signal_position_item_allow_cancel(sender: Event, **kwargs): - return CancellationCheck(id="pretixbase_position_item_allow_cancel", - type=CheckTypes.POSITION, - check_fn=position_item_allow_cancel, - prefetches=[ - lambda: Prefetch('all_positions__item') - ]) - - -def process_order_payment_state(order: Order, keep: PositionSet, check_ts: datetime): - if order.status == Order.STATUS_PAID: - if order.total == Decimal("0.00"): - return CheckResult( - id="pretixbase_process_order_paid", - reason="Free orders can be canceled" if order.event.settings.cancel_allow_user else "Free orders cannot be canceled", - cancellation_possible=order.event.settings.cancel_allow_user, - ) - return CheckResult( - id="pretixbase_process_order_paid", - reason="Paid orders can be canceled" if order.event.settings.cancel_allow_user_paid else "Paid orders cannot be canceled", - cancellation_possible=order.event.settings.cancel_allow_user_paid, - ) - elif order.payment_refund_sum > Decimal('0.00'): - return CheckResult( - id="pretixbase_process_order_paid", - reason="Outstanding refund sum prevents further cancellations", - cancellation_possible=False, - ) - elif order.status == Order.STATUS_PENDING: - return CheckResult( - id="pretixbase_process_order_paid", - reason="Pending orders can be canceled" if order.event.settings.cancel_allow_user else "Pending orders cannot be canceled", - cancellation_possible=order.event.settings.cancel_allow_user, - ) - else: - return CheckResult( - id="pretixbase_process_order_paid", - reason="Order is in a state that does not allow cancellations", - cancellation_possible=False, - ) - - -@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_process_order_payment_state") -def signal_listener_process_order_payment_state(sender: Event, **kwargs): - return CancellationCheck(id="pretixbase_process_order_payment_state", - type=CheckTypes.PROCESS, - check_fn=process_order_payment_state, - prefetches=[] - ) - - -def process_cancel_allowed(order: Order, keep: PositionSet, check_ts: datetime): - cancel_allowed = order.cancel_allowed() - - return CheckResult( - id="pretixbase_process_cancel_allowed", - reason="Order allows cancellation" if cancel_allowed else "Order doesn't allow for cancellation", - cancellation_possible=cancel_allowed, - ) - - -@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_process_cancel_allowed") -def signal_listener_process_cancel_allowed(sender: Event, **kwargs): - return CancellationCheck(id="pretixbase_process_process_cancel_allowed", - type=CheckTypes.PROCESS, - check_fn=process_cancel_allowed, - prefetches=[] - ) - - -def process_cancellation_in_progress(order: Order, keep: PositionSet, check_ts: datetime): - if any([c.state not in [Cancellation.PERFORMED, Cancellation.CANCELLED] for c in order.cancellations.all()]): - return CheckResult( - id="pretixbase_process_cancellation_in_progress", - reason="Cancellation request already in progress", - cancellation_possible=False, - ) - - return CheckResult( - id="pretixbase_process_cancellation_in_progress", - reason="No cancellation request in progress", - cancellation_possible=True, - ) - - -@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_process_cancellation_in_progress") -def signal_listener_process_cancellation_in_progress(sender: Event, **kwargs): - return CancellationCheck(id="pretixbase_process_process_cancellation_in_progress", - type=CheckTypes.PROCESS, - check_fn=process_cancellation_in_progress, - prefetches=[] - ) - -# TODO weitere System Checks -# 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 diff --git a/src/tests/base/test_self_service_cancellation.py b/src/tests/base/test_self_service_cancellation.py index 849cd08df2..5d8e1108e9 100644 --- a/src/tests/base/test_self_service_cancellation.py +++ b/src/tests/base/test_self_service_cancellation.py @@ -30,14 +30,19 @@ from django.db.models import Prefetch from django.utils.timezone import now from django_scopes import scope +from pretix.base.cancellation import ( + CancellationCheck, PositionResult, ProcessResult, +) from pretix.base.models import Checkin, Event, Order, OrderPosition, Organizer from pretix.base.models.cancellation import ( - Cancellation, CancellationCheck, CancellationRule, CheckResult, Checks, - CheckTypes, FeeType, PositionCancellationRule, PositionResult, - ProcessCancellationRule, ProcessResult, RuleResult, + CheckResult, CheckTypes, FeeType, PositionCancellationRule, + ProcessCancellationRule, RuleResult, ) from pretix.base.reldate import RelativeDate, RelativeDateWrapper -from pretix.base.services.orders import signal_listener_position_not_used +from pretix.base.services.cancellation import ( + Checks, _prefetch_order, collect_cancellation_checks, + evaluate_cancellation, signal_listener_position_not_used, +) from pretix.helpers import ensure_no_queries @@ -231,7 +236,7 @@ class TestPositionResult: assert pos_res.fee_value == fee def test_position_with_no_rule_results_does_not_raise(self): - # A position that has check results but no matching rule results at all + # A position that has check-results but no matching rule results at all # must not blow up min() on []. check_results = {1: [make_check_result(True)]} rule_results = {1: []} @@ -337,7 +342,7 @@ class TestCancellationRule: return [("", res) for res in received] with raises: - checks = CancellationRule.collect_checks(event=event, send_fn=send_fn) + checks = collect_cancellation_checks(event=event, send_fn=send_fn) for pos in process_checks: assert received[pos] in checks.process @@ -351,7 +356,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 = _prefetch_order(event, order, checks) assert prefetched_order.id == order.id @pytest.mark.django_db @@ -368,7 +373,7 @@ class TestCancellationRule: ) with scope(organizer=event.organizer): - prefetched_order = CancellationRule.prefetch_order(event, order, checks) + prefetched_order = _prefetch_order(event, order, checks) assert prefetched_order.id == order.id class TestChecks: @@ -380,7 +385,7 @@ class TestCancellationRule: keep = set() with scope(organizer=event.organizer): - prefetched_order = CancellationRule.prefetch_order(event, order, checks) + prefetched_order = _prefetch_order(event, order, checks) with ensure_no_queries(): result = position_not_used_check.evaluate(prefetched_order, keep, order_position, datetime.now(tz=UTC)) @@ -391,7 +396,7 @@ class TestCancellationRule: position=order_position, successful=True ) - prefetched_order = CancellationRule.prefetch_order(event, order, checks) + prefetched_order = _prefetch_order(event, order, checks) with ensure_no_queries(): result = position_not_used_check.evaluate(prefetched_order, keep, order_position, @@ -717,7 +722,7 @@ class TestCancellationRule: allowed_until=RelativeDateWrapper(reference_ts - timedelta(days=1))) with scope(organizer=event.organizer): - cancellation = Cancellation.evaluate(event, order, keep=set(), check_ts=check_ts) + cancellation = evaluate_cancellation(event, order, keep=set(), check_ts=check_ts) assert cancellation.cancellation_possible is True