This commit is contained in:
Lukas Bockstaller
2026-03-20 15:54:44 +01:00
parent 1bb2ab28ad
commit 9224c73c7f

View File

@@ -4,9 +4,10 @@ from decimal import Decimal
from functools import reduce
from typing import Callable, Dict, List, Set, Union
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.functional import _StrPromise
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager
@@ -31,10 +32,10 @@ class RelativeFee:
@dataclass(frozen=True)
class CheckResult:
class CheckRes:
cancellation_possible: bool
reason: str | _StrPromise
reason: str
CheckResult=Dict[str, CheckRes]
@dataclass(frozen=True)
class OrderDiff:
@@ -46,8 +47,6 @@ class OrderDiff:
return self.prev.difference(self.next)
CheckResult=Dict[str, CheckResult]
Fee=Union[AbsoluteFee, RelativeFee]
CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult]
@@ -64,7 +63,7 @@ def merge_check_results(a: CheckResult, b: CheckResult) -> CheckResult:
@dataclass(frozen=True)
class Ruling:
rule_id: int
check_results: CheckResult
results: CheckResult
order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0))
position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0)))
@@ -75,7 +74,7 @@ class Ruling:
self,
'cancellation_possible',
all(ruling.cancellation_possible
for ruling in self.check_results.values()
for ruling in self.results.values()
)
)
@@ -94,26 +93,37 @@ class Ruling:
class CancellationRuleQuerySet(models.QuerySet):
def cancellation_possible(self, order: Order):
verdicts = [v[0] for v in self._evaluate(order)]
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, order: Order) -> List[List[Ruling]]:
return [self._evaluate_op(position) for position in order.positions.all()]
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, order_position: OrderPosition) -> List[Ruling]:
consequences=[rule.apply(order_position) for rule in self]
def _evaluate_op(self, diff: OrderDiff, order_position: OrderPosition) -> List[Ruling]:
consequences=[rule.apply(diff, order_position) for rule in self]
consequences.sort()
return consequences
ALLOWED_STATUS_CHARS={char for char, _ in Order.STATUS_CHOICE} # {'n', 'p', 'e', 'c'}
def validate_status_chars(value):
invalid=set(value) - ALLOWED_STATUS_CHARS
if invalid:
raise ValidationError(
f"Invalid characters: {invalid}. Allowed: {ALLOWED_STATUS_CHARS}"
)
if len(value) != len(set(value)):
raise ValidationError("Duplicate characters are not allowed.")
class CancellationRule(models.Model):
"""
"""
organizer=models.ForeignKey(
"Organizer",
related_name="orders",
@@ -127,14 +137,15 @@ class CancellationRule(models.Model):
related_name="orders",
on_delete=models.CASCADE
)
item=models.ForeignKey("Item", on_delete=models.CASCADE, null=True, blank=True)
item_variation=models.ForeignKey("ItemVariation", on_delete=models.CASCADE, null=True, blank=True)
item=models.ForeignKey("Item", on_delete=models.CASCADE, null=True, blank=True) # probably m2m field to avoid duplicating rules
item_variation=models.ForeignKey("ItemVariation", on_delete=models.CASCADE, null=True, blank=True) # probably m2m field to avoid duplicating rules
order_status=models.CharField(
max_length=3,
allowed_if_in_order_status=models.CharField(
max_length=4,
choices=Order.STATUS_CHOICE,
verbose_name=_("Status"),
db_index=True
verbose_name=_("Cancellation possible if order is in status"),
validators=[validate_status_chars]
)
allowed_until=ModelRelativeDateTimeField(null=True, blank=True)
@@ -173,21 +184,24 @@ class CancellationRule(models.Model):
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.partial_checks: List[CheckFn]=[self._system_check_not_discounted]
self._system_check_not_checked_in, self._system_check_not_discounted]
@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: CheckResult(
return {check_id: CheckRes(
cancellation_possible=False,
reason=f"Order position was used",
)}
else:
return {check_id: CheckResult(
return {check_id: CheckRes(
cancellation_possible=True,
reason=f"Order position not yet used",
)}
@@ -208,17 +222,17 @@ class CancellationRule(models.Model):
if order_position in diff.cancellations():
if order_position.discount is None:
return {check_id: CheckResult(
return {check_id: CheckRes(
cancellation_possible=True,
reason=_("Order position was bought without discount"),
)}
else:
return {check_id: CheckResult(
return {check_id: CheckRes(
cancellation_possible=False,
reason=_("Order position was bought with a discount"),
)}
else:
return {check_id: CheckResult(
return {check_id: CheckRes(
cancellation_possible=False,
reason=_("Order position not canceled - check not applicable"),
)}
@@ -226,53 +240,51 @@ class CancellationRule(models.Model):
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
check_id = "TIME_WINDOW"
relevant_event = order_position.subevent or order_position.event
if not self.allowed_until and not self.allowed_until:
return {check_id: CheckResult(
return {check_id: CheckRes(
cancellation_possible=True,
reason=f"No time window specified",
)}
relevant_event=order_position.subevent or order_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: CheckResult(
return {check_id: CheckRes(
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: CheckResult(
return {check_id: CheckRes(
cancellation_possible=False,
reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}",
)}
else:
return {check_id: CheckResult(
return {check_id: CheckRes(
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:
check_id = "ORDER_STATUS"
if not self.order_status:
return {check_id: CheckResult(
if not self.allowed_until and not self.allowed_until:
return {check_id: CheckRes(
cancellation_possible=True,
reason=f"Orders in every status can be cancelled",
)}
elif order_position.order.status == self.order_status:
return {check_id: CheckResult(
elif order_position.order.status in self.allowed_if_in_order_status:
return {check_id: CheckRes(
cancellation_possible=True,
reason=f"Order in required status: '{order_position.order.status}'",
)}
else:
return {check_id: CheckResult(
return {check_id: CheckRes(
cancellation_possible=False,
reason=f"Order in status '{order_position.order.status}' cannot be canceled",
)}
@@ -281,8 +293,8 @@ class CancellationRule(models.Model):
# 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
# Shipping modul kann storno geshippter Items verhindern
# Backend-Anzeige "welche Regel greift da gerade" in der Order
def apply(self, diff: OrderDiff, order_position: OrderPosition) -> Ruling:
check_results=reduce(merge_check_results,
@@ -298,7 +310,7 @@ class CancellationRule(models.Model):
return Ruling(
rule_id=self.id,
check_results=check_results,
results=check_results,
order_fee=self.fee_absolute_per_order,
position_fee=fee
)