mirror of
https://github.com/pretix/pretix.git
synced 2026-05-12 16:24:00 +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 dataclasses import dataclass
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from functools import reduce
|
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.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
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.utils.translation import gettext_lazy as _
|
||||||
from django_scopes import ScopedManager
|
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 Event, Order, OrderPosition
|
||||||
|
from pretix.base.models import Item
|
||||||
|
from pretix.base.models import ItemVariation
|
||||||
from pretix.base.reldate import ModelRelativeDateTimeField
|
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
|
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)
|
@dataclass(frozen=True)
|
||||||
class OrderDiff:
|
class OrderDiff:
|
||||||
order: Order
|
order: Order
|
||||||
prev: Set[OrderPosition]
|
keep: Set[OrderPosition]
|
||||||
next: Set[OrderPosition]
|
|
||||||
|
|
||||||
def cancellations(self):
|
def cancellations(self):
|
||||||
return self.prev.difference(self.next)
|
return self.prev.difference(self.next)
|
||||||
@@ -47,75 +38,97 @@ class OrderDiff:
|
|||||||
return OrderDiff(order=order, prev=set(order.positions.all()), next=set())
|
return OrderDiff(order=order, prev=set(order.positions.all()), next=set())
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CheckRes:
|
class CancellationCheckResult:
|
||||||
cancellation_possible: bool
|
cancellation_possible: bool
|
||||||
reason: str
|
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:
|
class Ruling:
|
||||||
|
"""
|
||||||
|
A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition.
|
||||||
|
"""
|
||||||
rule_id: int
|
rule_id: int
|
||||||
results: CheckResult
|
results: CancellationCheckResultsById
|
||||||
order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0))
|
fee_type: FeeType
|
||||||
position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0)))
|
fee: Decimal
|
||||||
|
cancellation_possible: bool
|
||||||
|
|
||||||
cancellation_possible: bool=dataclasses.field(init=False)
|
def __init__(
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
object.__setattr__(
|
|
||||||
self,
|
self,
|
||||||
'cancellation_possible',
|
rule_id: int,
|
||||||
all(ruling.cancellation_possible
|
results: CancellationCheckResultsById,
|
||||||
for ruling in self.results.values()
|
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
|
@classmethod
|
||||||
def total_fee(self):
|
def from_absolute_fee(
|
||||||
return self.position_fee.amount + self.order_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):
|
def __lt__(self, other):
|
||||||
if not isinstance(other, Ruling):
|
if not isinstance(other, Ruling):
|
||||||
return NotImplemented
|
return NotImplemented
|
||||||
|
|
||||||
|
if self.fee_type != other.fee_type:
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
if self.cancellation_possible == other.cancellation_possible:
|
if self.cancellation_possible == other.cancellation_possible:
|
||||||
return self.total_fee < other.total_fee
|
return self.fee < other.fee
|
||||||
else:
|
else:
|
||||||
return self.cancellation_possible and not other.cancellation_possible
|
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):
|
def validate_status_chars(value):
|
||||||
invalid=set(value) - Order.ALLOWED_STATUS_CHARS
|
invalid=set(value) - Order.ALLOWED_STATUS_CHARS
|
||||||
if invalid:
|
if invalid:
|
||||||
@@ -126,33 +139,25 @@ def validate_status_chars(value):
|
|||||||
raise ValidationError("Duplicate characters are not allowed.")
|
raise ValidationError("Duplicate characters are not allowed.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CancellationRule(models.Model):
|
class CancellationRule(models.Model):
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
organizer=models.ForeignKey(
|
|
||||||
"Organizer",
|
|
||||||
related_name="orders",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
event=models.ForeignKey(
|
event=models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
verbose_name=_("Event"),
|
verbose_name=_("Event"),
|
||||||
related_name="orders",
|
related_name="orders",
|
||||||
on_delete=models.CASCADE
|
on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
items = models.ManyToManyField(
|
|
||||||
"Item",
|
all_products=models.BooleanField(
|
||||||
verbose_name=_("Items"),
|
verbose_name=_("All products and variations"),
|
||||||
|
default=True,
|
||||||
)
|
)
|
||||||
variations=models.ManyToManyField(
|
limit_products=models.ManyToManyField(Item, verbose_name=_("Products"), blank=True)
|
||||||
"ItemVariation",
|
limit_variations=models.ManyToManyField(
|
||||||
verbose_name=_("Item variations"),
|
ItemVariation, blank=True, verbose_name=_("Variations")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
allowed_if_in_order_status=models.CharField(
|
allowed_if_in_order_status=models.CharField(
|
||||||
max_length=4,
|
max_length=4,
|
||||||
choices=Order.STATUS_CHOICE,
|
choices=Order.STATUS_CHOICE,
|
||||||
@@ -166,92 +171,34 @@ class CancellationRule(models.Model):
|
|||||||
fee_percentage_per_item=models.DecimalField(
|
fee_percentage_per_item=models.DecimalField(
|
||||||
max_digits=5,
|
max_digits=5,
|
||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
max_value=Decimal("100.00"),
|
validators=[MinValueValidator("0.00"), MaxValueValidator("100.00")],
|
||||||
min_value=Decimal("0.00"),
|
verbose_name=_("Fee Percentage per OrderPosition"),
|
||||||
verbose_name=_("Fee Percentage per Item"),
|
|
||||||
default=Decimal("0.00"),
|
default=Decimal("0.00"),
|
||||||
) # wird als sum() kombiniert
|
) # wird als sum() kombiniert
|
||||||
fee_absolute_per_item=models.DecimalField(
|
fee_absolute_per_item=models.DecimalField(
|
||||||
max_digits=13,
|
max_digits=13,
|
||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
verbose_name=_("Absolute Fee per Item"),
|
verbose_name=_("Absolute fee per OrderPosition"),
|
||||||
default=Decimal("0.00"),
|
default=Decimal("0.00"),
|
||||||
) # wird als sum() kombiniert
|
) # wird als sum() kombiniert
|
||||||
fee_absolute_per_order=models.DecimalField(
|
|
||||||
|
|
||||||
|
fee_cancellation_process=models.DecimalField(
|
||||||
max_digits=13,
|
max_digits=13,
|
||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
verbose_name=_("Absolute Fee per Cancellation"),
|
verbose_name=_("Absolute fee per Cancellation"),
|
||||||
default=Decimal("0.00"),
|
default=Decimal("0.00"),
|
||||||
) # wird als max() kombiniert
|
) # wird als max() kombiniert
|
||||||
|
|
||||||
objects=ScopedManager(CancellationRuleQuerySet.as_manager().__class__, organizer='organizer',
|
|
||||||
event='event')
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# erstmal festgelegte List an Stornoregeln, weitere checks können zukünftig über ein
|
self.checks: List[CheckFn] = [self._check_order_status, self._check_time_window]
|
||||||
# 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]
|
|
||||||
|
|
||||||
|
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
|
||||||
# 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:
|
|
||||||
check_id = "TIME_WINDOW"
|
check_id = "TIME_WINDOW"
|
||||||
|
|
||||||
if not self.allowed_until and not self.allowed_until:
|
if not self.allowed_until and not self.allowed_until:
|
||||||
return {check_id: CheckRes(
|
return {check_id: CancellationCheckResult(
|
||||||
cancellation_possible=True,
|
cancellation_possible=True,
|
||||||
reason=f"No time window specified",
|
reason=f"No time window specified",
|
||||||
)}
|
)}
|
||||||
@@ -264,57 +211,37 @@ class CancellationRule(models.Model):
|
|||||||
|
|
||||||
if in_allowed_until and not in_exemption:
|
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 ""
|
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,
|
cancellation_possible=True,
|
||||||
reason=f"Cancellation in required time window before {self.allowed_until.datetime(relevant_event)}{except_after_message}",
|
reason=f"Cancellation in required time window before {self.allowed_until.datetime(relevant_event)}{except_after_message}",
|
||||||
)}
|
)}
|
||||||
elif in_allowed_until and in_exemption:
|
elif in_allowed_until and in_exemption:
|
||||||
return {check_id: CheckRes(
|
return {check_id: CancellationCheckResult(
|
||||||
cancellation_possible=False,
|
cancellation_possible=False,
|
||||||
reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}",
|
reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}",
|
||||||
)}
|
)}
|
||||||
else:
|
else:
|
||||||
return {check_id: CheckRes(
|
return {check_id: CancellationCheckResult(
|
||||||
cancellation_possible=False,
|
cancellation_possible=False,
|
||||||
reason=f"Cancellation after time window ending on {self.allowed_until.datetime(relevant_event)}",
|
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"
|
check_id = "ORDER_STATUS"
|
||||||
|
|
||||||
if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS):
|
if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS):
|
||||||
return {check_id: CheckRes(
|
return {check_id: CancellationCheckResult(
|
||||||
cancellation_possible=True,
|
cancellation_possible=True,
|
||||||
reason=f"Orders in every status can be cancelled",
|
reason=f"Orders in every status can be cancelled",
|
||||||
)}
|
)}
|
||||||
elif diff.order.status in self.allowed_if_in_order_status:
|
elif diff.order.status in self.allowed_if_in_order_status:
|
||||||
return {check_id: CheckRes(
|
return {check_id: CancellationCheckResult(
|
||||||
cancellation_possible=True,
|
cancellation_possible=True,
|
||||||
reason=f"Order in required status: '{diff.order.status}'",
|
reason=f"Order in required status: '{diff.order.status}'",
|
||||||
)}
|
)}
|
||||||
else:
|
else:
|
||||||
return {check_id: CheckRes(
|
return {check_id: CancellationCheckResult(
|
||||||
cancellation_possible=False,
|
cancellation_possible=False,
|
||||||
reason=f"Order in status '{diff.order.status}' cannot be canceled",
|
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 datetime import datetime, time, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
from itertools import chain
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
from typing import Dict
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
from celery.exceptions import MaxRetriesExceededError
|
from celery.exceptions import MaxRetriesExceededError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -53,6 +56,7 @@ from django.db.models import (
|
|||||||
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
|
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
|
||||||
Value,
|
Value,
|
||||||
)
|
)
|
||||||
|
from django.db.models import Prefetch
|
||||||
from django.db.models.functions import Coalesce, Greatest
|
from django.db.models.functions import Coalesce, Greatest
|
||||||
from django.db.transaction import get_connection
|
from django.db.transaction import get_connection
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -71,6 +75,16 @@ from pretix.base.models import (
|
|||||||
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
|
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
|
||||||
SeatCategoryMapping, User, Voucher,
|
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.event import SubEvent
|
||||||
from pretix.base.models.orders import (
|
from pretix.base.models.orders import (
|
||||||
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
|
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
|
||||||
@@ -105,9 +119,10 @@ from pretix.base.signals import (
|
|||||||
order_reactivated, order_split, order_valid_if_pending, periodic_task,
|
order_reactivated, order_split, order_valid_if_pending, periodic_task,
|
||||||
validate_order,
|
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.base.timemachine import time_machine_now, time_machine_now_assigned
|
||||||
from pretix.celery_app import app
|
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.models import modelcopy
|
||||||
from pretix.helpers.periodic import minimum_interval
|
from pretix.helpers.periodic import minimum_interval
|
||||||
from pretix.testutils.middleware import debugflags_var
|
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,
|
'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
|
Both the original and updated versions of the Device are included to allow
|
||||||
receivers to see what has been updated.
|
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/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import logging
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
||||||
from django.db import connection, transaction
|
from django.db import connection, transaction
|
||||||
@@ -29,6 +29,7 @@ from django.db.models import (
|
|||||||
)
|
)
|
||||||
from django.utils.functional import lazy
|
from django.utils.functional import lazy
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class DummyRollbackException(Exception):
|
class DummyRollbackException(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -280,3 +281,21 @@ def get_deterministic_ordering(model, ordering):
|
|||||||
# on the primary key to provide total ordering.
|
# on the primary key to provide total ordering.
|
||||||
ordering.append("-pk")
|
ordering.append("-pk")
|
||||||
return ordering
|
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