mirror of
https://github.com/pretix/pretix.git
synced 2026-05-13 16:33:59 +00:00
move checks to classes and add distinction between process_fees and position fees
This commit is contained in:
@@ -2,42 +2,33 @@ import dataclasses
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from functools import reduce
|
||||
from typing import Callable, Dict, List, Set, Union
|
||||
from typing import Callable, Dict, List, Literal, Optional, Set, Union
|
||||
|
||||
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.dispatch import receiver
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import Checkin
|
||||
from pretix.base.models import Event, Order, OrderPosition
|
||||
from pretix.base.models import Item
|
||||
from pretix.base.models import ItemVariation
|
||||
from pretix.base.reldate import ModelRelativeDateTimeField
|
||||
from pretix.base.services.orders import CancellationCheck
|
||||
from pretix.base.signals import self_service_cancellation_checks
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AbsoluteFee:
|
||||
amount: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RelativeFee:
|
||||
reference_price: Decimal
|
||||
percentage: Decimal
|
||||
|
||||
@property
|
||||
def amount(self):
|
||||
return self.reference_price * (self.percentage/100)
|
||||
|
||||
|
||||
Fee=Union[AbsoluteFee, RelativeFee]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderDiff:
|
||||
order: Order
|
||||
prev: Set[OrderPosition]
|
||||
next: Set[OrderPosition]
|
||||
keep: Set[OrderPosition]
|
||||
|
||||
def cancellations(self):
|
||||
return self.prev.difference(self.next)
|
||||
@@ -47,75 +38,97 @@ class OrderDiff:
|
||||
return OrderDiff(order=order, prev=set(order.positions.all()), next=set())
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckRes:
|
||||
class CancellationCheckResult:
|
||||
cancellation_possible: bool
|
||||
reason: str
|
||||
|
||||
CheckResult=Dict[str, CheckRes]
|
||||
# Maps Check identifier → cancellation check result
|
||||
CancellationCheckResultsById = Dict[str, CancellationCheckResult]
|
||||
CheckFn=Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById]
|
||||
|
||||
FeeType = Literal['position_fee', 'process_fee']
|
||||
|
||||
CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult]
|
||||
|
||||
|
||||
def merge_check_results(a: CheckResult, b: CheckResult) -> CheckResult:
|
||||
result = dict(a)
|
||||
for key, b_inner in b.items():
|
||||
if key in result:
|
||||
result[key] = result[key] | b_inner
|
||||
else:
|
||||
result[key] = b_inner
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Ruling:
|
||||
"""
|
||||
A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition.
|
||||
"""
|
||||
rule_id: int
|
||||
results: CheckResult
|
||||
order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0))
|
||||
position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0)))
|
||||
results: CancellationCheckResultsById
|
||||
fee_type: FeeType
|
||||
fee: Decimal
|
||||
cancellation_possible: bool
|
||||
|
||||
cancellation_possible: bool=dataclasses.field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
object.__setattr__(
|
||||
def __init__(
|
||||
self,
|
||||
'cancellation_possible',
|
||||
all(ruling.cancellation_possible
|
||||
for ruling in self.results.values()
|
||||
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()
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def total_fee(self):
|
||||
return self.position_fee.amount + self.order_fee
|
||||
@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.total_fee < other.total_fee
|
||||
return self.fee < other.fee
|
||||
else:
|
||||
return self.cancellation_possible and not other.cancellation_possible
|
||||
|
||||
|
||||
class CancellationRuleQuerySet(models.QuerySet):
|
||||
def cancellation_possible(self, diff: OrderDiff):
|
||||
verdicts = [v[0] for v in self._evaluate(diff)]
|
||||
return all(v.cancellation_possible for v in verdicts), verdicts
|
||||
|
||||
def _evaluate(self, diff: OrderDiff) -> List[List[Ruling]]:
|
||||
return [self._evaluate_op(diff, position) for position in diff.order.positions.all()]
|
||||
|
||||
def _evaluate_op(self, diff: OrderDiff, order_position: OrderPosition) -> List[Ruling]:
|
||||
consequences = []
|
||||
for rule in self:
|
||||
if order_position.item in rule.items.all() or order_position.variation in rule.variations.all():
|
||||
consequences.append(rule.apply(diff, order_position))
|
||||
consequences.sort()
|
||||
return consequences
|
||||
|
||||
|
||||
def validate_status_chars(value):
|
||||
invalid=set(value) - Order.ALLOWED_STATUS_CHARS
|
||||
if invalid:
|
||||
@@ -126,33 +139,25 @@ def validate_status_chars(value):
|
||||
raise ValidationError("Duplicate characters are not allowed.")
|
||||
|
||||
|
||||
|
||||
class CancellationRule(models.Model):
|
||||
"""
|
||||
|
||||
|
||||
"""
|
||||
organizer=models.ForeignKey(
|
||||
"Organizer",
|
||||
related_name="orders",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
event=models.ForeignKey(
|
||||
Event,
|
||||
verbose_name=_("Event"),
|
||||
related_name="orders",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
"Item",
|
||||
verbose_name=_("Items"),
|
||||
|
||||
all_products=models.BooleanField(
|
||||
verbose_name=_("All products and variations"),
|
||||
default=True,
|
||||
)
|
||||
variations=models.ManyToManyField(
|
||||
"ItemVariation",
|
||||
verbose_name=_("Item variations"),
|
||||
limit_products=models.ManyToManyField(Item, verbose_name=_("Products"), blank=True)
|
||||
limit_variations=models.ManyToManyField(
|
||||
ItemVariation, blank=True, verbose_name=_("Variations")
|
||||
)
|
||||
|
||||
|
||||
allowed_if_in_order_status=models.CharField(
|
||||
max_length=4,
|
||||
choices=Order.STATUS_CHOICE,
|
||||
@@ -166,92 +171,34 @@ class CancellationRule(models.Model):
|
||||
fee_percentage_per_item=models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
max_value=Decimal("100.00"),
|
||||
min_value=Decimal("0.00"),
|
||||
verbose_name=_("Fee Percentage per Item"),
|
||||
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 Item"),
|
||||
verbose_name=_("Absolute fee per OrderPosition"),
|
||||
default=Decimal("0.00"),
|
||||
) # wird als sum() kombiniert
|
||||
fee_absolute_per_order=models.DecimalField(
|
||||
|
||||
|
||||
fee_cancellation_process=models.DecimalField(
|
||||
max_digits=13,
|
||||
decimal_places=2,
|
||||
verbose_name=_("Absolute Fee per Cancellation"),
|
||||
verbose_name=_("Absolute fee per Cancellation"),
|
||||
default=Decimal("0.00"),
|
||||
) # wird als max() kombiniert
|
||||
|
||||
objects=ScopedManager(CancellationRuleQuerySet.as_manager().__class__, organizer='organizer',
|
||||
event='event')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# erstmal festgelegte List an Stornoregeln, weitere checks können zukünftig über ein
|
||||
# Signal eingesammelt und an CancellationRule.__init__ übergeben werden
|
||||
# Ermöglicht dann: "Shipping modul kann storno geshippter Items verhindern"
|
||||
self.checks: List[CheckFn]=[self._check_order_status, self._check_time_window,
|
||||
self._system_check_not_checked_in, self._system_check_not_discounted]
|
||||
self.checks: List[CheckFn] = [self._check_order_status, self._check_time_window]
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
@staticmethod
|
||||
def _system_check_not_checked_in(diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
|
||||
check_id = "SYSTEM_TICKET_NOT_USED"
|
||||
|
||||
if order_position.checkins.filter(list__consider_tickets_used=True).exists():
|
||||
return {check_id: CheckRes(
|
||||
cancellation_possible=False,
|
||||
reason=f"Order position was used",
|
||||
)}
|
||||
else:
|
||||
return {check_id: CheckRes(
|
||||
cancellation_possible=True,
|
||||
reason=f"Order position not yet used",
|
||||
)}
|
||||
|
||||
@staticmethod
|
||||
def _system_check_not_discounted(diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
|
||||
"""
|
||||
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
|
||||
|
||||
:param diff:
|
||||
:param order_position:
|
||||
:return CheckResults:
|
||||
"""
|
||||
check_id = "SYSTEM_TICKET_NOT_DISCOUNTED"
|
||||
|
||||
if order_position in diff.cancellations():
|
||||
if order_position.discount_id is None:
|
||||
return {check_id: CheckRes(
|
||||
cancellation_possible=True,
|
||||
reason=_("Order position was bought without discount"),
|
||||
)}
|
||||
else:
|
||||
return {check_id: CheckRes(
|
||||
cancellation_possible=False,
|
||||
reason=_("Order position was bought with a discount"),
|
||||
)}
|
||||
else:
|
||||
return {check_id: CheckRes(
|
||||
cancellation_possible=False,
|
||||
reason=_("Order position not canceled - check not applicable"),
|
||||
)}
|
||||
|
||||
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
|
||||
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
|
||||
check_id = "TIME_WINDOW"
|
||||
|
||||
if not self.allowed_until and not self.allowed_until:
|
||||
return {check_id: CheckRes(
|
||||
return {check_id: CancellationCheckResult(
|
||||
cancellation_possible=True,
|
||||
reason=f"No time window specified",
|
||||
)}
|
||||
@@ -264,57 +211,37 @@ class CancellationRule(models.Model):
|
||||
|
||||
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: CheckRes(
|
||||
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: CheckRes(
|
||||
return {check_id: CancellationCheckResult(
|
||||
cancellation_possible=False,
|
||||
reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}",
|
||||
)}
|
||||
else:
|
||||
return {check_id: CheckRes(
|
||||
return {check_id: CancellationCheckResult(
|
||||
cancellation_possible=False,
|
||||
reason=f"Cancellation after time window ending on {self.allowed_until.datetime(relevant_event)}",
|
||||
)}
|
||||
|
||||
|
||||
def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
|
||||
def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
|
||||
check_id = "ORDER_STATUS"
|
||||
|
||||
if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS):
|
||||
return {check_id: CheckRes(
|
||||
return {check_id: CancellationCheckResult(
|
||||
cancellation_possible=True,
|
||||
reason=f"Orders in every status can be cancelled",
|
||||
)}
|
||||
elif diff.order.status in self.allowed_if_in_order_status:
|
||||
return {check_id: CheckRes(
|
||||
return {check_id: CancellationCheckResult(
|
||||
cancellation_possible=True,
|
||||
reason=f"Order in required status: '{diff.order.status}'",
|
||||
)}
|
||||
else:
|
||||
return {check_id: CheckRes(
|
||||
return {check_id: CancellationCheckResult(
|
||||
cancellation_possible=False,
|
||||
reason=f"Order in status '{diff.order.status}' cannot be canceled",
|
||||
)}
|
||||
|
||||
|
||||
def apply(self, diff: OrderDiff, order_position: OrderPosition) -> Ruling:
|
||||
check_results=reduce(merge_check_results,
|
||||
[rule(diff, order_position) for rule 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:
|
||||
fee=AbsoluteFee(self.fee_absolute_per_item)
|
||||
else:
|
||||
fee=RelativeFee(percentage=self.fee_percentage_per_item,
|
||||
reference_price=order_position.price)
|
||||
|
||||
return Ruling(
|
||||
rule_id=self.id,
|
||||
results=check_results,
|
||||
order_fee=self.fee_absolute_per_order,
|
||||
position_fee=fee
|
||||
)
|
||||
|
||||
@@ -41,8 +41,11 @@ 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
|
||||
from typing import List, Optional
|
||||
from typing import Set
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
@@ -53,6 +56,7 @@ from django.db.models import (
|
||||
Count, Exists, F, IntegerField, Max, Min, OuterRef, 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
|
||||
@@ -71,6 +75,16 @@ from pretix.base.models import (
|
||||
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
|
||||
SeatCategoryMapping, User, Voucher,
|
||||
)
|
||||
from pretix.base.models import Checkin
|
||||
from pretix.base.models.cancellation import AbsoluteFee, CancellationCheckResult
|
||||
from pretix.base.models.cancellation import CancellationRule
|
||||
from pretix.base.models.cancellation import CheckFn
|
||||
from pretix.base.models.cancellation import CheckRes
|
||||
from pretix.base.models.cancellation import OrderDiff
|
||||
from pretix.base.models.cancellation import RelativeFee
|
||||
from pretix.base.models.cancellation import Ruling
|
||||
from pretix.base.models.cancellation import assert_no_queries
|
||||
from pretix.base.models.cancellation import merge_check_results
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
|
||||
@@ -105,9 +119,10 @@ from pretix.base.signals import (
|
||||
order_reactivated, order_split, order_valid_if_pending, periodic_task,
|
||||
validate_order,
|
||||
)
|
||||
from pretix.base.signals import self_service_cancellation_checks
|
||||
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
|
||||
@@ -3525,3 +3540,171 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
|
||||
'customer': order.customer_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
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=f"Order position was used",
|
||||
)}
|
||||
else:
|
||||
return {self.id: CancellationCheckResult(
|
||||
cancellation_possible=True,
|
||||
reason=f"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_rulings])
|
||||
|
||||
# TODO zusammenführen der Rulings
|
||||
|
||||
if dry_run:
|
||||
return cancellation_possible, rulings
|
||||
else:
|
||||
...
|
||||
|
||||
@@ -1206,3 +1206,14 @@ 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,7 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import contextlib
|
||||
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
||||
from django.db import connection, transaction
|
||||
@@ -29,6 +29,7 @@ from django.db.models import (
|
||||
)
|
||||
from django.utils.functional import lazy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DummyRollbackException(Exception):
|
||||
pass
|
||||
@@ -280,3 +281,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
|
||||
|
||||
Reference in New Issue
Block a user