This commit is contained in:
Lukas Bockstaller
2026-04-08 11:09:56 +02:00
parent 5ab3b08fca
commit 2e673b5e49
2 changed files with 52 additions and 94 deletions

View File

@@ -1,7 +1,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Callable, Dict, List, Literal, Set from functools import reduce
from typing import Callable, Dict, List, Literal, Optional, Set
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
@@ -15,19 +16,6 @@ from pretix.base.reldate import ModelRelativeDateTimeField
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
@dataclass(frozen=True)
class OrderDiff:
order: Order
keep: Set[OrderPosition]
def cancellations(self):
return self.prev.difference(self.next)
@staticmethod
def cancel_all(order: Order) -> "OrderDiff":
return OrderDiff(order=order, prev=set(order.positions.all()), next=set())
@dataclass(frozen=True) @dataclass(frozen=True)
class CancellationCheckResult: class CancellationCheckResult:
cancellation_possible: bool cancellation_possible: bool
@@ -38,7 +26,7 @@ class CancellationCheckResult:
CancellationCheckResultsById = Dict[str, CancellationCheckResult] CancellationCheckResultsById = Dict[str, CancellationCheckResult]
CheckFn = Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById] CheckFn = Callable[[Order, Set[OrderPosition], Optional[OrderPosition]], CancellationCheckResultsById]
FeeType = Literal['position_fee', 'process_fee'] FeeType = Literal['position_fee', 'process_fee']
@@ -127,16 +115,6 @@ class Ruling:
return self.cancellation_possible and not other.cancellation_possible return self.cancellation_possible and not other.cancellation_possible
def validate_status_chars(value):
invalid = set(value) - Order.ALLOWED_STATUS_CHARS
if invalid:
raise ValidationError(
f"Invalid characters: {invalid}. Allowed: {Order.ALLOWED_STATUS_CHARS}"
)
if len(value) != len(set(value)):
raise ValidationError("Duplicate characters are not allowed.")
class CancellationRule(models.Model): class CancellationRule(models.Model):
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
@@ -154,13 +132,7 @@ class CancellationRule(models.Model):
ItemVariation, blank=True, verbose_name=_("Variations") ItemVariation, blank=True, verbose_name=_("Variations")
) )
allowed_if_in_order_status = models.CharField(
max_length=4,
choices=Order.STATUS_CHOICE,
verbose_name=_("Cancellation possible if order is in status"),
validators=[validate_status_chars],
default="".join(Order.ALLOWED_STATUS_CHARS),
)
allowed_until = ModelRelativeDateTimeField(null=True, blank=True) allowed_until = ModelRelativeDateTimeField(null=True, blank=True)
except_after = ModelRelativeDateTimeField(null=True, blank=True) except_after = ModelRelativeDateTimeField(null=True, blank=True)
@@ -187,9 +159,12 @@ class CancellationRule(models.Model):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.checks: List[CheckFn] = [self._check_order_status, self._check_time_window] self.checks: List[CheckFn] = [self._check_time_window]
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
# TODO implement order status check
def _check_time_window(self, order: Order, keep: Set[OrderPosition], position: OrderPosition) -> CancellationCheckResultsById:
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:
@@ -198,7 +173,7 @@ class CancellationRule(models.Model):
reason="No time window specified", reason="No time window specified",
)} )}
relevant_event = order_position.subevent or order_position.event relevant_event = position.subevent or 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(
@@ -221,24 +196,33 @@ class CancellationRule(models.Model):
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) -> CancellationCheckResultsById: def check(self, system_check_results: List[CancellationCheckResult], order: Order, keep: Set[OrderPosition], position: OrderPosition) -> Optional[Ruling]:
check_id = "ORDER_STATUS" if not self.all_products and position.item_id not in self.limit_products.values_list('pk', flat=True):
return None
if diff.order.status == "".join([]): if not self.all_products and position.variation_id not in self.limit_variations.values_list('pk', flat=True):
return {check_id: CancellationCheckResult( return None
cancellation_possible=True,
reason="Orders in every status can be cancelled", check_results = [check(order, keep, position) for check in self.checks]
)}
elif diff.order.status in self.allowed_if_in_order_status: if self.fee_percentage_per_item and self.fee_absolute_per_item:
return {check_id: CancellationCheckResult( raise NotImplementedError("Should never be reached")
cancellation_possible=True, elif self.fee_absolute_per_item != Decimal(0.00):
reason=f"Order in required status: '{diff.order.status}'", return Ruling.from_absolute_fee(
)} rule_id=self.id,
results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
fee_type='position_fee',
absolute_fee=self.fee_absolute_per_item
)
else: else:
return {check_id: CancellationCheckResult( return Ruling.from_relative_fee(
cancellation_possible=False, rule_id=self.id,
reason=f"Order in status '{diff.order.status}' cannot be canceled", results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}),
)} fee_type='position_fee',
reference_price=position.price,
percentage=self.fee_absolute_per_item,
currency=order.event.currency
)
class CancellationCheck: class CancellationCheck:

