Compare commits

...

8 Commits

Author SHA1 Message Date
Lukas Bockstaller
2e673b5e49 wip 2026-04-15 11:48:32 +02:00
Lukas Bockstaller
5ab3b08fca cleanup
# Conflicts:
#	src/pretix/base/services/orders.py
2026-04-15 11:47:13 +02:00
Lukas Bockstaller
c624fcfe41 move checks to classes and add distinction between process_fees and position fees 2026-04-15 11:15:01 +02:00
Lukas Bockstaller
7ffadb87b3 Apply suggestions from code review
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2026-04-15 11:15:01 +02:00
Lukas Bockstaller
c1db94dec3 clean up 2026-04-15 11:15:01 +02:00
Lukas Bockstaller
9224c73c7f wip 2026-04-15 11:15:01 +02:00
Lukas Bockstaller
1bb2ab28ad wip 2026-04-15 11:15:01 +02:00
Lukas Bockstaller
accfc843d6 wip 2026-04-15 11:15:00 +02:00
5 changed files with 687 additions and 70 deletions

View File

@@ -0,0 +1,234 @@
from dataclasses import dataclass
from decimal import Decimal
from functools import reduce
from typing import Callable, Dict, List, Literal, Optional, Set
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.timemachine import time_machine_now
@dataclass(frozen=True)
class CancellationCheckResult:
cancellation_possible: bool
reason: str
# Maps Check identifier → cancellation check result
CancellationCheckResultsById = Dict[str, CancellationCheckResult]
CheckFn = Callable[[Order, Set[OrderPosition], Optional[OrderPosition]], CancellationCheckResultsById]
FeeType = Literal['position_fee', 'process_fee']
class Ruling:
"""
A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition.
"""
rule_id: int
results: CancellationCheckResultsById
fee_type: FeeType
fee: Decimal
cancellation_possible: bool
def __init__(
self,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: FeeType,
fee: Decimal
):
self.rule_id = rule_id
self.results = results
self.fee_type = fee_type
self.fee = fee
self.cancellation_possible = all(ruling.cancellation_possible for ruling in results.values())
@classmethod
def from_absolute_fee(
cls,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: FeeType,
absolute_fee: Decimal
) -> "Ruling":
"""
Constructs a Ruling with an absolute fee.
:param rule_id: Id of the rule
:param results: CheckResult object
:param fee_type: If the fee is calculated for a position or process fee
:param absolute_fee: amount of the fee
:return:
"""
return Ruling(rule_id=rule_id, results=results, fee_type=fee_type, fee=absolute_fee)
@classmethod
def from_relative_fee(
cls,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: Literal['position_fee'],
reference_price: Decimal,
percentage: Decimal,
currency: str
) -> "Ruling":
"""
Constructs a Ruling with an absolute fee.
:param rule_id: ID of the rule
:param results: CheckResult object
:param fee_type: Must be a position_fee as the fee can only be in reference to a position
:param reference_price: Price of the position to reference
:param percentage: Percentage of the reference_price set as the fee
:param currency: Currency of the reference_price, used for correct rounding of the fee
:return:
"""
if fee_type == "process_fee":
raise ValidationError("Process fee cannot be used with relative fees")
return Ruling(
rule_id=rule_id,
results=results,
fee_type=fee_type,
fee=round_decimal(reference_price * (percentage / 100), currency)
)
def __lt__(self, other):
if not isinstance(other, Ruling):
return NotImplemented
if self.fee_type != other.fee_type:
return NotImplemented
if self.cancellation_possible == other.cancellation_possible:
return self.fee < other.fee
else:
return self.cancellation_possible and not other.cancellation_possible
class CancellationRule(models.Model):
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="orders",
on_delete=models.CASCADE
)
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")
)
allowed_until = ModelRelativeDateTimeField(null=True, blank=True)
except_after = ModelRelativeDateTimeField(null=True, blank=True)
fee_percentage_per_item = 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"),
) # wird als sum() kombiniert
fee_absolute_per_item = models.DecimalField(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute fee per OrderPosition"),
default=Decimal("0.00"),
) # wird als sum() kombiniert
fee_cancellation_process = models.DecimalField(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute fee per Cancellation"),
default=Decimal("0.00"),
) # wird als max() kombiniert
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.checks: List[CheckFn] = [self._check_time_window]
# TODO implement order status check
def _check_time_window(self, order: Order, keep: Set[OrderPosition], position: OrderPosition) -> CancellationCheckResultsById:
check_id = "TIME_WINDOW"
if not self.allowed_until and not self.allowed_until:
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason="No time window specified",
)}
relevant_event = position.subevent or position.event
in_allowed_until = time_machine_now() < self.allowed_until.datetime(
relevant_event) if self.allowed_until else False
in_exemption = time_machine_now() > self.except_after.datetime(
relevant_event) if self.except_after else False
if in_allowed_until and not in_exemption:
except_after_message = f" and not after {self.except_after.datetime(relevant_event)}" if self.except_after else ""
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Cancellation in required time window before {self.allowed_until.datetime(relevant_event)}{except_after_message}",
)}
elif in_allowed_until and in_exemption:
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}",
)}
else:
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Cancellation after time window ending on {self.allowed_until.datetime(relevant_event)}",
)}
def check(self, system_check_results: List[CancellationCheckResult], order: Order, keep: Set[OrderPosition], position: OrderPosition) -> Optional[Ruling]:
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
check_results = [check(order, keep, position) for check in self.checks]
if self.fee_percentage_per_item and self.fee_absolute_per_item:
raise NotImplementedError("Should never be reached")
elif self.fee_absolute_per_item != Decimal(0.00):
return Ruling.from_absolute_fee(
rule_id=self.id,
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
fee_type='position_fee',
absolute_fee=self.fee_absolute_per_item
)
else:
return Ruling.from_relative_fee(
rule_id=self.id,
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
fee_type='position_fee',
reference_price=position.price,
percentage=self.fee_absolute_per_item,
currency=order.event.currency
)
class CancellationCheck:
id: str
prefetches: List[Prefetch] = []
related_selects: List[str] = []
def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResult:
raise NotImplementedError()

