mirror of
https://github.com/pretix/pretix.git
synced 2026-08-04 09:47:50 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c57c9706e | ||
|
|
cc280d5f6c | ||
|
|
a6b5cef3b6 | ||
|
|
7761387d07 | ||
|
|
2e673b5e49 | ||
|
|
5ab3b08fca | ||
|
|
c624fcfe41 | ||
|
|
7ffadb87b3 | ||
|
|
c1db94dec3 | ||
|
|
9224c73c7f | ||
|
|
1bb2ab28ad | ||
|
|
accfc843d6 |
@@ -0,0 +1,513 @@
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from itertools import chain
|
||||
from typing import (
|
||||
TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Protocol, Set,
|
||||
Tuple, TypeAlias,
|
||||
)
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Prefetch
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import Event, Item, ItemVariation, Order, OrderPosition
|
||||
from pretix.base.reldate import ModelRelativeDateTimeField
|
||||
from pretix.base.signals import self_service_cancellation_checks
|
||||
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
@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":
|
||||
fee = Decimal(0)
|
||||
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([check.prefetches for check in [*self.position, *self.process]]))
|
||||
|
||||
@property
|
||||
def related_selects(self) -> List[str]:
|
||||
return list(chain([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) -> CheckResult:
|
||||
...
|
||||
|
||||
|
||||
class ProcessCheckFn(Protocol):
|
||||
def __call__(self, order: Order, keep: PositionSet) -> 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) -> CheckResult:
|
||||
if position and self.type == CheckTypes.POSITION:
|
||||
return self.check_fn(order, keep, position)
|
||||
elif position is None and self.type == CheckTypes.PROCESS:
|
||||
return self.check_fn(order, keep)
|
||||
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]]
|
||||
|
||||
@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()})
|
||||
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]
|
||||
|
||||
@property
|
||||
def cancellation_possible(self) -> bool:
|
||||
best_option = min(self.process_rule_results)
|
||||
return all([res.cancellation_possible for res in [*self.process_check_results, best_option]])
|
||||
|
||||
@property
|
||||
def fee_value(self) -> Decimal:
|
||||
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
|
||||
|
||||
@property
|
||||
def cancellation_possible(self) -> bool:
|
||||
return self.position_result.cancellation_possible and self.process_result.cancellation_possible
|
||||
|
||||
def remember_cancellation(self):
|
||||
# TODO: store the cancellation verdict in the session storage for X Minutes
|
||||
pass
|
||||
|
||||
def perform_cancellation(self, order: Order, keep: Set[int]):
|
||||
# TODO load the cancellation verdict from the session 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 CancellationRule(models.Model):
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
verbose_name=_("Event"),
|
||||
related_name="cancellation_rule",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
type = models.CharField(
|
||||
verbose_name=_("Type of the cancellation rule"),
|
||||
default=CheckTypes.POSITION,
|
||||
choices=CheckTypes,
|
||||
max_length=15,
|
||||
)
|
||||
|
||||
allowed_until = ModelRelativeDateTimeField(null=True, blank=True)
|
||||
except_after = ModelRelativeDateTimeField(null=True, blank=True)
|
||||
|
||||
prefetches: List[Callable[[], Prefetch]] = []
|
||||
related_selects: List[str] = []
|
||||
|
||||
@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 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 evaluate(event: Event, order: Order, keep: Set[OrderPosition]) -> "CancellationResult":
|
||||
# collect all checks, position_rules and process_rules that are applicable
|
||||
checks = CancellationRule._collect_checks(event=event)
|
||||
position_rules = PositionCancellationRule.objects.filter(event=event, type=CheckTypes.POSITION)
|
||||
process_rules = ProcessCancellationRule.objects.filter(event=event, type=CheckTypes.PROCESS)
|
||||
|
||||
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.id in keep:
|
||||
continue
|
||||
|
||||
# evaluate the system/plugin checks for the position
|
||||
for check in checks.position:
|
||||
position_check_results[position.id].append(check.evaluate(order=order, keep=keep, position=position))
|
||||
|
||||
# evaluate all customer specified rules for this position
|
||||
for rule in position_rules:
|
||||
result = rule.evaluate_position_rule(order=order, keep=keep, position=position)
|
||||
if result is not None:
|
||||
position_rule_results[position.id].append(result)
|
||||
|
||||
position_results = PositionResult(position_check_results=position_check_results,
|
||||
position_rule_results=position_rule_results)
|
||||
|
||||
# we need the current fee_value to select the cheapest process rule
|
||||
temp_position_fees = position_results.fee_value
|
||||
|
||||
# again keep track of all decisions so we can explain them in the logs
|
||||
process_check_results: List[CheckResult] = []
|
||||
process_rule_results: List[RuleResult] = []
|
||||
|
||||
# evaluate all system/plugin provided checks for the cancellation process
|
||||
for check in checks.process:
|
||||
process_check_results.append(check.evaluate(order=order, keep=keep, position=None))
|
||||
|
||||
# evaluate all customer specified rules for the cancellation process
|
||||
for rule in process_rules:
|
||||
result = rule.evaluate_process_rule(order=order, keep=keep, position_fees=temp_position_fees)
|
||||
if result is not None:
|
||||
process_rule_results.append(result)
|
||||
|
||||
process_result = ProcessResult(process_check_results=process_check_results,
|
||||
process_rule_results=process_rule_results)
|
||||
|
||||
return CancellationResult(position_result=position_results, process_result=process_result)
|
||||
|
||||
@staticmethod
|
||||
def _prefetch_order(event: Event, order: Order, checks: Checks) -> Order:
|
||||
prefetches = [pref() for pref in [*chain(*checks.prefetches),
|
||||
*chain(*PositionCancellationRule.prefetches),
|
||||
*chain(*ProcessCancellationRule.prefetches)]]
|
||||
|
||||
related_selects = {*chain(*checks.related_selects),
|
||||
*chain(*PositionCancellationRule.related_selects),
|
||||
*chain(*ProcessCancellationRule.related_selects)}
|
||||
|
||||
order = Order.objects.prefetch_related(*prefetches).select_related(*related_selects).get(event=event,
|
||||
id=order.id)
|
||||
return order
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class PositionCancellationRule(CancellationRule):
|
||||
"""
|
||||
PositionCancellationRules answer the questions:
|
||||
- Can this position be canceled?
|
||||
- What is the price for cancelling this position?
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
fee_percentage_per_position = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
validators=[MinValueValidator("0.00"), MaxValueValidator("100.00")],
|
||||
verbose_name=_("Fee Percentage per OrderPosition"),
|
||||
default=Decimal("0.00"),
|
||||
)
|
||||
fee_absolute_per_position = models.DecimalField(
|
||||
max_digits=13,
|
||||
decimal_places=2,
|
||||
verbose_name=_("Absolute fee per OrderPosition"),
|
||||
default=Decimal("0.00"),
|
||||
)
|
||||
|
||||
all_products = models.BooleanField(
|
||||
verbose_name=_("All products and variations"),
|
||||
default=True,
|
||||
)
|
||||
limit_products = models.ManyToManyField(Item, verbose_name=_("Products"), blank=True)
|
||||
limit_variations = models.ManyToManyField(
|
||||
ItemVariation, blank=True, verbose_name=_("Variations")
|
||||
)
|
||||
|
||||
prefetches: List[Callable[[], Prefetch]] = []
|
||||
related_selects: List[str] = []
|
||||
|
||||
if TYPE_CHECKING:
|
||||
allowed_until = ModelRelativeDateTimeField(null=True, blank=True)
|
||||
except_after = ModelRelativeDateTimeField(null=True, blank=True)
|
||||
|
||||
def evaluate_position_rule(self, order: Order, keep: Set[OrderPosition], position: OrderPosition) -> Optional[
|
||||
RuleResult
|
||||
]:
|
||||
if not self.all_products and position.item_id not in self.limit_products.values_list('pk', flat=True):
|
||||
return None
|
||||
|
||||
if not self.all_products and position.variation_id not in self.limit_variations.values_list('pk', flat=True):
|
||||
return None
|
||||
|
||||
rule_results = [] # TODO really evaluate rules
|
||||
|
||||
if self.fee_percentage_per_position and self.fee_absolute_per_position:
|
||||
raise NotImplementedError(
|
||||
"Combination of fee_percentage_per position and fee_absolute_per_position is not valid")
|
||||
elif self.fee_absolute_per_position != Decimal(0.00):
|
||||
return RuleResult.from_absolute_fee(
|
||||
id=self.id,
|
||||
partial_results=rule_results,
|
||||
fee_type=FeeType.POSITION,
|
||||
absolute_fee=self.fee_absolute_per_position
|
||||
)
|
||||
else:
|
||||
return RuleResult.from_relative_fee(
|
||||
id=self.id,
|
||||
partial_results=rule_results,
|
||||
fee_type=FeeType.POSITION,
|
||||
position_price=position.price,
|
||||
percentage=self.fee_absolute_per_position,
|
||||
currency=order.event.currency
|
||||
)
|
||||
|
||||
|
||||
class ProcessCancellationRule(CancellationRule):
|
||||
"""
|
||||
ProcessCancellationRules answer the question:
|
||||
- What is the processing fee for performing this cancellation?
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
fee_cancellation_process = models.DecimalField(
|
||||
max_digits=13,
|
||||
decimal_places=2,
|
||||
verbose_name=_("Absolute fee per Cancellation"),
|
||||
default=Decimal("0.00"),
|
||||
)
|
||||
|
||||
fee_mode = models.CharField(
|
||||
verbose_name=_("Restrict to check-in status"),
|
||||
default=FeeType.MINIMUM,
|
||||
choices=[
|
||||
(FeeType.MINIMUM, FeeType.MINIMUM.label),
|
||||
(FeeType.ADDITIONAL, FeeType.ADDITIONAL.label),
|
||||
],
|
||||
max_length=15,
|
||||
)
|
||||
|
||||
prefetches: List[Callable[[], Prefetch]] = []
|
||||
related_selects: List[str] = []
|
||||
|
||||
if TYPE_CHECKING:
|
||||
allowed_until = ModelRelativeDateTimeField(null=True, blank=True)
|
||||
except_after = ModelRelativeDateTimeField(null=True, blank=True)
|
||||
|
||||
def evaluate_process_rule(self, order: Order, keep: Set[OrderPosition], position_fees: Decimal) -> \
|
||||
Optional[RuleResult]:
|
||||
|
||||
rule_results = [] # TODO really evaluate rules
|
||||
|
||||
fee_type = self.fee_mode
|
||||
if fee_type not in (FeeType.MINIMUM, FeeType.ADDITIONAL):
|
||||
raise ValueError(f"Unexpected fee_mode: {fee_type!r}")
|
||||
|
||||
return RuleResult.from_process_fee(
|
||||
id=self.id,
|
||||
partial_results=rule_results,
|
||||
fee_type=fee_type,
|
||||
absolute_fee=self.fee_cancellation_process,
|
||||
reference_price=position_fees,
|
||||
)
|
||||
@@ -41,6 +41,7 @@ from collections import Counter, defaultdict, namedtuple
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from functools import reduce
|
||||
|
||||
from time import sleep
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -50,8 +51,8 @@ from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
|
||||
Value,
|
||||
Count, Exists, F, IntegerField, Max, Min, OuterRef, Prefetch, Q, QuerySet,
|
||||
Sum, Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
from django.db.transaction import get_connection
|
||||
@@ -67,10 +68,13 @@ from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import get_language_without_region, language
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry,
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation,
|
||||
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
|
||||
SeatCategoryMapping, User, Voucher,
|
||||
)
|
||||
from pretix.base.models.cancellation import (
|
||||
CancellationCheck, CheckResult, CheckTypes, PositionSet,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
|
||||
@@ -103,7 +107,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,
|
||||
validate_order,
|
||||
self_service_cancellation_checks, validate_order,
|
||||
)
|
||||
from pretix.base.timemachine import time_machine_now, time_machine_now_assigned
|
||||
from pretix.celery_app import app
|
||||
@@ -1619,7 +1623,7 @@ class OrderChangeManager:
|
||||
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
|
||||
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
|
||||
'valid_from', 'valid_until', 'is_bundled', 'result', 'count'))
|
||||
'valid_from', 'valid_until', 'is_bundled', 'result'))
|
||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
|
||||
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
|
||||
@@ -1633,24 +1637,16 @@ class OrderChangeManager:
|
||||
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
|
||||
|
||||
class AddPositionResult:
|
||||
_positions: Optional[List[OrderPosition]]
|
||||
_position: Optional[OrderPosition]
|
||||
|
||||
def __init__(self):
|
||||
self._positions = None
|
||||
self._position = None
|
||||
|
||||
@property
|
||||
def position(self) -> OrderPosition:
|
||||
if self._positions is None:
|
||||
if self._position is None:
|
||||
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
|
||||
if len(self._positions) != 1:
|
||||
raise RuntimeError("More than one position created.")
|
||||
return self._positions[0]
|
||||
|
||||
@property
|
||||
def positions(self) -> List[OrderPosition]:
|
||||
if self._positions is None:
|
||||
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
|
||||
return self._positions
|
||||
return self._position
|
||||
|
||||
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
|
||||
self.order = order
|
||||
@@ -1857,12 +1853,8 @@ class OrderChangeManager:
|
||||
|
||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
|
||||
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
|
||||
valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult':
|
||||
if count < 1:
|
||||
raise ValueError("Count must be positive")
|
||||
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult':
|
||||
if isinstance(seat, str):
|
||||
if count > 1:
|
||||
raise ValueError("Cannot combine count > 1 with seat")
|
||||
if not seat:
|
||||
seat = None
|
||||
else:
|
||||
@@ -1916,14 +1908,14 @@ class OrderChangeManager:
|
||||
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._totaldiff_guesstimate += price.gross * count
|
||||
self._quotadiff.update({q: count for q in new_quotas})
|
||||
self._totaldiff_guesstimate += price.gross
|
||||
self._quotadiff.update(new_quotas)
|
||||
if seat:
|
||||
self._seatdiff.update([seat])
|
||||
|
||||
result = self.AddPositionResult()
|
||||
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
|
||||
valid_from, valid_until, is_bundled, result, count))
|
||||
valid_from, valid_until, is_bundled, result))
|
||||
return result
|
||||
|
||||
def split(self, position: OrderPosition):
|
||||
@@ -2543,35 +2535,29 @@ class OrderChangeManager:
|
||||
secret_dirty.remove(position)
|
||||
position.save(update_fields=['canceled', 'secret'])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
new_pos = []
|
||||
new_logs = []
|
||||
for i in range(op.count):
|
||||
pos = OrderPosition.objects.create(
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
|
||||
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
|
||||
positionid=nextposid, subevent=op.subevent, seat=op.seat,
|
||||
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
|
||||
is_bundled=op.is_bundled,
|
||||
)
|
||||
nextposid += 1
|
||||
new_pos.append(pos)
|
||||
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
|
||||
'position': pos.pk,
|
||||
'item': op.item.pk,
|
||||
'variation': op.variation.pk if op.variation else None,
|
||||
'addon_to': op.addon_to.pk if op.addon_to else None,
|
||||
'price': op.price.gross,
|
||||
'positionid': pos.positionid,
|
||||
'membership': pos.used_membership_id,
|
||||
'subevent': op.subevent.pk if op.subevent else None,
|
||||
'seat': op.seat.pk if op.seat else None,
|
||||
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
|
||||
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
|
||||
}, save=False))
|
||||
|
||||
op.result._positions = new_pos
|
||||
LogEntry.bulk_create_and_postprocess(new_logs)
|
||||
pos = OrderPosition.objects.create(
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
|
||||
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
|
||||
positionid=nextposid, subevent=op.subevent, seat=op.seat,
|
||||
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
|
||||
is_bundled=op.is_bundled,
|
||||
)
|
||||
nextposid += 1
|
||||
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
|
||||
'position': pos.pk,
|
||||
'item': op.item.pk,
|
||||
'variation': op.variation.pk if op.variation else None,
|
||||
'addon_to': op.addon_to.pk if op.addon_to else None,
|
||||
'price': op.price.gross,
|
||||
'positionid': pos.positionid,
|
||||
'membership': pos.used_membership_id,
|
||||
'subevent': op.subevent.pk if op.subevent else None,
|
||||
'seat': op.seat.pk if op.seat else None,
|
||||
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
|
||||
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
|
||||
})
|
||||
op.result._position = pos
|
||||
elif isinstance(op, self.SplitOperation):
|
||||
position = position_cache.setdefault(op.position.pk, op.position)
|
||||
split_positions.append(position)
|
||||
@@ -2896,7 +2882,7 @@ class OrderChangeManager:
|
||||
return total
|
||||
|
||||
def _check_order_size(self):
|
||||
if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
|
||||
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
|
||||
raise OrderError(
|
||||
self.error_messages['max_order_size'] % {
|
||||
'max': settings.PRETIX_MAX_ORDER_SIZE,
|
||||
@@ -2957,7 +2943,7 @@ class OrderChangeManager:
|
||||
]) + len([
|
||||
o for o in self._operations if isinstance(o, self.SplitOperation)
|
||||
])
|
||||
adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)])
|
||||
adds = len([o for o in self._operations if isinstance(o, self.AddOperation)])
|
||||
if current > 0 and current - cancels + adds < 1:
|
||||
raise OrderError(self.error_messages['complete_cancel'])
|
||||
|
||||
@@ -3004,18 +2990,17 @@ class OrderChangeManager:
|
||||
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
|
||||
fake_cart.remove(positions_to_fake_cart[op.position])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
for i in range(op.count):
|
||||
cp = CartPosition(
|
||||
event=self.event,
|
||||
item=op.item,
|
||||
variation=op.variation,
|
||||
used_membership=op.membership,
|
||||
subevent=op.subevent,
|
||||
seat=op.seat,
|
||||
)
|
||||
cp.override_valid_from = op.valid_from
|
||||
cp.override_valid_until = op.valid_until
|
||||
fake_cart.append(cp)
|
||||
cp = CartPosition(
|
||||
event=self.event,
|
||||
item=op.item,
|
||||
variation=op.variation,
|
||||
used_membership=op.membership,
|
||||
subevent=op.subevent,
|
||||
seat=op.seat,
|
||||
)
|
||||
cp.override_valid_from = op.valid_from
|
||||
cp.override_valid_until = op.valid_until
|
||||
fake_cart.append(cp)
|
||||
try:
|
||||
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
|
||||
except ValidationError as e:
|
||||
@@ -3526,3 +3511,42 @@ 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):
|
||||
for pos in order.all_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', )
|
||||
])
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1206,3 +1206,11 @@ This signal is sent out each time the information for a Device is modified.
|
||||
Both the original and updated versions of the Device are included to allow
|
||||
receivers to see what has been updated.
|
||||
"""
|
||||
|
||||
self_service_cancellation_checks = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to collect checks to approve or deny a self service cancellation.
|
||||
You are expected to return a class instance that implements CancellationCheck.
|
||||
It is is expected that that the CheckFn will not issue any further queries.
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
||||
@@ -29,6 +31,8 @@ from django.db.models import (
|
||||
)
|
||||
from django.utils.functional import lazy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DummyRollbackException(Exception):
|
||||
pass
|
||||
@@ -285,3 +289,20 @@ def get_deterministic_ordering(model, ordering):
|
||||
# on the primary key to provide total ordering.
|
||||
ordering.append("-pk")
|
||||
return ordering
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ensure_no_queries():
|
||||
"""
|
||||
Ensures that no database queries are being made in that context.
|
||||
Raises a RuntimeError if running in DEBUG mode, otherwise logs
|
||||
an error.
|
||||
:return:
|
||||
"""
|
||||
def blocker(*args, **kwargs):
|
||||
if settings.DEBUG or "PYTEST_CURRENT_TEST" in os.environ:
|
||||
raise RuntimeError(f"Unexpected DB query: {args[1]}")
|
||||
logger.error("Unexpected DB query: %s", args[1])
|
||||
|
||||
with connection.execute_wrapper(blocker):
|
||||
yield
|
||||
|
||||
@@ -28,9 +28,10 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.logentrytypes import EventLogEntryType, log_entry_types
|
||||
from pretix.base.models import Event, SalesChannel
|
||||
from pretix.base.signals import ( # NOQA: legacy import
|
||||
EventPluginSignal, event_copy_data, item_copy_data, layout_text_variables,
|
||||
logentry_display, logentry_object_link, register_data_exporters,
|
||||
from pretix.base.signals import EventPluginSignal # NOQA: legacy import
|
||||
from pretix.base.signals import (
|
||||
event_copy_data, item_copy_data, layout_text_variables, logentry_display,
|
||||
logentry_object_link, register_data_exporters,
|
||||
register_multievent_data_exporters, register_ticket_outputs,
|
||||
)
|
||||
from pretix.control.signals import item_forms, order_position_buttons
|
||||
@@ -38,9 +39,8 @@ from pretix.plugins.ticketoutputpdf.forms import TicketLayoutItemForm
|
||||
from pretix.plugins.ticketoutputpdf.models import (
|
||||
TicketLayout, TicketLayoutItem,
|
||||
)
|
||||
from pretix.presale.style import ( # NOQA: legacy import
|
||||
get_fonts, register_event_fonts, register_fonts,
|
||||
)
|
||||
from pretix.presale.style import get_fonts # NOQA: legacy import
|
||||
from pretix.presale.style import register_event_fonts, register_fonts
|
||||
|
||||
|
||||
@receiver(register_ticket_outputs, dispatch_uid="output_pdf")
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import contextlib
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from django.db.models import Prefetch
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
from pretix.base.models import (
|
||||
Checkin, Event, Order, OrderPosition, Organizer,
|
||||
)
|
||||
from pretix.base.models.cancellation import (
|
||||
CancellationCheck, CancellationRule, CheckResult, Checks, CheckTypes,
|
||||
FeeType, PositionResult, ProcessResult, RuleResult,
|
||||
)
|
||||
from pretix.base.services.orders import signal_listener_position_not_used
|
||||
from pretix.helpers import ensure_no_queries
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event():
|
||||
o = Organizer.objects.create(name='Dummy', slug='dummy')
|
||||
event = Event.objects.create(
|
||||
organizer=o, name='Dummy', slug='dummy',
|
||||
date_from=now()
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def item(event):
|
||||
return event.items.create(
|
||||
name='Ticket',
|
||||
category=None, default_price=23,
|
||||
admission=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def checkin_list(event):
|
||||
return event.checkin_lists.create(name="foo", consider_tickets_used=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def order(event, item):
|
||||
o = Order.objects.create(
|
||||
code='123456', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING,
|
||||
datetime=now(), expires=now() + timedelta(days=10),
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
total=14, locale='en'
|
||||
)
|
||||
return o
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def order_position(item, order):
|
||||
op = OrderPosition.objects.create(
|
||||
order=order,
|
||||
item=item,
|
||||
variation=None,
|
||||
price=Decimal("14"),
|
||||
)
|
||||
return op
|
||||
|
||||
|
||||
def make_check_result(possible: bool, *, id: str = "chk", reason: str = "") -> CheckResult:
|
||||
return CheckResult(id=id, reason=reason, cancellation_possible=possible)
|
||||
|
||||
|
||||
def make_rule_result(fee, *, possible: bool = True, fee_type: FeeType = FeeType.POSITION, id: int = 1) -> RuleResult:
|
||||
return RuleResult(
|
||||
id=id,
|
||||
partial_results=[make_check_result(possible)],
|
||||
fee_type=fee_type,
|
||||
fee=Decimal(fee),
|
||||
)
|
||||
|
||||
|
||||
def make_cancellation_check(id: str, type: CheckTypes, result: bool, prefetches=[],
|
||||
related_selects=[]) -> CancellationCheck:
|
||||
def position_check_fn(order, keep, position):
|
||||
return make_check_result(result, id=id)
|
||||
|
||||
def check_fn(order, keep):
|
||||
return make_check_result(result, id=id)
|
||||
|
||||
if type == CheckTypes.POSITION:
|
||||
return CancellationCheck(id, type, position_check_fn, prefetches=prefetches, related_selects=related_selects)
|
||||
else:
|
||||
return CancellationCheck(id, type, check_fn, prefetches=prefetches, related_selects=related_selects)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("partial_results,expected", [
|
||||
([True], True),
|
||||
([False], False),
|
||||
([True, True], True),
|
||||
([False, False], False),
|
||||
([True, False], False),
|
||||
])
|
||||
def test_rule_result_cancellation_possible(partial_results, expected):
|
||||
check_results = [make_check_result(state) for state in partial_results]
|
||||
|
||||
result = RuleResult(id=1, partial_results=check_results, fee_type=FeeType.POSITION, fee=Decimal(0))
|
||||
assert result.cancellation_possible == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("left", "right", "expected"),
|
||||
[
|
||||
(("5", True), ("10", True), True),
|
||||
(("10", True), ("5", True), False),
|
||||
(("5", True), ("5", True), False),
|
||||
(("5", False), ("10", False), True),
|
||||
(("100", True), ("1", False), True),
|
||||
(("1", False), ("100", True), False),
|
||||
],
|
||||
ids=[
|
||||
"cheaper-lt-pricier-both-possible",
|
||||
"pricier-not-lt-cheaper-both-possible",
|
||||
"equal-fee-not-lt",
|
||||
"cheaper-lt-pricier-both-impossible",
|
||||
"possible-lt-impossible-despite-higher-fee",
|
||||
"impossible-not-lt-possible",
|
||||
],
|
||||
)
|
||||
def test_lt_returns_expected(left, right, expected):
|
||||
a = make_rule_result(left[0], possible=left[1])
|
||||
b = make_rule_result(right[0], possible=right[1])
|
||||
assert (a < b) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fee_type", "absolute", "reference", "result"),
|
||||
[
|
||||
(FeeType.MINIMUM, Decimal(10), Decimal(1), Decimal(9)),
|
||||
(FeeType.MINIMUM, Decimal(1), Decimal(10), Decimal(0)),
|
||||
(FeeType.MINIMUM, Decimal(10), Decimal(10), Decimal(0)),
|
||||
(FeeType.ADDITIONAL, Decimal(10), Decimal(1), Decimal(10)),
|
||||
(FeeType.ADDITIONAL, Decimal(1), Decimal(10), Decimal(1)),
|
||||
(FeeType.ADDITIONAL, Decimal(10), Decimal(10), Decimal(10)),
|
||||
],
|
||||
ids=[
|
||||
"minimum-absolute-less-than-reference",
|
||||
"minimum-absolute-more-than-reference",
|
||||
"minimum-absolute-equal-reference",
|
||||
"additional-absolute-less-than-reference",
|
||||
"additional-absolute-more-than-reference",
|
||||
"additional-absolute-equal-reference",
|
||||
],
|
||||
)
|
||||
def test_from_process_fee(fee_type: FeeType, absolute: Decimal, reference: Decimal, result: Decimal):
|
||||
res = RuleResult.from_process_fee(id=1, partial_results=[],
|
||||
fee_type=fee_type, absolute_fee=absolute, reference_price=reference)
|
||||
assert res.fee == result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("check_results", "rule_results", "cancellation_possible", "fee"),
|
||||
[
|
||||
({1: [make_check_result(True)]}, {1: [make_rule_result(Decimal(10), possible=True)]}, True, Decimal(10)),
|
||||
({1: [make_check_result(False)]}, {1: [make_rule_result(Decimal(10), possible=True)]}, False, Decimal(10)),
|
||||
({1: [make_check_result(False)]}, {1: [make_rule_result(Decimal(10), possible=False)]}, False, Decimal(0)),
|
||||
({1: [make_check_result(True), make_check_result(False)]}, {1: [make_rule_result(Decimal(10), possible=True)]},
|
||||
False, Decimal(10)),
|
||||
({1: [make_check_result(True)]},
|
||||
{1: [make_rule_result(Decimal(10), possible=False), make_rule_result(Decimal(5), possible=True)]}, True,
|
||||
Decimal(5)),
|
||||
({1: [make_check_result(True)]},
|
||||
{1: [make_rule_result(Decimal(10), possible=False), make_rule_result(Decimal(5), possible=False)]}, False,
|
||||
Decimal(0)),
|
||||
|
||||
],
|
||||
)
|
||||
def test_position_results(check_results, rule_results, cancellation_possible, fee):
|
||||
pos_res = PositionResult(position_check_results=check_results, position_rule_results=rule_results, )
|
||||
assert pos_res.cancellation_possible == cancellation_possible
|
||||
assert pos_res.fee_value == fee
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("check_results", "rule_results", "cancellation_possible", "fee"),
|
||||
[
|
||||
([make_check_result(True)], [make_rule_result(Decimal(10), possible=True)], True, Decimal(10)),
|
||||
([make_check_result(False)], [make_rule_result(Decimal(10), possible=True)], False, Decimal(10)),
|
||||
([make_check_result(False)], [make_rule_result(Decimal(10), possible=False)], False, Decimal(0)),
|
||||
([make_check_result(True), make_check_result(False)], [make_rule_result(Decimal(10), possible=True)],
|
||||
False, Decimal(10)),
|
||||
([make_check_result(True)],
|
||||
[make_rule_result(Decimal(10), possible=False), make_rule_result(Decimal(5), possible=True)], True,
|
||||
Decimal(5)),
|
||||
([make_check_result(True)],
|
||||
[make_rule_result(Decimal(10), possible=False), make_rule_result(Decimal(5), possible=False)], False,
|
||||
Decimal(0)),
|
||||
|
||||
],
|
||||
)
|
||||
def test_process_results(check_results, rule_results, cancellation_possible, fee):
|
||||
pos_res = ProcessResult(process_check_results=check_results, process_rule_results=rule_results, )
|
||||
assert pos_res.cancellation_possible == cancellation_possible
|
||||
assert pos_res.fee_value == fee
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("received", "position_checks", "process_checks", "raises"),
|
||||
[
|
||||
([('', make_cancellation_check('pos-1', CheckTypes.POSITION, True))],
|
||||
[make_cancellation_check('pos-1', CheckTypes.POSITION, True)],
|
||||
[],
|
||||
contextlib.nullcontext()),
|
||||
([('', make_cancellation_check('proc-1', CheckTypes.PROCESS, True))],
|
||||
[],
|
||||
[make_cancellation_check('proc-1', CheckTypes.PROCESS, True)],
|
||||
contextlib.nullcontext()),
|
||||
([('', make_cancellation_check('proc-1', CheckTypes.PROCESS, True)),
|
||||
('', make_cancellation_check('proc-1', CheckTypes.PROCESS, True))],
|
||||
[],
|
||||
[make_cancellation_check('proc-1', CheckTypes.PROCESS, True)],
|
||||
pytest.raises(ValueError)),
|
||||
([('', make_cancellation_check('pos-1', CheckTypes.POSITION, True)),
|
||||
('', make_cancellation_check('pos-1', CheckTypes.POSITION, True))],
|
||||
[],
|
||||
[make_cancellation_check('pos-1', CheckTypes.POSITION, True)],
|
||||
pytest.raises(ValueError)),
|
||||
([('', 1)],
|
||||
[],
|
||||
[make_cancellation_check('pos-1', CheckTypes.POSITION, True)],
|
||||
pytest.raises(ValueError)),
|
||||
]
|
||||
)
|
||||
def test_cancellation_rule_collect_checks(received, position_checks, process_checks, raises):
|
||||
event = cast(Event, cast(object, {}))
|
||||
|
||||
def send_fn(_event):
|
||||
return received
|
||||
|
||||
with raises:
|
||||
checks = CancellationRule._collect_checks(event=event, send_fn=send_fn)
|
||||
assert checks.position == position_checks
|
||||
assert checks.process == process_checks
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_prefetch_empty(event, order):
|
||||
checks = Checks(position=[], process=[])
|
||||
with scope(organizer=event.organizer):
|
||||
prefetched_order = CancellationRule._prefetch_order(event, order, checks)
|
||||
assert prefetched_order.id == order.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_prefetch_incl_values_select_related(event, order):
|
||||
checks = Checks(
|
||||
position=[
|
||||
make_cancellation_check('pos_1', CheckTypes.POSITION, True, prefetches=[lambda: Prefetch('all_positions')],
|
||||
related_selects=['organizer'])],
|
||||
process=[
|
||||
make_cancellation_check('proc_1', CheckTypes.PROCESS, True, prefetches=[lambda: Prefetch('all_positions')],
|
||||
related_selects=['organizer'])]
|
||||
)
|
||||
|
||||
with scope(organizer=event.organizer):
|
||||
prefetched_order = CancellationRule._prefetch_order(event, order, checks)
|
||||
assert prefetched_order.id == order.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ticket_not_used(event, order, order_position, checkin_list, settings):
|
||||
position_not_used_check = signal_listener_position_not_used(event)
|
||||
checks = Checks(position=[position_not_used_check], process=[])
|
||||
keep = set()
|
||||
|
||||
with scope(organizer=event.organizer):
|
||||
prefetched_order = CancellationRule._prefetch_order(event, order, checks)
|
||||
|
||||
with ensure_no_queries():
|
||||
result = position_not_used_check.evaluate(prefetched_order, keep, order_position)
|
||||
|
||||
assert result.cancellation_possible is True
|
||||
|
||||
with scope(organizer=event.organizer):
|
||||
Checkin.objects.create(
|
||||
list=checkin_list,
|
||||
position=order_position,
|
||||
successful=True
|
||||
)
|
||||
|
||||
prefetched_order = CancellationRule._prefetch_order(event, order, checks)
|
||||
with scopes_disabled():
|
||||
with ensure_no_queries():
|
||||
result = position_not_used_check.evaluate(prefetched_order, keep, order_position)
|
||||
|
||||
assert result.cancellation_possible is False
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Tests for the ``ensure_no_queries`` context manager.
|
||||
|
||||
No mocking is used: every test runs against a real database connection via the
|
||||
``django_db`` marker. ``settings.DEBUG`` is toggled through pytest-django's
|
||||
``settings`` fixture, which restores the original value after each test.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.helpers.database import ensure_no_queries
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_raises_runtime_error_in_debug(settings):
|
||||
settings.DEBUG = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="Unexpected DB query"):
|
||||
with scopes_disabled():
|
||||
with ensure_no_queries():
|
||||
Event.objects.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_logs_error_when_not_debug(settings, caplog):
|
||||
settings.DEBUG = False
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with scopes_disabled():
|
||||
with ensure_no_queries():
|
||||
Event.objects.exists()
|
||||
|
||||
assert any(
|
||||
record.levelno == logging.ERROR
|
||||
and "Unexpected DB query" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_no_error_without_queries(settings, caplog):
|
||||
settings.DEBUG = True
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with ensure_no_queries():
|
||||
result = sum(range(10)) # pure Python, no DB access
|
||||
|
||||
assert result == 45
|
||||
assert "Unexpected DB query" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_queries_allowed_after_context(settings):
|
||||
settings.DEBUG = True
|
||||
|
||||
with ensure_no_queries():
|
||||
pass
|
||||
|
||||
with scopes_disabled():
|
||||
assert Event.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_blocker_removed_even_after_exception(settings):
|
||||
settings.DEBUG = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="Unexpected DB query"):
|
||||
with scopes_disabled():
|
||||
with ensure_no_queries():
|
||||
Event.objects.exists()
|
||||
|
||||
with scopes_disabled():
|
||||
assert Event.objects.count() == 0
|
||||
Reference in New Issue
Block a user