diff --git a/src/pretix/base/models/cancellation.py b/src/pretix/base/models/cancellation.py index c9bc4b3883..8f506d90c2 100644 --- a/src/pretix/base/models/cancellation.py +++ b/src/pretix/base/models/cancellation.py @@ -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 )