View File

@@ -41,8 +41,9 @@ 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 List, Optional
from typing import Dict, List, Optional, Set
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
@@ -50,9 +51,10 @@ 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 import Prefetch
from django.db.models.functions import Coalesce, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
@@ -67,10 +69,14 @@ 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, Checkin, Device, Event, GiftCard, Item, ItemVariation,
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher,
)
from pretix.base.models.cancellation import (
CancellationCheckResult, CancellationCheckResultsById, CancellationRule,
Ruling, CancellationCheck
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
@@ -103,11 +109,11 @@ 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
from pretix.helpers import OF_SELF
from pretix.helpers import OF_SELF, ensure_no_queries
from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval
from pretix.testutils.middleware import debugflags_var
@@ -1618,7 +1624,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'))
@@ -1632,24 +1638,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
@@ -1856,12 +1854,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:
@@ -1915,14 +1909,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):
@@ -2542,35 +2536,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)
@@ -2895,7 +2883,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,
@@ -2956,7 +2944,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'])
@@ -3003,18 +2991,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:
@@ -3525,3 +3512,154 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
'customer': order.customer_id,
}
)
class OrderPositionNotUsedCheck(CancellationCheck):
id = "SYSTEM_TICKET_NOT_USED"
prefetches = [
Prefetch(
'checkins',
queryset=Checkin.objects.filter(list__consider_tickets_used=True),
to_attr='used_checkins' # stores result in a list attribute
)
]
related_selects = []
def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResultsById:
if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason="Order position was used",
)}
else:
return {self.id: CancellationCheckResult(
cancellation_possible=True,
reason="Order position not yet used",
)}
@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_used")
def cancellation_checks_not_used(sender: Event):
return OrderPositionNotUsedCheck()
class NotDiscountedCheck(CancellationCheck):
"""
Check that ensures that orders containing discounted order_positions cannot
be canceled partially.
This is a stop-gap solution until the `discount_grouper` attribute for
AbstractPositions is introduced, allowing us to be more grannular
"""
id = "SYSTEM_NO_DISCOUNTED_ORDER_POSITIONS"
prefetches = [
]
related_selects = []
def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResultsById:
cancellations = Set(order.positions).difference(keep)
if order_position in cancellations:
if order_position.discount_id is None:
return {self.id: CancellationCheckResult(
cancellation_possible=True,
reason=_("Order position was bought without discount"),
)}
else:
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason=_("Order position was bought with a discount"),
)}
else:
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason=_("Order position not canceled - check not applicable"),
)}
@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_discountend")
def cancellation_checks_not_discounted(sender: Event):
return NotDiscountedCheck()
# 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

View File

