mirror of
https://github.com/pretix/pretix.git
synced 2026-08-15 11:36:27 +00:00
tests
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from itertools import chain
|
||||
from typing import Callable, Dict, List, Literal, Optional, Protocol, Set, TYPE_CHECKING, TypeAlias
|
||||
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
|
||||
@@ -18,9 +20,9 @@ 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
|
||||
2. charge the customer the appropriate fees for their cancellation
|
||||
|
||||
Number 1 is a question of bringing enough checks into place and prevent a
|
||||
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.
|
||||
@@ -33,26 +35,26 @@ 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 unfeasable runtime behaviour, and if we would optimize it in difficult to explain non-optimal
|
||||
situations.
|
||||
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
|
||||
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")
|
||||
@@ -84,7 +86,7 @@ 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` as well as
|
||||
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
|
||||
@@ -139,13 +141,15 @@ class RuleResult:
|
||||
if reference_price < absolute_fee:
|
||||
fee = absolute_fee - reference_price
|
||||
else:
|
||||
fee = reference_price
|
||||
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):
|
||||
def __lt__(self, other: object) -> bool:
|
||||
if not isinstance(other, RuleResult):
|
||||
return NotImplemented
|
||||
|
||||
@@ -168,20 +172,25 @@ class Checks:
|
||||
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: ...
|
||||
def __call__(self, order: Order, keep: PositionSet, position: OrderPosition) -> CheckResult:
|
||||
...
|
||||
|
||||
|
||||
class ProcessCheckFn(Protocol):
|
||||
def __call__(self, order: Order, keep: PositionSet) -> CheckResult: ...
|
||||
def __call__(self, order: Order, keep: PositionSet) -> CheckResult:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CancellationCheck(ABC):
|
||||
class CancellationCheck:
|
||||
id: str
|
||||
type: CheckTypes
|
||||
check_fn: PositionCheckFn | ProcessCheckFn
|
||||
check_fn: PositionCheckFn | ProcessCheckFn = field(compare=False)
|
||||
prefetches: List[Callable[[], Prefetch]] = field(default_factory=list)
|
||||
related_selects: List[str] = field(default_factory=list)
|
||||
|
||||
@@ -200,22 +209,24 @@ 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) -> bool:
|
||||
return results[0].cancellation_possible if results else True
|
||||
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, self.position_rule_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:
|
||||
results.sort()
|
||||
best_option = results[0]
|
||||
best_option = min(results)
|
||||
if best_option.cancellation_possible:
|
||||
fee_value += best_option.fee
|
||||
return fee_value
|
||||
@@ -226,8 +237,17 @@ class ProcessResult:
|
||||
process_check_results: List[CheckResult]
|
||||
process_rule_results: List[RuleResult]
|
||||
|
||||
@property
|
||||
def cancellation_possible(self) -> bool:
|
||||
return all([res.cancellation_possible for res in [*self.process_check_results, *self.process_rule_results]])
|
||||
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)
|
||||
@@ -235,8 +255,9 @@ 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()
|
||||
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
|
||||
@@ -247,6 +268,10 @@ class CancellationResult:
|
||||
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,
|
||||
@@ -269,13 +294,15 @@ class CancellationRule(models.Model):
|
||||
related_selects: List[str] = []
|
||||
|
||||
@staticmethod
|
||||
def _collect_checks(event: Event) -> Checks:
|
||||
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 self_service_cancellation_checks.send(sender=event):
|
||||
if not isinstance(recv, CancellationCheck):
|
||||
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')
|
||||
@@ -295,15 +322,7 @@ class CancellationRule(models.Model):
|
||||
position_rules = PositionCancellationRule.objects.filter(event=event, type=CheckTypes.POSITION)
|
||||
process_rules = ProcessCancellationRule.objects.filter(event=event, type=CheckTypes.PROCESS)
|
||||
|
||||
# prefetch and join everything these rules want
|
||||
prefetches = [*checks.prefetches,
|
||||
*PositionCancellationRule.prefetches,
|
||||
*ProcessCancellationRule.prefetches]
|
||||
related_selects = list(set(*checks.related_selects,
|
||||
*PositionCancellationRule.related_selects,
|
||||
*ProcessCancellationRule.related_selects))
|
||||
order = Order.objects.prefetch_related(*prefetches).select_related(*related_selects).get(event=event,
|
||||
id=order.id)
|
||||
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]] = {}
|
||||
@@ -332,7 +351,7 @@ class CancellationRule(models.Model):
|
||||
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()
|
||||
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] = []
|
||||
@@ -353,6 +372,20 @@ class CancellationRule(models.Model):
|
||||
|
||||
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)
|
||||
|
||||
@@ -398,7 +431,8 @@ class PositionCancellationRule(CancellationRule):
|
||||
except_after = ModelRelativeDateTimeField(null=True, blank=True)
|
||||
|
||||
def evaluate_position_rule(self, order: Order, keep: Set[OrderPosition], position: OrderPosition) -> Optional[
|
||||
RuleResult]:
|
||||
RuleResult
|
||||
]:
|
||||
if not self.all_products and position.item_id not in self.limit_products.values_list('pk', flat=True):
|
||||
return None
|
||||
|
||||
|
||||
@@ -41,16 +41,19 @@ from collections import Counter, defaultdict, namedtuple
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from functools import reduce
|
||||
from itertools import chain
|
||||
|
||||
from time import sleep
|
||||
from typing import Dict, List, Optional, Set
|
||||
from typing import List, Optional
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
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
|
||||
from django.db.models import (
|
||||
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
|
||||
from django.dispatch import receiver
|
||||
@@ -69,14 +72,16 @@ from pretix.base.models import (
|
||||
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
|
||||
SeatCategoryMapping, User, Voucher,
|
||||
)
|
||||
from pretix.base.models.cancellation import (CancellationCheck, CancellationRule)
|
||||
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,
|
||||
generate_secret,
|
||||
)
|
||||
from pretix.base.models.organizer import SalesChannel, TeamAPIToken
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxRule, TaxedPrice
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
from pretix.base.payment import GiftCardPayment, PaymentException
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.secrets import assign_ticket_secret
|
||||
@@ -106,7 +111,7 @@ from pretix.base.signals import (
|
||||
)
|
||||
from pretix.base.timemachine import time_machine_now, time_machine_now_assigned
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF, ensure_no_queries
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.models import modelcopy
|
||||
from pretix.helpers.periodic import minimum_interval
|
||||
from pretix.testutils.middleware import debugflags_var
|
||||
@@ -3508,88 +3513,40 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
# TODO transaktion
|
||||
def self_service_cancel(order: Order, keep: Set[OrderPosition], dry_run: bool):
|
||||
"""
|
||||
|
||||
:param order:
|
||||
:param keep:
|
||||
:param dry_run:
|
||||
:return:
|
||||
"""
|
||||
cancellation_checks: List[CancellationCheck] = [resp for recv, resp in self_service_cancellation_checks.send(event=order.event)]
|
||||
|
||||
position_rules = CancellationRule.objects.filter(event=order.event).filter("fee_cancellation_process" == Decimal("0.00")).all()
|
||||
process_rules = CancellationRule.objects.filter(event=order.event).filter("fee_cancellation_process" != Decimal("0.00")).all()
|
||||
|
||||
# Todo get prefetches/selects from rules as well
|
||||
prefetches = list(chain.from_iterable([cc.prefetches for cc in cancellation_checks]))
|
||||
related_selects = list(chain.from_iterable(cc.related_selects for cc in cancellation_checks))
|
||||
|
||||
per_position_rulings: Dict[int, List[Ruling]] = {}
|
||||
|
||||
prefetched_order = Order.objects.select_related(related_selects).prefetch_related(*prefetches).get(pk=order.pk)
|
||||
# All queries should be done by now
|
||||
with ensure_no_queries():
|
||||
for position in prefetched_order.positions:
|
||||
position_rulings = []
|
||||
|
||||
system_check_results = [cc.check(prefetched_order, keep, position) for cc in cancellation_checks]
|
||||
|
||||
for rule in position_rules:
|
||||
check_results = [check(prefetched_order, keep, position) for check in rule.checks]
|
||||
|
||||
if rule.fee_percentage_per_item and rule.fee_absolute_per_item:
|
||||
raise NotImplementedError("Should never be reached")
|
||||
elif rule.fee_absolute_per_item != Decimal(0.00):
|
||||
position_rulings.append(
|
||||
Ruling.from_absolute_fee(
|
||||
rule_id=rule.id,
|
||||
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
|
||||
fee_type='position_fee',
|
||||
absolute_fee=rule.fee_absolute_per_item
|
||||
)
|
||||
)
|
||||
else:
|
||||
position_rulings.append(
|
||||
Ruling.from_relative_fee(
|
||||
rule_id=rule.id,
|
||||
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
|
||||
fee_type='position_fee',
|
||||
reference_price=position.price,
|
||||
percentage=rule.fee_absolute_per_item,
|
||||
currency=order.event.currency
|
||||
)
|
||||
)
|
||||
position_rulings.sort()
|
||||
per_position_rulings[position.id] = position_rulings
|
||||
effective_position_rulings = [op_rulings[0] for op_rulings in per_position_rulings.values()]
|
||||
|
||||
process_rulings: List[Ruling] = []
|
||||
|
||||
for rule in process_rules:
|
||||
check_results = [check(prefetched_order, keep, position) for check in rule.checks]
|
||||
|
||||
process_rulings.append(Ruling.from_absolute_fee(
|
||||
rule_id=rule.id,
|
||||
results=reduce(lambda a, b: a | b, [*check_results], {}),
|
||||
fee_type='process_fee',
|
||||
absolute_fee=rule.fee_cancellation_process
|
||||
))
|
||||
|
||||
process_rulings.sort()
|
||||
|
||||
effective_process_ruling = process_rulings[0]
|
||||
|
||||
cancellation_possible = all([r.cancellation_possible for r in effective_position_rulings])
|
||||
|
||||
# TODO zusammenführen der Rulings
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
||||
@@ -299,10 +300,9 @@ def ensure_no_queries():
|
||||
:return:
|
||||
"""
|
||||
def blocker(*args, **kwargs):
|
||||
if settings.DEBUG:
|
||||
raise RuntimeError(f"Unexpected DB query: {args[0]}")
|
||||
else:
|
||||
logger.error("Unexpected DB query: %s", args[0])
|
||||
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")
|
||||
|
||||
@@ -1,25 +1,98 @@
|
||||
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.cancellation import CheckResult, FeeType, RuleResult
|
||||
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
|
||||
|
||||
|
||||
def make_check(possible: bool, *, id: str = "chk", reason: str = "") -> CheckResult:
|
||||
@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(fee, *, possible: bool = True, fee_type: FeeType = FeeType.POSITION, id: int = 1) -> RuleResult:
|
||||
# ``cancellation_possible`` is derived from the partial check results, so we
|
||||
# attach a single passing/failing check to control it deterministically.
|
||||
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(possible)],
|
||||
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),
|
||||
@@ -28,7 +101,7 @@ def make_rule(fee, *, possible: bool = True, fee_type: FeeType = FeeType.POSITIO
|
||||
([True, False], False),
|
||||
])
|
||||
def test_rule_result_cancellation_possible(partial_results, expected):
|
||||
check_results = [make_check(state) for state in partial_results]
|
||||
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
|
||||
@@ -37,12 +110,12 @@ def test_rule_result_cancellation_possible(partial_results, expected):
|
||||
@pytest.mark.parametrize(
|
||||
("left", "right", "expected"),
|
||||
[
|
||||
(("5", True), ("10", True), True), # cheaper fee is "less"
|
||||
(("10", True), ("5", True), False), # pricier fee is not "less"
|
||||
(("5", True), ("5", True), False), # equal fee is not "less"
|
||||
(("5", False), ("10", False), True), # cheaper is "less" when both impossible
|
||||
(("100", True), ("1", False), True), # possible ranks below impossible...
|
||||
(("1", False), ("100", True), False), # ...and impossible never below possible
|
||||
(("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",
|
||||
@@ -54,6 +127,169 @@ def test_rule_result_cancellation_possible(partial_results, expected):
|
||||
],
|
||||
)
|
||||
def test_lt_returns_expected(left, right, expected):
|
||||
a = make_rule(left[0], possible=left[1])
|
||||
b = make_rule(right[0], possible=right[1])
|
||||
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