mirror of
https://github.com/pretix/pretix.git
synced 2026-05-14 16:44:06 +00:00
Compare commits
8 Commits
fix-orderl
...
self-servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e673b5e49 | ||
|
|
5ab3b08fca | ||
|
|
c624fcfe41 | ||
|
|
7ffadb87b3 | ||
|
|
c1db94dec3 | ||
|
|
9224c73c7f | ||
|
|
1bb2ab28ad | ||
|
|
accfc843d6 |
234
src/pretix/base/models/cancellation.py
Normal file
234
src/pretix/base/models/cancellation.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
216
src/tests/base/test_self_service_cancellation.py
Normal file
216
src/tests/base/test_self_service_cancellation.py
Normal 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
|
||||
Reference in New Issue
Block a user