diff --git a/src/pretix/base/models/cancellation.py b/src/pretix/base/models/cancellation.py index 809121ecd..a4afe93af 100644 --- a/src/pretix/base/models/cancellation.py +++ b/src/pretix/base/models/cancellation.py @@ -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: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 84ac7b56a..15168acbf 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -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: - ...