diff --git a/src/pretix/base/models/cancellation.py b/src/pretix/base/models/cancellation.py index 13472d51aa..8c64fd50bb 100644 --- a/src/pretix/base/models/cancellation.py +++ b/src/pretix/base/models/cancellation.py @@ -2,42 +2,33 @@ import dataclasses from dataclasses import dataclass from decimal import Decimal from functools import reduce -from typing import Callable, Dict, List, Set, Union +from typing import Callable, Dict, List, Literal, Optional, Set, Union from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.db.models import Prefetch +from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ from django_scopes import ScopedManager +from pretix.base.decimal import round_decimal +from pretix.base.models import Checkin from pretix.base.models import Event, Order, OrderPosition +from pretix.base.models import Item +from pretix.base.models import ItemVariation from pretix.base.reldate import ModelRelativeDateTimeField +from pretix.base.services.orders import CancellationCheck +from pretix.base.signals import self_service_cancellation_checks from pretix.base.timemachine import time_machine_now -@dataclass(frozen=True) -class AbsoluteFee: - amount: Decimal - - -@dataclass(frozen=True) -class RelativeFee: - reference_price: Decimal - percentage: Decimal - - @property - def amount(self): - return self.reference_price * (self.percentage/100) - - -Fee=Union[AbsoluteFee, RelativeFee] @dataclass(frozen=True) class OrderDiff: order: Order - prev: Set[OrderPosition] - next: Set[OrderPosition] + keep: Set[OrderPosition] def cancellations(self): return self.prev.difference(self.next) @@ -47,75 +38,97 @@ class OrderDiff: return OrderDiff(order=order, prev=set(order.positions.all()), next=set()) @dataclass(frozen=True) -class CheckRes: +class CancellationCheckResult: cancellation_possible: bool reason: str -CheckResult=Dict[str, CheckRes] +# Maps Check identifier → cancellation check result +CancellationCheckResultsById = Dict[str, CancellationCheckResult] +CheckFn=Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById] +FeeType = Literal['position_fee', 'process_fee'] -CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult] - - -def merge_check_results(a: CheckResult, b: CheckResult) -> CheckResult: - result = dict(a) - for key, b_inner in b.items(): - if key in result: - result[key] = result[key] | b_inner - else: - result[key] = b_inner - return result - - -@dataclass(frozen=True) class Ruling: + """ + A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition. + """ rule_id: int - results: CheckResult - order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0)) - position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0))) + results: CancellationCheckResultsById + fee_type: FeeType + fee: Decimal + cancellation_possible: bool - cancellation_possible: bool=dataclasses.field(init=False) - - def __post_init__(self): - object.__setattr__( + def __init__( self, - 'cancellation_possible', - all(ruling.cancellation_possible - for ruling in self.results.values() + rule_id: int, + results: CancellationCheckResultsById, + fee_type: FeeType, + fee: Decimal + ): + self.rule_id = rule_id + self.results = results + self.fee_type = fee_type + self.fee = fee + self.cancellation_possible = all(ruling.cancellation_possible + for ruling in results.values() ) - ) - @property - def total_fee(self): - return self.position_fee.amount + self.order_fee + @classmethod + def from_absolute_fee( + cls, + rule_id: int, + results: CancellationCheckResultsById, + fee_type: FeeType, + absolute_fee: Decimal + ) -> "Ruling": + """ + Constructs a Ruling with an absolute fee. + :param rule_id: Id of the rule + :param results: CheckResult object + :param fee_type: If the fee is calculated for a position or process fee + :param absolute_fee: amount of the fee + :return: + """ + return Ruling(rule_id=rule_id, results=results, fee_type=fee_type, fee=absolute_fee) + + @classmethod + def from_relative_fee( + cls, + rule_id: int, + results: CancellationCheckResultsById, + fee_type: Literal['position_fee'], + reference_price: Decimal, + percentage: Decimal, + currency: str + ) -> "Ruling": + """ + Constructs a Ruling with an absolute fee. + :param rule_id: Id of the rule + :param results: CheckResult object + :param fee_type: Must be a position_fee as the fee can only be in reference to a position + :param reference_price: Price of the position to reference + :param percentage: Percentage of the reference_price set as the fee + :param currency: Currency of the reference_price, used for correct rounding of the fee + :return: + """ + if fee_type == "process_fee": + raise ValidationError("Process fee cannot be used with relative fees") + + return Ruling(rule_id=rule_id, results=results, fee_type=fee_type, fee=round_decimal(reference_price * (percentage/100), currency)) def __lt__(self, other): if not isinstance(other, Ruling): return NotImplemented + if self.fee_type != other.fee_type: + return NotImplemented + if self.cancellation_possible == other.cancellation_possible: - return self.total_fee < other.total_fee + return self.fee < other.fee else: return self.cancellation_possible and not other.cancellation_possible -class CancellationRuleQuerySet(models.QuerySet): - 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, diff: OrderDiff) -> List[List[Ruling]]: - return [self._evaluate_op(diff, position) for position in diff.order.positions.all()] - - def _evaluate_op(self, diff: OrderDiff, order_position: OrderPosition) -> List[Ruling]: - consequences = [] - for rule in self: - if order_position.item in rule.items.all() or order_position.variation in rule.variations.all(): - consequences.append(rule.apply(diff, order_position)) - consequences.sort() - return consequences - - def validate_status_chars(value): invalid=set(value) - Order.ALLOWED_STATUS_CHARS if invalid: @@ -126,33 +139,25 @@ def validate_status_chars(value): raise ValidationError("Duplicate characters are not allowed.") + class CancellationRule(models.Model): - """ - - - """ - organizer=models.ForeignKey( - "Organizer", - related_name="orders", - on_delete=models.CASCADE, - null=True, - blank=True, - ) event=models.ForeignKey( Event, verbose_name=_("Event"), related_name="orders", on_delete=models.CASCADE ) - items = models.ManyToManyField( - "Item", - verbose_name=_("Items"), + + all_products=models.BooleanField( + verbose_name=_("All products and variations"), + default=True, ) - variations=models.ManyToManyField( - "ItemVariation", - verbose_name=_("Item variations"), + limit_products=models.ManyToManyField(Item, verbose_name=_("Products"), blank=True) + limit_variations=models.ManyToManyField( + ItemVariation, blank=True, verbose_name=_("Variations") ) + allowed_if_in_order_status=models.CharField( max_length=4, choices=Order.STATUS_CHOICE, @@ -166,92 +171,34 @@ class CancellationRule(models.Model): fee_percentage_per_item=models.DecimalField( max_digits=5, decimal_places=2, - max_value=Decimal("100.00"), - min_value=Decimal("0.00"), - verbose_name=_("Fee Percentage per Item"), + validators=[MinValueValidator("0.00"), MaxValueValidator("100.00")], + verbose_name=_("Fee Percentage per OrderPosition"), default=Decimal("0.00"), ) # wird als sum() kombiniert fee_absolute_per_item=models.DecimalField( max_digits=13, decimal_places=2, - verbose_name=_("Absolute Fee per Item"), + verbose_name=_("Absolute fee per OrderPosition"), default=Decimal("0.00"), ) # wird als sum() kombiniert - fee_absolute_per_order=models.DecimalField( + + + fee_cancellation_process=models.DecimalField( max_digits=13, decimal_places=2, - verbose_name=_("Absolute Fee per Cancellation"), + verbose_name=_("Absolute fee per Cancellation"), default=Decimal("0.00"), ) # wird als max() kombiniert - objects=ScopedManager(CancellationRuleQuerySet.as_manager().__class__, organizer='organizer', - event='event') - 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._system_check_not_discounted] + self.checks: List[CheckFn] = [self._check_order_status, self._check_time_window] - - # 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 - # OrderPositions mit is_bundled dürfen nur mit der Parent-Position zusammen storniert werden - - @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: CheckRes( - cancellation_possible=False, - reason=f"Order position was used", - )} - else: - return {check_id: CheckRes( - cancellation_possible=True, - reason=f"Order position not yet used", - )} - - @staticmethod - def _system_check_not_discounted(diff: OrderDiff, order_position: OrderPosition) -> CheckResult: - """ - Check that ensures that orders containing discounted order_positions cannot - be canceled partially. - This is a stop-gap solution until the `discount_grouper` attribute for - AbstractPositions is introduced, allowing us to be more grannular - - :param diff: - :param order_position: - :return CheckResults: - """ - check_id = "SYSTEM_TICKET_NOT_DISCOUNTED" - - if order_position in diff.cancellations(): - if order_position.discount_id is None: - return {check_id: CheckRes( - cancellation_possible=True, - reason=_("Order position was bought without discount"), - )} - else: - return {check_id: CheckRes( - cancellation_possible=False, - reason=_("Order position was bought with a discount"), - )} - else: - return {check_id: CheckRes( - cancellation_possible=False, - reason=_("Order position not canceled - check not applicable"), - )} - - def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult: + def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById: check_id = "TIME_WINDOW" if not self.allowed_until and not self.allowed_until: - return {check_id: CheckRes( + return {check_id: CancellationCheckResult( cancellation_possible=True, reason=f"No time window specified", )} @@ -264,57 +211,37 @@ class CancellationRule(models.Model): 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: CheckRes( + return {check_id: CancellationCheckResult( 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: CheckRes( + return {check_id: CancellationCheckResult( cancellation_possible=False, reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}", )} else: - return {check_id: CheckRes( + return {check_id: CancellationCheckResult( 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: + def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById: check_id = "ORDER_STATUS" if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS): - return {check_id: CheckRes( + return {check_id: CancellationCheckResult( cancellation_possible=True, reason=f"Orders in every status can be cancelled", )} elif diff.order.status in self.allowed_if_in_order_status: - return {check_id: CheckRes( + return {check_id: CancellationCheckResult( cancellation_possible=True, reason=f"Order in required status: '{diff.order.status}'", )} else: - return {check_id: CheckRes( + return {check_id: CancellationCheckResult( cancellation_possible=False, reason=f"Order in status '{diff.order.status}' cannot be canceled", )} - - - def apply(self, diff: OrderDiff, order_position: OrderPosition) -> Ruling: - check_results=reduce(merge_check_results, - [rule(diff, order_position) for rule 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: - fee=AbsoluteFee(self.fee_absolute_per_item) - else: - fee=RelativeFee(percentage=self.fee_percentage_per_item, - reference_price=order_position.price) - - return Ruling( - rule_id=self.id, - results=check_results, - order_fee=self.fee_absolute_per_order, - position_fee=fee - ) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index ba3e199b98..672ff17a9a 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -41,8 +41,11 @@ from collections import Counter, defaultdict, namedtuple from datetime import datetime, time, timedelta 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 celery.exceptions import MaxRetriesExceededError from django.conf import settings @@ -53,6 +56,7 @@ from django.db.models import ( Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum, Value, ) +from django.db.models import Prefetch from django.db.models.functions import Coalesce, Greatest from django.db.transaction import get_connection from django.dispatch import receiver @@ -71,6 +75,16 @@ from pretix.base.models import ( 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.event import SubEvent from pretix.base.models.orders import ( BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund, @@ -105,9 +119,10 @@ from pretix.base.signals import ( order_reactivated, order_split, order_valid_if_pending, periodic_task, 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 +from pretix.helpers import OF_SELF, ensure_no_queries from pretix.helpers.models import modelcopy from pretix.helpers.periodic import minimum_interval from pretix.testutils.middleware import debugflags_var @@ -3525,3 +3540,171 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs): 'customer': order.customer_id, } ) + + + +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" + prefetches = [ + Prefetch( + 'checkins', + queryset=Checkin.objects.filter(list__consider_tickets_used=True), + to_attr='used_checkins' # stores result in a list attribute + ) + ] + related_selects = [] + + def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResultsById: + if order_position.checkins.filter(list__consider_tickets_used=True).exists(): + return {self.id: CancellationCheckResult( + cancellation_possible=False, + reason=f"Order position was used", + )} + else: + return {self.id: CancellationCheckResult( + cancellation_possible=True, + reason=f"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 + be canceled partially. + This is a stop-gap solution until the `discount_grouper` attribute for + AbstractPositions is introduced, allowing us to be more grannular + """ + + id = "SYSTEM_NO_DISCOUNTED_ORDER_POSITIONS" + prefetches = [ + ] + related_selects = [] + + def check(self, order: Order, keep: Set[OrderPosition], order_position: OrderPosition) -> CancellationCheckResultsById: + cancellations = Set(order.positions).difference(keep) + + if order_position in cancellations: + if order_position.discount_id is None: + return {self.id: CancellationCheckResult( + cancellation_possible=True, + reason=_("Order position was bought without discount"), + )} + else: + return {self.id: CancellationCheckResult( + cancellation_possible=False, + reason=_("Order position was bought with a discount"), + )} + else: + return {self.id: CancellationCheckResult( + cancellation_possible=False, + reason=_("Order position not canceled - check not applicable"), + )} + + + +@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 +# OrderPositions mit is_bundled dürfen nur mit der Parent-Position zusammen storniert werden + +# TODO transaktion +def self_service_cancel(order: Order, keep: Set[OrderPosition], dry_run: bool): + """ + + :param order: + :param keep: + :param dry_run: + :return: + """ + 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() + + # Todo get prefetches/selects from rules as well + prefetches = list(chain.from_iterable([cc.prefetches for cc in cancellation_checks])) + related_selects = list(chain.from_iterable(cc.related_selects for cc in cancellation_checks)) + + per_position_rulings: Dict[int, List[Ruling]] = {} + + prefetched_order = Order.objects.select_related(related_selects).prefetch_related(*prefetches).get(pk=order.pk) + # All queries should be done by now + with ensure_no_queries(): + for position in prefetched_order.positions: + position_rulings = [] + + system_check_results = [cc.check(prefetched_order, keep, position) for cc in cancellation_checks] + + for rule in position_rules: + check_results = [check(prefetched_order, keep, position) for check in rule.checks] + + if rule.fee_percentage_per_item and rule.fee_absolute_per_item: + raise NotImplementedError("Should never be reached") + elif rule.fee_absolute_per_item != Decimal(0.00): + position_rulings.append( + Ruling.from_absolute_fee( + rule_id=rule.id, + results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}), + fee_type='position_fee', + absolute_fee=rule.fee_absolute_per_item + ) + ) + else: + position_rulings.append( + Ruling.from_relative_fee( + rule_id=rule.id, + results=reduce(lambda a, b: a | b, [*system_check_results, *check_results], {}), + fee_type='position_fee', + reference_price=position.price, + percentage=rule.fee_absolute_per_item, + currency=order.event.currency + ) + ) + position_rulings.sort() + per_position_rulings[position.id] = position_rulings + effective_position_rulings = [op_rulings[0] for op_rulings in per_position_rulings.values()] + + process_rulings: List[Ruling] = [] + + for rule in process_rules: + check_results = [check(prefetched_order, keep, position) for check in rule.checks] + + process_rulings.append(Ruling.from_absolute_fee( + rule_id=rule.id, + results=reduce(lambda a, b: a | b, [*check_results], {}), + fee_type='process_fee', + absolute_fee=rule.fee_cancellation_process + )) + + process_rulings.sort() + + effective_process_ruling = process_rulings[0] + + + + cancellation_possible = all([r.cancellation_possible for r in effective_rulings]) + + # TODO zusammenführen der Rulings + + if dry_run: + return cancellation_possible, rulings + else: + ... diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 108b24b0f6..9a6a8fe7fe 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -1206,3 +1206,14 @@ This signal is sent out each time the information for a Device is modified. Both the original and updated versions of the Device are included to allow receivers to see what has been updated. """ + +self_service_cancellation_checks = EventPluginSignal() +""" +This signal is sent out to collect checks to approve or deny a self service cancellation. +You are expected to return a class instance that implements CancellationCheck. +It is is expected that that the CheckFn will not issue any further queries. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. + + +""" diff --git a/src/pretix/helpers/database.py b/src/pretix/helpers/database.py index 1289f4f1af..facb7a7fe7 100644 --- a/src/pretix/helpers/database.py +++ b/src/pretix/helpers/database.py @@ -20,7 +20,7 @@ # . # import contextlib - +import logging from django.conf import settings from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured from django.db import connection, transaction @@ -29,6 +29,7 @@ from django.db.models import ( ) from django.utils.functional import lazy +logger = logging.getLogger(__name__) class DummyRollbackException(Exception): pass @@ -280,3 +281,21 @@ def get_deterministic_ordering(model, ordering): # on the primary key to provide total ordering. ordering.append("-pk") return ordering + + +@contextlib.contextmanager +def ensure_no_queries(): + """ + Ensures that no database queries are being made in that context. + Raises a RuntimeError if running in DEBUG mode, otherwise logs + an error. + :return: + """ + def blocker(*args, **kwargs): + if settings.DEBUG: + raise RuntimeError(f"Unexpected DB query: {args[0]}") + else: + logger.error("Unexpected DB query: %s", args[0]) + + with connection.execute_wrapper(blocker): + yield