split up models.cancellation.py

This commit is contained in:
Lukas Bockstaller committed 2026-10-27 10:20:46 +01:00
1 parent ad91afff2b
commit 0f167a2294
6 files changed
+32 -690

No files matched your search

+4 -1
View File
@@ -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
+1
View File
@@ -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
+7 -434
View File
@@ -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,
+2 -3
View File
@@ -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):
+2 -241
View File
@@ -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
@@ -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