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 functools import reduce
from typing import Callable, Dict, List, Set, Union from typing import Callable, Dict, List, Set, Union
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.utils.functional import _StrPromise
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
@@ -31,10 +32,10 @@ class RelativeFee:
@dataclass(frozen=True) @dataclass(frozen=True)
class CheckResult: class CheckRes:
cancellation_possible: bool cancellation_possible: bool
reason: str | _StrPromise reason: str
CheckResult=Dict[str, CheckRes]
@dataclass(frozen=True) @dataclass(frozen=True)
class OrderDiff: class OrderDiff:
@@ -46,8 +47,6 @@ class OrderDiff:
return self.prev.difference(self.next) return self.prev.difference(self.next)
CheckResult=Dict[str, CheckResult]
Fee=Union[AbsoluteFee, RelativeFee] Fee=Union[AbsoluteFee, RelativeFee]
CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult] CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult]
@@ -64,7 +63,7 @@ def merge_check_results(a: CheckResult, b: CheckResult) -> CheckResult:
@dataclass(frozen=True) @dataclass(frozen=True)
class Ruling: class Ruling:
rule_id: int rule_id: int
check_results: CheckResult results: CheckResult
order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0)) order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0))
position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0))) position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0)))
@@ -75,7 +74,7 @@ class Ruling:
self, self,
'cancellation_possible', 'cancellation_possible',
all(ruling.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): class CancellationRuleQuerySet(models.QuerySet):
def cancellation_possible(self, order: Order): def cancellation_possible(self, diff: OrderDiff):
verdicts = [v[0] for v in self._evaluate(order)] verdicts = [v[0] for v in self._evaluate(diff)]
return all(v.cancellation_possible for v in verdicts), verdicts return all(v.cancellation_possible for v in verdicts), verdicts
def _evaluate(self, order: Order) -> List[List[Ruling]]: def _evaluate(self, diff: OrderDiff) -> List[List[Ruling]]:
return [self._evaluate_op(position) for position in order.positions.all()] return [self._evaluate_op(diff, position) for position in diff.order.positions.all()]
def _evaluate_op(self, order_position: OrderPosition) -> List[Ruling]: def _evaluate_op(self, diff: OrderDiff, order_position: OrderPosition) -> List[Ruling]:
consequences=[rule.apply(order_position) for rule in self] consequences=[rule.apply(diff, order_position) for rule in self]
consequences.sort() consequences.sort()
return consequences 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): class CancellationRule(models.Model):
""" """
""" """
organizer=models.ForeignKey( organizer=models.ForeignKey(
"Organizer", "Organizer",
related_name="orders", related_name="orders",
@@ -127,14 +137,15 @@ class CancellationRule(models.Model):
related_name="orders", related_name="orders",
on_delete=models.CASCADE on_delete=models.CASCADE
) )
item=models.ForeignKey("Item", 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) 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, choices=Order.STATUS_CHOICE,
verbose_name=_("Status"), verbose_name=_("Cancellation possible if order is in status"),
db_index=True validators=[validate_status_chars]
) )
allowed_until=ModelRelativeDateTimeField(null=True, blank=True) allowed_until=ModelRelativeDateTimeField(null=True, blank=True)
@@ -173,21 +184,24 @@ class CancellationRule(models.Model):
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
# 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.checks: List[CheckFn]=[self._check_order_status, self._check_time_window,
self._system_check_not_checked_in] self._system_check_not_checked_in, self._system_check_not_discounted]
self.partial_checks: List[CheckFn]=[self._system_check_not_discounted]
@staticmethod @staticmethod
def _system_check_not_checked_in(diff: OrderDiff, order_position: OrderPosition) -> CheckResult: def _system_check_not_checked_in(diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
check_id = "SYSTEM_TICKET_NOT_USED" check_id = "SYSTEM_TICKET_NOT_USED"
if order_position.checkins.filter(list__consider_tickets_used=True).exists(): if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {check_id: CheckResult( return {check_id: CheckRes(
cancellation_possible=False, cancellation_possible=False,
reason=f"Order position was used", reason=f"Order position was used",
)} )}
else: else:
return {check_id: CheckResult( return {check_id: CheckRes(
cancellation_possible=True, cancellation_possible=True,
reason=f"Order position not yet used", reason=f"Order position not yet used",
)} )}
@@ -208,17 +222,17 @@ class CancellationRule(models.Model):
if order_position in diff.cancellations(): if order_position in diff.cancellations():
if order_position.discount is None: if order_position.discount is None:
return {check_id: CheckResult( return {check_id: CheckRes(
cancellation_possible=True, cancellation_possible=True,
reason=_("Order position was bought without discount"), reason=_("Order position was bought without discount"),
)} )}
else: else:
return {check_id: CheckResult( return {check_id: CheckRes(
cancellation_possible=False, cancellation_possible=False,
reason=_("Order position was bought with a discount"), reason=_("Order position was bought with a discount"),
)} )}
else: else:
return {check_id: CheckResult( return {check_id: CheckRes(
cancellation_possible=False, cancellation_possible=False,
reason=_("Order position not canceled - check not applicable"), 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: def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
check_id = "TIME_WINDOW" check_id = "TIME_WINDOW"
relevant_event = order_position.subevent or order_position.event
if not self.allowed_until and not self.allowed_until: if not self.allowed_until and not self.allowed_until:
return {check_id: CheckResult( return {check_id: CheckRes(
cancellation_possible=True, cancellation_possible=True,
reason=f"No time window specified", 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( in_allowed_until=time_machine_now() < self.allowed_until.datetime(
relevant_event) if self.allowed_until else False relevant_event) if self.allowed_until else False
in_exemption=time_machine_now() > self.except_after.datetime( in_exemption=time_machine_now() > self.except_after.datetime(
relevant_event) if self.except_after else False relevant_event) if self.except_after else False
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: CheckResult(
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: CheckResult( return {check_id: CheckRes(
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: CheckResult( return {check_id: CheckRes(
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) -> CheckResult:
check_id = "ORDER_STATUS" check_id = "ORDER_STATUS"
if not self.order_status: if not self.allowed_until and not self.allowed_until:
return {check_id: CheckResult( return {check_id: CheckRes(
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 order_position.order.status == self.order_status: elif order_position.order.status in self.allowed_if_in_order_status:
return {check_id: CheckResult( return {check_id: CheckRes(
cancellation_possible=True, cancellation_possible=True,
reason=f"Order in required status: '{order_position.order.status}'", reason=f"Order in required status: '{order_position.order.status}'",
)} )}
else: else:
return {check_id: CheckResult( return {check_id: CheckRes(
cancellation_possible=False, cancellation_possible=False,
reason=f"Order in status '{order_position.order.status}' cannot be canceled", 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 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 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 # 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: def apply(self, diff: OrderDiff, order_position: OrderPosition) -> Ruling:
check_results=reduce(merge_check_results, check_results=reduce(merge_check_results,
@@ -298,7 +310,7 @@ class CancellationRule(models.Model):
return Ruling( return Ruling(
rule_id=self.id, rule_id=self.id,
check_results=check_results, results=check_results,
order_fee=self.fee_absolute_per_order, order_fee=self.fee_absolute_per_order,
position_fee=fee position_fee=fee
) )