mirror of
https://github.com/pretix/pretix.git
synced 2026-11-10 01:17:07 +00:00
split up models.cancellation.py
This commit is contained in:
1 parent
ad91afff2b
commit
0f167a2294
6 files changed
+32
-690
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user