@@ -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.
"""

View File

@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import contextlib
import logging
from django.conf import settings
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
@@ -29,6 +30,8 @@ from django.db.models import (
)
from django.utils.functional import lazy
logger = logging.getLogger(__name__)
class DummyRollbackException(Exception):
pass
@@ -280,3 +283,21 @@ 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:
raise RuntimeError(f"Unexpected DB query: {args[0]}")
else:
logger.error("Unexpected DB query: %s", args[0])
with connection.execute_wrapper(blocker):
yield

View File

@@ -0,0 +1,216 @@
from datetime import date, datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
import pytest
from django.utils.timezone import make_aware, now
from django_scopes import scope
from freezegun import freeze_time
from pretix.base.models import Event, Item, Order, OrderPosition, Organizer
from pretix.base.models.cancellation import CancellationRule, OrderDiff
NOW = now()
DAYS_UNTIL_EVENT=60
EVENT_START = NOW+timedelta(days=DAYS_UNTIL_EVENT)
@pytest.fixture()
def event():
o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=EVENT_START,
plugins='pretix.plugins.banktransfer'
)
return event
@pytest.fixture()
def item1(event):
return Item.objects.create(event=event, name='Early-bird item1',
default_price=Decimal('23.00'), admission=True)
@pytest.fixture()
def order(event):
return Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, locale='en',
datetime=NOW,
total=0,
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
@pytest.mark.django_db
def test_status_rule(event, item1, order):
with scope(organizer=event.organizer, event=event):
op = OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_if_in_order_status=Order.STATUS_PENDING
)
cancellation_rule.items.set([item1])
diff = OrderDiff.cancel_all(order)
assert cancellation_rule._check_order_status(diff=diff, order_position=op) == {
'ORDER_STATUS': CheckRes(
cancellation_possible=True,
reason="Order in required status: 'n'",
),
}
cancellation_rule = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_if_in_order_status=Order.STATUS_PAID
)
cancellation_rule.items.set([item1])
assert cancellation_rule._check_order_status(diff=diff, order_position=op) == {
'ORDER_STATUS': CheckRes(
cancellation_possible=False,
reason="Order in status 'n' cannot be canceled",
),
}
@pytest.mark.django_db
def test_timing(event, item1, order):
with scope(organizer=event.organizer, event=event):
order.status = Order.STATUS_PAID
order.save()
OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
cr1 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=now() + timedelta(hours=1),
)
cr1.items.set([item1])
diff = OrderDiff.cancel_all(order)
with freeze_time(now()):
possible, verdicts = CancellationRule.objects.all().cancellation_possible(diff)
assert possible == True
with freeze_time(now()+timedelta(hours=2)):
possible, verdicts=CancellationRule.objects.all().cancellation_possible(diff)
assert possible == False
@pytest.mark.django_db
def test_multiple_limits(event, item1, order):
with (scope(organizer=event.organizer, event=event)):
order.status = Order.STATUS_PAID
order.save()
OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("100.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
# free in the first hour after booking
cr1=CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=NOW + timedelta(hours=1),
)
cr1.items.set([item1])
# free until 30 days before event
cr2 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=30),
)
cr2.items.set([item1])
# 50% until 14 days before event
cr3 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=14),
fee_percentage_per_item=Decimal(50.0)
)
cr3.items.set([item1])
# 80% until 7 days before event
cr4 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=7),
fee_percentage_per_item=Decimal(80.0)
)
cr4.items.set([item1])
# 100% until 1 day before event
cr5 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=EVENT_START - timedelta(days=1),
fee_percentage_per_item=Decimal(100)
)
cr5.items.set([item1])
# Cancellation is not allowed at all, but rule doesn't match the item
CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_until=NOW,
fee_percentage_per_item=Decimal(100)
)
diff = OrderDiff.cancel_all(order)
possible_trace = []
cost_trace = []
for days in range(DAYS_UNTIL_EVENT):
today = NOW + timedelta(days=days)
with freeze_time(today):
possible, verdicts=CancellationRule.objects.all().cancellation_possible(
diff)
possible_trace.append(possible)
cost_trace.append(verdicts[0].total_fee)
assert possible_trace == [True] * 59 + [False]
assert cost_trace == [Decimal("0.0000")] * 30 + \
[Decimal("50.0000")] * 16 + \
[Decimal("80.0000")] * 7 + \
[Decimal("100.0000")] * 6 + \
[Decimal("0.0000")]
@pytest.mark.django_db
def test_cancellation_rule_query_set(event, item1, order):
with scope(organizer=event.organizer, event=event):
OrderPosition.objects.create(
order=order, item=item1, variation=None,
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
cr1 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_if_in_order_status=Order.STATUS_PENDING, fee_absolute_per_order=Decimal('10.00'),
)
cr1.items.set([item1])
cr2 = CancellationRule.objects.create(
organizer=event.organizer, event=event,
allowed_if_in_order_status=Order.STATUS_PAID
)
cr2.items.set([item1])
diff = OrderDiff.cancel_all(order)
possible, verdicts = CancellationRule.objects.all().cancellation_possible(diff)
assert possible == True