View File

@@ -43,9 +43,7 @@ from decimal import Decimal
from functools import reduce from functools import reduce
from itertools import chain from itertools import chain
from time import sleep from time import sleep
from typing import Dict from typing import Dict, List, Optional, Set
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,8 +51,8 @@ from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.db.models import ( from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum, Count, Exists, F, IntegerField, Max, Min, OuterRef, Prefetch, Q, QuerySet,
Value, Sum, Value,
) )
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models.functions import Coalesce, Greatest from django.db.models.functions import Coalesce, Greatest
@@ -71,20 +69,14 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Checkin, Device, Event, GiftCard, Item, ItemVariation, LogEntry, CartPosition, Checkin, Device, Event, GiftCard, Item, ItemVariation,
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 (
from pretix.base.models.cancellation import AbsoluteFee, CancellationCheckResult CancellationCheckResult, CancellationCheckResultsById, CancellationRule,
from pretix.base.models.cancellation import CancellationRule Ruling, CancellationCheck
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,
@@ -117,9 +109,8 @@ from pretix.base.signals import (
order_approved, order_canceled, order_changed, order_denied, order_expired, order_approved, order_canceled, order_changed, order_denied, order_expired,
order_expiry_changed, order_fee_calculation, order_paid, order_placed, order_expiry_changed, order_fee_calculation, order_paid, order_placed,
order_reactivated, order_split, order_valid_if_pending, periodic_task, order_reactivated, order_split, order_valid_if_pending, periodic_task,
validate_order, self_service_cancellation_checks, 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, ensure_no_queries from pretix.helpers import OF_SELF, ensure_no_queries
@@ -3523,16 +3514,7 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
) )
class OrderPositionNotUsedCheck(CancellationCheck):
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" id = "SYSTEM_TICKET_NOT_USED"
prefetches = [ prefetches = [
Prefetch( Prefetch(
@@ -3547,18 +3529,20 @@ class CancellationCheck:
if order_position.checkins.filter(list__consider_tickets_used=True).exists(): if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {self.id: CancellationCheckResult( return {self.id: CancellationCheckResult(
cancellation_possible=False, cancellation_possible=False,
reason=f"Order position was used", reason="Order position was used",
)} )}
else: else:
return {self.id: CancellationCheckResult( return {self.id: CancellationCheckResult(
cancellation_possible=True, cancellation_possible=True,
reason=f"Order position not yet used", reason="Order position not yet used",
)} )}
@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_used") @receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_used")
def cancellation_checks_not_used(sender: Event): def cancellation_checks_not_used(sender: Event):
return OrderPositionNotUsedCheck() return OrderPositionNotUsedCheck()
class NotDiscountedCheck(CancellationCheck): class NotDiscountedCheck(CancellationCheck):
""" """
Check that ensures that orders containing discounted order_positions cannot Check that ensures that orders containing discounted order_positions cannot
@@ -3593,14 +3577,11 @@ class NotDiscountedCheck(CancellationCheck):
)} )}
@receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_discountend") @receiver(self_service_cancellation_checks, dispatch_uid="pretixbase_not_discountend")
def cancellation_checks_not_discounted(sender: Event): def cancellation_checks_not_discounted(sender: Event):
return NotDiscountedCheck() return NotDiscountedCheck()
# TODO weitere System Checks # 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 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
@@ -3617,8 +3598,8 @@ def self_service_cancel(order: Order, keep: Set[OrderPosition], dry_run: bool):
""" """
cancellation_checks: List[CancellationCheck] = [resp for recv, resp in self_service_cancellation_checks.send(event=order.event)] 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() 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() process_rules = CancellationRule.objects.filter(event=order.event).filter("fee_cancellation_process" != Decimal("0.00")).all()
# Todo get prefetches/selects from rules as well # Todo get prefetches/selects from rules as well
prefetches = list(chain.from_iterable([cc.prefetches for cc in cancellation_checks])) prefetches = list(chain.from_iterable([cc.prefetches for cc in cancellation_checks]))
@@ -3679,13 +3660,6 @@ def self_service_cancel(order: Order, keep: Set[OrderPosition], dry_run: bool):
effective_process_ruling = process_rulings[0] effective_process_ruling = process_rulings[0]
cancellation_possible = all([r.cancellation_possible for r in effective_position_rulings])
cancellation_possible = all([r.cancellation_possible for r in effective_rulings])
# TODO zusammenführen der Rulings # TODO zusammenführen der Rulings
if dry_run:
return cancellation_possible, rulings
else:
...