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 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.validators import MaxValueValidator, MinValueValidator
@@ -15,19 +16,6 @@ from pretix.base.reldate import ModelRelativeDateTimeField
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)
class CancellationCheckResult:
cancellation_possible: bool
@@ -38,7 +26,7 @@ class 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']
@@ -127,16 +115,6 @@ class Ruling:
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):
event = models.ForeignKey(
Event,
@@ -154,13 +132,7 @@ class CancellationRule(models.Model):
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)
except_after = ModelRelativeDateTimeField(null=True, blank=True)
@@ -187,9 +159,12 @@ class CancellationRule(models.Model):
def __init__(self, *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"
if not self.allowed_until and not self.allowed_until:
@@ -198,7 +173,7 @@ class CancellationRule(models.Model):
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(
relevant_event) if self.allowed_until else False
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)}",
)}
def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
check_id = "ORDER_STATUS"
def check(self, system_check_results: List[CancellationCheckResult], order: Order, keep: Set[OrderPosition], position: OrderPosition) -> Optional[Ruling]:
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([]):
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason="Orders in every status can be cancelled",
)}
elif diff.order.status in self.allowed_if_in_order_status:
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Order in required status: '{diff.order.status}'",
)}
if not self.all_products and position.variation_id not in self.limit_variations.values_list('pk', flat=True):
return None
check_results = [check(order, keep, position) for check in self.checks]
if self.fee_percentage_per_item and self.fee_absolute_per_item:
raise NotImplementedError("Should never be reached")
elif self.fee_absolute_per_item != Decimal(0.00):
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:
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Order in status '{diff.order.status}' cannot be canceled",
)}
return Ruling.from_relative_fee(
rule_id=self.id,
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:

View File

@@ -43,9 +43,7 @@ from decimal import Decimal
from functools import reduce
from itertools import chain
from time import sleep
from typing import Dict
from typing import List, Optional
from typing import Set
from typing import Dict, List, Optional, Set
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
@@ -53,8 +51,8 @@ from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
Value,
Count, Exists, F, IntegerField, Max, Min, OuterRef, Prefetch, Q, QuerySet,
Sum, Value,
)
from django.db.models import Prefetch
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.media import MEDIA_TYPES
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,
SeatCategoryMapping, User, Voucher,
)
from pretix.base.models import Checkin
from pretix.base.models.cancellation import AbsoluteFee, CancellationCheckResult
from pretix.base.models.cancellation import CancellationRule
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.cancellation import (
CancellationCheckResult, CancellationCheckResultsById, CancellationRule,
Ruling, CancellationCheck
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
@@ -117,9 +109,8 @@ from pretix.base.signals import (
order_approved, order_canceled, order_changed, order_denied, order_expired,
order_expiry_changed, order_fee_calculation, order_paid, order_placed,
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.celery_app import app
from pretix.helpers import OF_SELF, ensure_no_queries
@@ -3523,16 +3514,7 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
)
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):
class OrderPositionNotUsedCheck(CancellationCheck):
id = "SYSTEM_TICKET_NOT_USED"
prefetches = [
Prefetch(
@@ -3547,18 +3529,20 @@ class CancellationCheck:
if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {self.id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Order position was used",
reason="Order position was used",
)}
else:
return {self.id: CancellationCheckResult(
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")
def cancellation_checks_not_used(sender: Event):
return OrderPositionNotUsedCheck()
class NotDiscountedCheck(CancellationCheck):
"""
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")
def cancellation_checks_not_discounted(sender: Event):
return NotDiscountedCheck()
# 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 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)]
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()
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()
# Todo get prefetches/selects from rules as well
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]
cancellation_possible = all([r.cancellation_possible for r in effective_rulings])
cancellation_possible = all([r.cancellation_possible for r in effective_position_rulings])
# TODO zusammenführen der Rulings
if dry_run:
return cancellation_possible, rulings
else:
...