forked from CGM_Public/pretix_original
811 lines
30 KiB
Python
811 lines
30 KiB
Python
import datetime
|
|
import operator
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal
|
|
|
|
from django.utils.timezone import make_aware
|
|
from itertools import chain
|
|
from typing import (
|
|
Any, Callable, ClassVar, Dict, Final, List, Literal, Optional, Protocol, Set,
|
|
TYPE_CHECKING, Tuple, TypeAlias,
|
|
)
|
|
from django_stubs_ext import StrOrPromise
|
|
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
|
from django.db import models
|
|
from django.db.models import Prefetch, QuerySet
|
|
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, RelativeDateWrapper
|
|
from pretix.base.signals import self_service_cancellation_checks
|
|
from pretix.helpers import ensure_no_queries
|
|
|
|
"""
|
|
Supporting self-service cancellation requires us to do two main things:
|
|
1. uphold the business logic of pretix and the installed plugins
|
|
2. charge the customer the appropriate fees for their cancellation
|
|
|
|
Number 1 is a question of bringing enough checks into place and prevent a
|
|
cancellation if one of them is violated.
|
|
Checks need to subclass `CancellationCheck` and can be provided via the new
|
|
`self_service_cancellation_checks` signal.
|
|
|
|
Number 2 is trickier because organizers will have complex^(TM) cancellation
|
|
fee structures and expressing these in an understandable way is a challenge.
|
|
Especially when taking support cases into consideration that have to debug
|
|
certain behaviour long after.
|
|
The cancellation fees are computed via `CancellationRules`.
|
|
|
|
When a customer triggers a self service cancellation, we will:
|
|
1. Positions
|
|
a. Evaluate all `CancellationChecks` that are concerned with individual positions
|
|
b. Evaluate all `CancellationRules` that are concerned with individual positions and compute the fees
|
|
c. Choose for each position the cheapest `CancellationRules` position result available
|
|
2. Process
|
|
a. Evaluate all `CancellationChecks` that are concerned with the process of cancellation
|
|
b. Evaluate all `CancellationRules` that are concerned with the process of cancellation
|
|
c. Choose the cheapest `CancellationRules` process result available
|
|
3. Return all results for Checks and Rules
|
|
|
|
Step 1c. and 2c. are kept separate intentionally.
|
|
The alternative of finding the cheapest cancellation option overall (process and position) would
|
|
require us to check the full combinatorics of possible process and position fees, resulting
|
|
in unfeasible runtime behaviour, and if we would optimize it in difficult to explain non-optimal
|
|
situations.
|
|
"""
|
|
|
|
|
|
class FeeType(models.TextChoices):
|
|
"""
|
|
Process fees can be added on top of all position fees, or they
|
|
can set a floor for the minimum cancellation fee that this will incur.
|
|
"""
|
|
MINIMUM = "min_process_fee", _("Minimum total fee")
|
|
ADDITIONAL = "add_process_fee", _("Additional fee")
|
|
POSITION = "position_fee", _("Position fee")
|
|
|
|
|
|
class CheckTypes(models.TextChoices):
|
|
POSITION = "position", _("Order Position Cancellation Rule")
|
|
PROCESS = "process", _("Cancellation Process Rule")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CheckResult:
|
|
"""
|
|
Result of an individual cancellation check.
|
|
The check result only encodes if the check allows or disallows cancellation via
|
|
`cancellation_possible`
|
|
"""
|
|
id: str
|
|
reason: StrOrPromise
|
|
cancellation_possible: bool
|
|
type: Literal['check'] = field(default="check")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RuleResult:
|
|
"""
|
|
Result of evaluating a CancellationRule.
|
|
A rule can consist out of multiple different checks, each partial_result is recorded individually.
|
|
|
|
A RuleResult encodes both the feasibility of a cancellation via `cancellation_possible` and
|
|
the resulting consequences in form of fees which can be expressed as:
|
|
- absolute position fees of a fixed amount
|
|
- relative position fees of a percentage of the position price
|
|
- minimum process fees, the total cancellation fee across all positions and the process must be at least this
|
|
- additional process fee, an additional processing fee is charged in addition to the per position fees
|
|
"""
|
|
id: int
|
|
partial_results: List[CheckResult]
|
|
fee_type: FeeType
|
|
fee: Decimal
|
|
|
|
type: Literal['rule'] = field(default="rule")
|
|
|
|
@property
|
|
def cancellation_possible(self) -> bool:
|
|
return all(result.cancellation_possible for result in self.partial_results)
|
|
|
|
@classmethod
|
|
def from_absolute_fee(
|
|
cls,
|
|
id: int,
|
|
partial_results: List[CheckResult],
|
|
fee_type: Literal[FeeType.POSITION],
|
|
absolute_fee: Decimal
|
|
) -> "RuleResult":
|
|
return RuleResult(id=id, partial_results=partial_results, fee_type=fee_type, fee=absolute_fee)
|
|
|
|
@classmethod
|
|
def from_relative_fee(
|
|
cls,
|
|
id: int,
|
|
partial_results: List[CheckResult],
|
|
fee_type: Literal[FeeType.POSITION],
|
|
position_price: Decimal,
|
|
percentage: Decimal,
|
|
currency: str
|
|
) -> "RuleResult":
|
|
return RuleResult(id=id, partial_results=partial_results, fee_type=fee_type,
|
|
fee=round_decimal(position_price * (percentage / 100), currency))
|
|
|
|
@classmethod
|
|
def from_process_fee(
|
|
cls,
|
|
id: int,
|
|
partial_results: List[CheckResult],
|
|
fee_type: Literal[FeeType.MINIMUM, FeeType.ADDITIONAL],
|
|
absolute_fee: Decimal,
|
|
reference_price: Decimal
|
|
) -> "RuleResult":
|
|
if fee_type == FeeType.MINIMUM:
|
|
if reference_price < absolute_fee:
|
|
fee = absolute_fee - reference_price
|
|
else:
|
|
fee = Decimal(0)
|
|
elif fee_type == FeeType.ADDITIONAL:
|
|
fee = absolute_fee
|
|
else:
|
|
raise ValueError("Unknown fee type")
|
|
|
|
return RuleResult(id=id, partial_results=partial_results, fee_type=fee_type, fee=fee)
|
|
|
|
def __lt__(self, other: object) -> bool:
|
|
if not isinstance(other, RuleResult):
|
|
return NotImplemented
|
|
|
|
if self.cancellation_possible == other.cancellation_possible:
|
|
return self.fee < other.fee
|
|
else:
|
|
return self.cancellation_possible and not other.cancellation_possible
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Checks:
|
|
position: List["CancellationCheck"]
|
|
process: List["CancellationCheck"]
|
|
|
|
@property
|
|
def prefetches(self) -> List[Callable[[], Prefetch]]:
|
|
return list(chain.from_iterable(
|
|
check.prefetches for check in [*self.position, *self.process]
|
|
))
|
|
|
|
@property
|
|
def related_selects(self) -> List[str]:
|
|
return list(chain.from_iterable(
|
|
check.related_selects for check in [*self.position, *self.process]
|
|
))
|
|
|
|
|
|
PositionSet: TypeAlias = Set[OrderPosition]
|
|
|
|
|
|
class PositionCheckFn(Protocol):
|
|
def __call__(self, order: Order, keep: PositionSet, position: OrderPosition, check_ts: datetime.datetime,
|
|
/) -> CheckResult:
|
|
...
|
|
|
|
|
|
class ProcessCheckFn(Protocol):
|
|
def __call__(self, order: Order, keep: PositionSet, check_ts: datetime.datetime, /) -> CheckResult:
|
|
...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CancellationCheck:
|
|
id: str
|
|
type: CheckTypes
|
|
check_fn: PositionCheckFn | ProcessCheckFn = field(compare=False)
|
|
prefetches: List[Callable[[], Prefetch]] = field(default_factory=list)
|
|
related_selects: List[str] = field(default_factory=list)
|
|
|
|
def evaluate(self, order: Order, keep: PositionSet,
|
|
position: OrderPosition | None, check_ts: datetime.datetime) -> CheckResult:
|
|
if position and self.type == CheckTypes.POSITION:
|
|
return self.check_fn(order, keep, position, check_ts)
|
|
elif position is None and self.type == CheckTypes.PROCESS:
|
|
return self.check_fn(order, keep, check_ts)
|
|
else:
|
|
raise ValidationError("Type of the rule doesn't match the check_fn")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PositionResult:
|
|
position_check_results: Dict[int, List[CheckResult]]
|
|
position_rule_results: Dict[int, List[RuleResult]]
|
|
|
|
@property
|
|
def cancellation_possible(self) -> bool:
|
|
def ok(results: List[CheckResult] | List[RuleResult]) -> bool:
|
|
return all([val.cancellation_possible for val in results]) if results else True
|
|
|
|
return all(
|
|
ok(results)
|
|
for d in
|
|
(self.position_check_results,
|
|
{key: [min(pos_res)] for key, pos_res in self.position_rule_results.items() if pos_res})
|
|
for results in d.values()
|
|
)
|
|
|
|
@property
|
|
def fee_value(self) -> Decimal:
|
|
fee_value = Decimal("0.00")
|
|
for pos_id, results in self.position_rule_results.items():
|
|
if len(results) > 0:
|
|
best_option = min(results)
|
|
if best_option.cancellation_possible:
|
|
fee_value += best_option.fee
|
|
return fee_value
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProcessResult:
|
|
process_check_results: List[CheckResult]
|
|
process_rule_results: List[RuleResult]
|
|
|
|
@property
|
|
def cancellation_possible(self) -> bool:
|
|
results: List[CheckResult | RuleResult] = [*self.process_check_results]
|
|
if self.process_rule_results:
|
|
results.append(min(self.process_rule_results))
|
|
return all(res.cancellation_possible for res in results)
|
|
|
|
@property
|
|
def fee_value(self) -> Decimal:
|
|
if not self.process_rule_results:
|
|
return Decimal("0.00")
|
|
best_option = min(self.process_rule_results)
|
|
if best_option.cancellation_possible:
|
|
return best_option.fee
|
|
return Decimal("0.00")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CancellationResult:
|
|
position_result: PositionResult
|
|
process_result: ProcessResult
|
|
|
|
order: Order
|
|
keep: Set[OrderPosition]
|
|
check_ts: datetime.datetime
|
|
|
|
# TODO add state choice
|
|
# TODO make this a model
|
|
|
|
|
|
@property
|
|
def cancellation_possible(self) -> bool:
|
|
return self.position_result.cancellation_possible and self.process_result.cancellation_possible
|
|
|
|
def prepare(self):
|
|
# TODO: store the cancellation id in the session storage
|
|
pass
|
|
|
|
def execute(self):
|
|
# TODO load the cancellation verdict from the id and perform the actions
|
|
pass
|
|
|
|
|
|
def _send_self_service_cancellation_checks(event: Event) -> List[Tuple[Any, Any]]:
|
|
return self_service_cancellation_checks.send(sender=event)
|
|
|
|
|
|
|
|
class CancellationRule(models.Model):
|
|
EARLIEST: Final = "EARLIEST"
|
|
LATEST: Final = "LATEST"
|
|
|
|
SUBEVENT_VARIANT_CHOICES = (
|
|
(EARLIEST, _("Earliest")),
|
|
(LATEST, _("Latest")),
|
|
)
|
|
|
|
event = models.ForeignKey(
|
|
Event,
|
|
verbose_name=_("Event"),
|
|
related_name="cancellation_rules",
|
|
on_delete=models.CASCADE
|
|
)
|
|
|
|
type = models.CharField(
|
|
verbose_name=_("Type of the cancellation rule"),
|
|
default=CheckTypes.POSITION,
|
|
choices=CheckTypes,
|
|
max_length=15,
|
|
)
|
|
|
|
allowed_until = ModelRelativeDateTimeField(null=True, blank=True, verbose_name=_("Allowed until"))
|
|
except_after = ModelRelativeDateTimeField(null=True, blank=True, verbose_name=_("Except after"))
|
|
if TYPE_CHECKING:
|
|
allowed_until: Optional[RelativeDateWrapper]
|
|
except_after: Optional[RelativeDateWrapper]
|
|
|
|
# --- position-only fields ---
|
|
fee_percentage_per_position = models.DecimalField(
|
|
max_digits=5,
|
|
decimal_places=2,
|
|
validators=[MinValueValidator(Decimal("0.00")), MaxValueValidator(Decimal("100.00"))],
|
|
verbose_name=_("Fee Percentage per OrderPosition"),
|
|
default=Decimal("0.00"),
|
|
)
|
|
fee_absolute_per_position = models.DecimalField(
|
|
max_digits=13,
|
|
decimal_places=2,
|
|
verbose_name=_("Absolute fee per OrderPosition"),
|
|
default=Decimal("0.00"),
|
|
validators=[MinValueValidator(Decimal("0.00"))],
|
|
)
|
|
|
|
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")
|
|
)
|
|
|
|
# --- process-only fields ---
|
|
subevent_variant = models.CharField(
|
|
max_length=8,
|
|
choices=SUBEVENT_VARIANT_CHOICES,
|
|
default=EARLIEST,
|
|
verbose_name=_("Subevent variant"),
|
|
help_text=_("An order can contain tickets for multiple different events if the event has "
|
|
"subevents enabled. This choice controls if the order position for the earliest "
|
|
"or the latest point in time in the order is used to determine the allowed until and "
|
|
"except after dates.")
|
|
)
|
|
|
|
fee_cancellation_process = models.DecimalField(
|
|
max_digits=13,
|
|
decimal_places=2,
|
|
verbose_name=_("Absolute fee per Cancellation"),
|
|
default=Decimal("0.00"),
|
|
validators=[MinValueValidator(Decimal("0.00"))],
|
|
)
|
|
|
|
fee_mode = models.CharField(
|
|
verbose_name=_("The method with which process and position fees are combined."),
|
|
choices=[
|
|
(FeeType.MINIMUM, FeeType.MINIMUM.label),
|
|
(FeeType.ADDITIONAL, FeeType.ADDITIONAL.label),
|
|
],
|
|
blank=True,
|
|
null=True,
|
|
max_length=15,
|
|
)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.CheckConstraint(
|
|
condition=models.Q(type__in=[CheckTypes.POSITION, CheckTypes.PROCESS]),
|
|
name="cancellation_rule_type_valid",
|
|
),
|
|
]
|
|
|
|
|
|
@staticmethod
|
|
def _collect_checks(event: Event, send_fn: Callable[
|
|
[Event], List[Tuple[Any, Any]]] = _send_self_service_cancellation_checks) -> Checks:
|
|
position_checks: List[CancellationCheck] = []
|
|
process_checks: List[CancellationCheck] = []
|
|
|
|
seen = set()
|
|
for recv, resp in send_fn(event):
|
|
if resp is None:
|
|
continue
|
|
|
|
if not isinstance(resp, CancellationCheck):
|
|
raise ValueError('self_service_cancellation_checks received response of wrong type')
|
|
if resp.id in seen:
|
|
raise ValueError('self_service_cancellation_checks received multiple responses with the id')
|
|
seen.add(resp.id)
|
|
|
|
if resp.type == CheckTypes.POSITION:
|
|
position_checks.append(resp)
|
|
if resp.type == CheckTypes.PROCESS:
|
|
process_checks.append(resp)
|
|
|
|
return Checks(position=position_checks, process=process_checks)
|
|
|
|
@staticmethod
|
|
def _prefetch_order(event: Event, order: Order, checks: Checks) -> Order:
|
|
prefetches = [pref() for pref in [*checks.prefetches,
|
|
*PositionCancellationRule.prefetches,
|
|
*ProcessCancellationRule.prefetches]]
|
|
|
|
related_selects = {*checks.related_selects,
|
|
*PositionCancellationRule.related_selects,
|
|
*ProcessCancellationRule.related_selects}
|
|
|
|
qs = Order.objects.prefetch_related(*prefetches)
|
|
if related_selects:
|
|
qs = qs.select_related(*related_selects)
|
|
|
|
return qs.get(event=event, id=order.id)
|
|
|
|
@staticmethod
|
|
def evaluate(event: Event, order: Order, keep: Set[OrderPosition],
|
|
check_ts: datetime.datetime) -> "CancellationResult":
|
|
|
|
# TODO check that all keep entries belong to order
|
|
# TODO exclude cancelled positions
|
|
|
|
# collect all checks, position_rules and process_rules that are applicable
|
|
checks = CancellationRule._collect_checks(event=event)
|
|
position_rules: QuerySet[PositionCancellationRule] = PositionCancellationRule.objects.filter(
|
|
event=event).with_rule_data().all()
|
|
process_rules: QuerySet[ProcessCancellationRule] = ProcessCancellationRule.objects.filter(
|
|
event=event).with_rule_data().all()
|
|
|
|
order = CancellationRule._prefetch_order(event, order, checks)
|
|
|
|
|
|
# keep track of all decisions so we can explain them in the logs
|
|
position_check_results: Dict[int, List[CheckResult]] = {}
|
|
position_rule_results: Dict[int, List[RuleResult]] = {}
|
|
|
|
# perform all position checks and position rules
|
|
for position in order.positions.all():
|
|
position_check_results[position.id] = []
|
|
position_rule_results[position.id] = []
|
|
|
|
# skip this position if customer doesn't want to cancel
|
|
if position in keep:
|
|
continue
|
|
|
|
# evaluate the system/plugin checks for the position
|
|
for check in checks.position:
|
|
position_check_results[position.id].append(
|
|
check.evaluate(order=order, keep=keep, position=position, check_ts=check_ts))
|
|
|
|
# evaluate all customer specified rules for this position
|
|
for rule in position_rules:
|
|
result = rule.evaluate_position_rule(order, keep, position, check_ts)
|
|
if result is not None:
|
|
position_rule_results[position.id].append(result)
|
|
|
|
position_results = PositionResult(position_check_results=position_check_results,
|
|
position_rule_results=position_rule_results)
|
|
|
|
# we need the current fee_value to select the cheapest process rule
|
|
temp_position_fees = position_results.fee_value
|
|
|
|
# again keep track of all decisions so we can explain them in the logs
|
|
process_check_results: List[CheckResult] = []
|
|
process_rule_results: List[RuleResult] = []
|
|
|
|
# evaluate all system/plugin provided checks for the cancellation process
|
|
for check in checks.process:
|
|
process_check_results.append(check.evaluate(order=order, keep=keep, position=None, check_ts=check_ts))
|
|
|
|
# evaluate all customer specified rules for the cancellation process
|
|
for rule in process_rules:
|
|
result = rule.evaluate_process_rule(order, keep, temp_position_fees, check_ts)
|
|
if result is not None:
|
|
process_rule_results.append(result)
|
|
|
|
process_result = ProcessResult(process_check_results=process_check_results,
|
|
process_rule_results=process_rule_results)
|
|
|
|
return CancellationResult(position_result=position_results, process_result=process_result,
|
|
check_ts=check_ts, order=order, keep=keep)
|
|
|
|
|
|
|
|
def clean(self):
|
|
super().clean()
|
|
errors = {}
|
|
|
|
if self.type == CheckTypes.PROCESS:
|
|
if self.fee_mode not in (FeeType.MINIMUM, FeeType.ADDITIONAL):
|
|
errors["fee_mode"] = _(
|
|
"Fee mode is not valid on a process rule."
|
|
)
|
|
if self.fee_percentage_per_position or self.fee_absolute_per_position:
|
|
errors["fee_percentage_per_position"] = _(
|
|
"Position fees must be unset on a process rule."
|
|
)
|
|
if self.pk and (self.limit_products.exists() or self.limit_variations.exists()):
|
|
errors["limit_products"] = _(
|
|
"Product/variation limits are not valid on a process rule."
|
|
)
|
|
|
|
if self.type == CheckTypes.POSITION:
|
|
if self.fee_cancellation_process:
|
|
errors["fee_cancellation_process"] = _(
|
|
"Process fee must be unset on a position rule."
|
|
)
|
|
if self.fee_mode:
|
|
errors["fee_mode"] = _(
|
|
"Fee mode is not valid on a position rule."
|
|
)
|
|
|
|
if errors:
|
|
raise ValidationError(errors)
|
|
|
|
|
|
class PositionCancellationRuleQuerySet(models.QuerySet):
|
|
def with_rule_data(self):
|
|
qs = self.prefetch_related(*[p() for p in PositionCancellationRule.rule_prefetches])
|
|
if PositionCancellationRule.rule_related_selects:
|
|
qs = qs.select_related(*PositionCancellationRule.rule_related_selects)
|
|
return qs
|
|
|
|
|
|
class PositionCancellationRuleManager(models.Manager.from_queryset(PositionCancellationRuleQuerySet)):
|
|
def get_queryset(self):
|
|
return super().get_queryset().filter(type=CheckTypes.POSITION).order_by("pk")
|
|
|
|
|
|
class PositionCancellationRule(CancellationRule):
|
|
"""
|
|
PositionCancellationRules answer the questions:
|
|
- Can this position be canceled?
|
|
- What is the price for cancelling this position?
|
|
"""
|
|
objects = PositionCancellationRuleManager()
|
|
|
|
rule_prefetches: ClassVar[List[Callable[[], Prefetch]]] = [
|
|
lambda: Prefetch('limit_products'),
|
|
lambda: Prefetch('limit_variations'),
|
|
]
|
|
rule_related_selects: ClassVar[List[str]] = []
|
|
|
|
prefetches: ClassVar[List[Callable[[], Prefetch]]] = [
|
|
lambda: Prefetch('all_positions__item'),
|
|
lambda: Prefetch('event'),
|
|
]
|
|
related_selects: ClassVar[List[str]] = []
|
|
|
|
class Meta:
|
|
proxy = True
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.type = CheckTypes.POSITION
|
|
self.full_clean()
|
|
super().save(*args, **kwargs)
|
|
|
|
def _position_matches_rule(self, position: OrderPosition) -> Optional[CheckResult]:
|
|
with ensure_no_queries():
|
|
res = CheckResult(
|
|
id=f"position_rule_{self.id}",
|
|
reason=_("Rule matches this product"),
|
|
cancellation_possible=True
|
|
)
|
|
|
|
if self.all_products:
|
|
return res
|
|
|
|
item_pks = {item.pk for item in self.limit_products.all()}
|
|
if position.item_id in item_pks:
|
|
return res
|
|
|
|
variation_pks = {variation.pk for variation in self.limit_variations.all()}
|
|
if position.variation_id in variation_pks:
|
|
return res
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _resolve_date_field(date_field: RelativeDateWrapper, order: Order,
|
|
position: OrderPosition) -> datetime.date | datetime.datetime:
|
|
reldate_type = date_field.choice
|
|
|
|
if reldate_type == "date":
|
|
return make_aware(
|
|
datetime.datetime.combine(date_field.date(order.event), datetime.time(hour=23, minute=59, second=59)),
|
|
order.event.timezone,
|
|
)
|
|
elif reldate_type == "datetime":
|
|
return date_field.datetime(order.event)
|
|
|
|
if reldate_type.base == "order":
|
|
return date_field.datetime(order)
|
|
|
|
if not order.event.has_subevents:
|
|
return date_field.datetime(order.event)
|
|
|
|
return date_field.datetime(position.subevent)
|
|
|
|
def _evaluate_cancellation_moment(self, position: OrderPosition, check_ts: datetime.datetime) -> List[CheckResult]:
|
|
with ensure_no_queries():
|
|
check_results = []
|
|
|
|
order = position.order
|
|
|
|
for param in ('allowed_until', 'except_after'):
|
|
value: RelativeDateWrapper | None = getattr(self, param, None)
|
|
if value is not None:
|
|
if check_ts <= self._resolve_date_field(value, order, position):
|
|
check_results.append(
|
|
CheckResult(
|
|
id=f"position_rule_{self.id}_{param}",
|
|
reason=_("{} is earlier than {} cutoff {}".format(check_ts, param, value)),
|
|
cancellation_possible=True
|
|
)
|
|
)
|
|
else:
|
|
check_results.append(
|
|
CheckResult(
|
|
id=f"position_rule_{self.id}_{param}",
|
|
reason=_("{} is later than {} cutoff {}".format(check_ts, param, value)),
|
|
cancellation_possible=False
|
|
)
|
|
)
|
|
else:
|
|
check_results.append(
|
|
CheckResult(
|
|
id=f"position_rule_{self.id}_{param}",
|
|
reason=_("No {} limit defined".format(param)),
|
|
cancellation_possible=True
|
|
)
|
|
)
|
|
|
|
return check_results
|
|
|
|
def evaluate_position_rule(self, order: Order, _keep: Set[OrderPosition], position: OrderPosition,
|
|
check_ts: datetime.datetime) -> Optional[RuleResult]:
|
|
rule_check_results = []
|
|
match = self._position_matches_rule(position)
|
|
if match:
|
|
rule_check_results.append(match)
|
|
rule_check_results.extend(self._evaluate_cancellation_moment(position, check_ts))
|
|
|
|
if self.fee_percentage_per_position and self.fee_absolute_per_position:
|
|
raise NotImplementedError(
|
|
"Combination of fee_percentage_per position and fee_absolute_per_position is not valid")
|
|
elif self.fee_absolute_per_position != Decimal(0.00):
|
|
return RuleResult.from_absolute_fee(
|
|
id=self.id,
|
|
partial_results=rule_check_results,
|
|
fee_type=FeeType.POSITION,
|
|
absolute_fee=self.fee_absolute_per_position
|
|
)
|
|
else:
|
|
return RuleResult.from_relative_fee(
|
|
id=self.id,
|
|
partial_results=rule_check_results,
|
|
fee_type=FeeType.POSITION,
|
|
position_price=position.price,
|
|
percentage=self.fee_percentage_per_position,
|
|
currency=order.event.currency
|
|
)
|
|
|
|
|
|
class ProcessCancellationRuleQuerySet(models.QuerySet):
|
|
def with_rule_data(self):
|
|
qs = self.prefetch_related(*[p() for p in ProcessCancellationRule.rule_prefetches])
|
|
if ProcessCancellationRule.rule_related_selects:
|
|
qs = qs.select_related(*ProcessCancellationRule.rule_related_selects)
|
|
return qs
|
|
|
|
|
|
class ProcessCancellationRuleManager(models.Manager.from_queryset(ProcessCancellationRuleQuerySet)):
|
|
def get_queryset(self):
|
|
return super().get_queryset().filter(type=CheckTypes.PROCESS).order_by("pk")
|
|
|
|
|
|
class ProcessCancellationRule(CancellationRule):
|
|
"""
|
|
ProcessCancellationRules answer the question:
|
|
- What is the processing fee for performing this cancellation?
|
|
"""
|
|
|
|
objects = ProcessCancellationRuleManager()
|
|
|
|
rule_prefetches: ClassVar[List[Callable[[], Prefetch]]] = []
|
|
rule_related_selects: ClassVar[List[str]] = []
|
|
|
|
prefetches: ClassVar[List[Callable[[], Prefetch]]] = [
|
|
lambda: Prefetch('event'),
|
|
]
|
|
related_selects: ClassVar[List[str]] = []
|
|
|
|
class Meta:
|
|
proxy = True
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.type = CheckTypes.PROCESS
|
|
self.full_clean()
|
|
super().save(*args, **kwargs)
|
|
|
|
@staticmethod
|
|
def _resolve_date_field(date_field: RelativeDateWrapper, order: Order,
|
|
mode: Literal["EARLIEST", "LATEST"] | str) -> datetime.date | datetime.datetime:
|
|
reldate_type = date_field.choice
|
|
|
|
if mode not in ('EARLIEST', 'LATEST'):
|
|
raise ValidationError('Mode is invalid')
|
|
|
|
if reldate_type == "date":
|
|
return make_aware(
|
|
datetime.datetime.combine(date_field.date(order.event), datetime.time(hour=23, minute=59, second=59)),
|
|
order.event.timezone,
|
|
)
|
|
elif reldate_type == "datetime":
|
|
return date_field.datetime(order.event)
|
|
|
|
if reldate_type.base == "order":
|
|
return date_field.datetime(order)
|
|
|
|
if not order.event.has_subevents:
|
|
return date_field.datetime(order.event)
|
|
|
|
comparators = {
|
|
"EARLIEST": operator.lt,
|
|
"LATEST": operator.gt,
|
|
}
|
|
|
|
compare = comparators[mode]
|
|
base_event = order.event
|
|
base_value: None | datetime.date | datetime.date = None
|
|
|
|
for pos in order.positions.all():
|
|
e = pos.subevent if pos.subevent else pos.event
|
|
value = getattr(e, reldate_type.attribute)
|
|
|
|
if value is None:
|
|
continue # skip when there is no value
|
|
|
|
if base_value is None or compare(value, base_value):
|
|
base_event = e
|
|
base_value = value
|
|
|
|
return date_field.datetime(base_event)
|
|
|
|
def _evaluate_cancellation_moment(self, order: Order, check_ts: datetime.datetime) -> List[CheckResult]:
|
|
with ensure_no_queries():
|
|
check_results: List[CheckResult] = []
|
|
|
|
for param in ('allowed_until', 'except_after'):
|
|
value: RelativeDateWrapper | None = getattr(self, param, None)
|
|
if value is not None:
|
|
if check_ts <= self._resolve_date_field(value, order, self.subevent_variant):
|
|
check_results.append(
|
|
CheckResult(
|
|
id=f"process_rule_{self.id}_{param}",
|
|
reason=_("{} is earlier than {} cutoff {}".format(check_ts, param, value)),
|
|
cancellation_possible=True
|
|
)
|
|
)
|
|
else:
|
|
check_results.append(
|
|
CheckResult(
|
|
id=f"process_rule_{self.id}_{param}",
|
|
reason=_("{} is later than {} cutoff {}".format(check_ts, param, value)),
|
|
cancellation_possible=False
|
|
)
|
|
)
|
|
else:
|
|
check_results.append(
|
|
CheckResult(
|
|
id=f"process_rule_{self.id}_{param}",
|
|
reason=_("No {} limit defined".format(param)),
|
|
cancellation_possible=True
|
|
)
|
|
)
|
|
return check_results
|
|
|
|
def evaluate_process_rule(self, order: Order, _keep: Set[OrderPosition], position_fees: Decimal,
|
|
check_ts: datetime.datetime) -> Optional[RuleResult]:
|
|
fee_mode = self.fee_mode
|
|
if fee_mode not in (FeeType.MINIMUM, FeeType.ADDITIONAL):
|
|
raise ValueError(f"Unexpected fee_mode: {fee_mode!r}")
|
|
|
|
check_results: List[CheckResult] = self._evaluate_cancellation_moment(order, check_ts)
|
|
|
|
return RuleResult.from_process_fee(
|
|
id=self.id,
|
|
partial_results=check_results,
|
|
fee_type=fee_mode,
|
|
absolute_fee=self.fee_cancellation_process,
|
|
reference_price=position_fees,
|
|
)
|