diff --git a/src/pretix/base/models/cancellation.py b/src/pretix/base/models/cancellation.py index 8c64fd50bb..809121ecdd 100644 --- a/src/pretix/base/models/cancellation.py +++ b/src/pretix/base/models/cancellation.py @@ -1,30 +1,20 @@ -import dataclasses + from dataclasses import dataclass from decimal import Decimal -from functools import reduce -from typing import Callable, Dict, List, Literal, Optional, Set, Union +from typing import Callable, Dict, List, Literal, Set 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.models import Event, Item, ItemVariation, Order, OrderPosition 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 OrderDiff: order: Order @@ -37,17 +27,22 @@ class OrderDiff: 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 reason: str + # Maps Check identifier → cancellation check result CancellationCheckResultsById = Dict[str, CancellationCheckResult] -CheckFn=Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById] + + +CheckFn = Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById] FeeType = Literal['position_fee', 'process_fee'] + class Ruling: """ A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition. @@ -69,9 +64,7 @@ class Ruling: self.results = results self.fee_type = fee_type self.fee = fee - self.cancellation_possible = all(ruling.cancellation_possible - for ruling in results.values() - ) + self.cancellation_possible = all(ruling.cancellation_possible for ruling in results.values()) @classmethod def from_absolute_fee( @@ -103,7 +96,7 @@ class Ruling: ) -> "Ruling": """ Constructs a Ruling with an absolute fee. - :param rule_id: Id of the rule + :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 @@ -114,7 +107,12 @@ class Ruling: 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)) + 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): @@ -130,7 +128,7 @@ class Ruling: def validate_status_chars(value): - invalid=set(value) - Order.ALLOWED_STATUS_CHARS + invalid = set(value) - Order.ALLOWED_STATUS_CHARS if invalid: raise ValidationError( f"Invalid characters: {invalid}. Allowed: {Order.ALLOWED_STATUS_CHARS}" @@ -139,51 +137,48 @@ def validate_status_chars(value): raise ValidationError("Duplicate characters are not allowed.") - class CancellationRule(models.Model): - event=models.ForeignKey( + event = models.ForeignKey( Event, verbose_name=_("Event"), related_name="orders", on_delete=models.CASCADE ) - all_products=models.BooleanField( + all_products = models.BooleanField( verbose_name=_("All products and variations"), default=True, ) - limit_products=models.ManyToManyField(Item, verbose_name=_("Products"), blank=True) - limit_variations=models.ManyToManyField( + 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( + 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) + allowed_until = ModelRelativeDateTimeField(null=True, blank=True) + except_after = ModelRelativeDateTimeField(null=True, blank=True) - fee_percentage_per_item=models.DecimalField( + fee_percentage_per_item = models.DecimalField( max_digits=5, decimal_places=2, 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( + fee_absolute_per_item = models.DecimalField( max_digits=13, decimal_places=2, verbose_name=_("Absolute fee per OrderPosition"), default=Decimal("0.00"), ) # wird als sum() kombiniert - - fee_cancellation_process=models.DecimalField( + fee_cancellation_process = models.DecimalField( max_digits=13, decimal_places=2, verbose_name=_("Absolute fee per Cancellation"), @@ -200,14 +195,14 @@ class CancellationRule(models.Model): if not self.allowed_until and not self.allowed_until: return {check_id: CancellationCheckResult( cancellation_possible=True, - reason=f"No time window specified", + reason="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 + 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 "" @@ -226,14 +221,13 @@ 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" - if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS): + if diff.order.status == "".join([]): return {check_id: CancellationCheckResult( cancellation_possible=True, - reason=f"Orders in every status can be cancelled", + reason="Orders in every status can be cancelled", )} elif diff.order.status in self.allowed_if_in_order_status: return {check_id: CancellationCheckResult( @@ -245,3 +239,12 @@ class CancellationRule(models.Model): cancellation_possible=False, reason=f"Order in status '{diff.order.status}' cannot be canceled", )} + + +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() diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index bef8d8a936..e74e450e8c 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -203,7 +203,6 @@ class Order(LockModel, LoggedModel): (STATUS_EXPIRED, _("expired")), (STATUS_CANCELED, _("canceled")), ) - ALLOWED_STATUS_CHARS={char for char, _ in STATUS_CHOICE} # {'n', 'p', 'e', 'c'} code = models.CharField( max_length=16, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 672ff17a9a..84ac7b56a9 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -71,7 +71,7 @@ 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, Device, Event, GiftCard, Item, ItemVariation, LogEntry, + CartPosition, Checkin, Device, Event, GiftCard, Item, ItemVariation, LogEntry, Membership, Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher, ) @@ -1633,7 +1633,7 @@ class OrderChangeManager: MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', - 'valid_from', 'valid_until', 'is_bundled', 'result', 'count')) + 'valid_from', 'valid_until', 'is_bundled', 'result')) SplitOperation = namedtuple('SplitOperation', ('position',)) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) @@ -1647,24 +1647,16 @@ class OrderChangeManager: ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple()) class AddPositionResult: - _positions: Optional[List[OrderPosition]] + _position: Optional[OrderPosition] def __init__(self): - self._positions = None + self._position = None @property def position(self) -> OrderPosition: - if self._positions is None: + if self._position is None: raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") - if len(self._positions) != 1: - raise RuntimeError("More than one position created.") - return self._positions[0] - - @property - def positions(self) -> List[OrderPosition]: - if self._positions is None: - raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") - return self._positions + return self._position def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False): self.order = order @@ -1871,12 +1863,8 @@ class OrderChangeManager: def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, - valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult': - if count < 1: - raise ValueError("Count must be positive") + valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult': if isinstance(seat, str): - if count > 1: - raise ValueError("Cannot combine count > 1 with seat") if not seat: seat = None else: @@ -1930,14 +1918,14 @@ class OrderChangeManager: if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): self._invoice_dirty = True - self._totaldiff_guesstimate += price.gross * count - self._quotadiff.update({q: count for q in new_quotas}) + self._totaldiff_guesstimate += price.gross + self._quotadiff.update(new_quotas) if seat: self._seatdiff.update([seat]) result = self.AddPositionResult() self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, - valid_from, valid_until, is_bundled, result, count)) + valid_from, valid_until, is_bundled, result)) return result def split(self, position: OrderPosition): @@ -2557,35 +2545,29 @@ class OrderChangeManager: secret_dirty.remove(position) position.save(update_fields=['canceled', 'secret']) elif isinstance(op, self.AddOperation): - new_pos = [] - new_logs = [] - for i in range(op.count): - pos = OrderPosition.objects.create( - item=op.item, variation=op.variation, addon_to=op.addon_to, - price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, - tax_value=op.price.tax, tax_rule=op.item.tax_rule, - positionid=nextposid, subevent=op.subevent, seat=op.seat, - used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, - is_bundled=op.is_bundled, - ) - nextposid += 1 - new_pos.append(pos) - new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ - 'position': pos.pk, - 'item': op.item.pk, - 'variation': op.variation.pk if op.variation else None, - 'addon_to': op.addon_to.pk if op.addon_to else None, - 'price': op.price.gross, - 'positionid': pos.positionid, - 'membership': pos.used_membership_id, - 'subevent': op.subevent.pk if op.subevent else None, - 'seat': op.seat.pk if op.seat else None, - 'valid_from': op.valid_from.isoformat() if op.valid_from else None, - 'valid_until': op.valid_until.isoformat() if op.valid_until else None, - }, save=False)) - - op.result._positions = new_pos - LogEntry.bulk_create_and_postprocess(new_logs) + pos = OrderPosition.objects.create( + item=op.item, variation=op.variation, addon_to=op.addon_to, + price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, + tax_value=op.price.tax, tax_rule=op.item.tax_rule, + positionid=nextposid, subevent=op.subevent, seat=op.seat, + used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, + is_bundled=op.is_bundled, + ) + nextposid += 1 + self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ + 'position': pos.pk, + 'item': op.item.pk, + 'variation': op.variation.pk if op.variation else None, + 'addon_to': op.addon_to.pk if op.addon_to else None, + 'price': op.price.gross, + 'positionid': pos.positionid, + 'membership': pos.used_membership_id, + 'subevent': op.subevent.pk if op.subevent else None, + 'seat': op.seat.pk if op.seat else None, + 'valid_from': op.valid_from.isoformat() if op.valid_from else None, + 'valid_until': op.valid_until.isoformat() if op.valid_until else None, + }) + op.result._position = pos elif isinstance(op, self.SplitOperation): position = position_cache.setdefault(op.position.pk, op.position) split_positions.append(position) @@ -2910,7 +2892,7 @@ class OrderChangeManager: return total def _check_order_size(self): - if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: + if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: raise OrderError( self.error_messages['max_order_size'] % { 'max': settings.PRETIX_MAX_ORDER_SIZE, @@ -2971,7 +2953,7 @@ class OrderChangeManager: ]) + len([ o for o in self._operations if isinstance(o, self.SplitOperation) ]) - adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)]) + adds = len([o for o in self._operations if isinstance(o, self.AddOperation)]) if current > 0 and current - cancels + adds < 1: raise OrderError(self.error_messages['complete_cancel']) @@ -3018,18 +3000,17 @@ class OrderChangeManager: elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart: fake_cart.remove(positions_to_fake_cart[op.position]) elif isinstance(op, self.AddOperation): - for i in range(op.count): - cp = CartPosition( - event=self.event, - item=op.item, - variation=op.variation, - used_membership=op.membership, - subevent=op.subevent, - seat=op.seat, - ) - cp.override_valid_from = op.valid_from - cp.override_valid_until = op.valid_until - fake_cart.append(cp) + cp = CartPosition( + event=self.event, + item=op.item, + variation=op.variation, + used_membership=op.membership, + subevent=op.subevent, + seat=op.seat, + ) + cp.override_valid_from = op.valid_from + cp.override_valid_until = op.valid_until + fake_cart.append(cp) try: validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode) except ValidationError as e: diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 9a6a8fe7fe..f7efe2d7d5 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -1212,8 +1212,5 @@ 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 facb7a7fe7..6defe63b17 100644 --- a/src/pretix/helpers/database.py +++ b/src/pretix/helpers/database.py @@ -21,6 +21,7 @@ # import contextlib import logging + from django.conf import settings from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured from django.db import connection, transaction @@ -31,6 +32,7 @@ from django.utils.functional import lazy logger = logging.getLogger(__name__) + class DummyRollbackException(Exception): pass diff --git a/src/tests/base/test_self_service_cancellation.py b/src/tests/base/test_self_service_cancellation.py index a0a2c64692..a881706605 100644 --- a/src/tests/base/test_self_service_cancellation.py +++ b/src/tests/base/test_self_service_cancellation.py @@ -8,9 +8,7 @@ from django_scopes import scope from freezegun import freeze_time from pretix.base.models import Event, Item, Order, OrderPosition, Organizer -from pretix.base.models.cancellation import CancellationRule, Ruling -from pretix.base.models.cancellation import CheckRes -from pretix.base.models.cancellation import OrderDiff +from pretix.base.models.cancellation import CancellationRule, OrderDiff NOW = now() DAYS_UNTIL_EVENT=60