mirror of
https://github.com/pretix/pretix.git
synced 2026-05-07 15:34:02 +00:00
Allow to cancel all orders in an event (#1596)
* Allow to cancel all orders in an event * Add tests * Actually add tests
This commit is contained in:
190
src/pretix/base/services/cancelevent.py
Normal file
190
src/pretix/base/services/cancelevent.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Count, Exists, IntegerField, OuterRef, Subquery, Sum,
|
||||
)
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Event, InvoiceAddress, Order, OrderFee, OrderPosition, SubEvent, User,
|
||||
)
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
|
||||
)
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.celery_app import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
||||
refund_amount: Decimal, user: User, positions: list):
|
||||
with language(order.locale):
|
||||
try:
|
||||
ia = order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = InvoiceAddress()
|
||||
|
||||
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
|
||||
order=order, position_or_address=ia, event=order.event)
|
||||
try:
|
||||
order.send_mail(
|
||||
subject, message, email_context,
|
||||
'pretix.event.order.email.event_canceled',
|
||||
user,
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order canceled email could not be sent')
|
||||
|
||||
for p in positions:
|
||||
if subevent and p.subevent_id != subevent.id:
|
||||
continue
|
||||
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
email_context = get_email_context(event_or_subevent=subevent or order.event,
|
||||
event=order.event,
|
||||
refund_amount=refund_amount,
|
||||
position_or_address=p,
|
||||
order=order, position=p)
|
||||
try:
|
||||
order.send_mail(
|
||||
subject, message, email_context,
|
||||
'pretix.event.order.email.event_canceled',
|
||||
position=p,
|
||||
user=user
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order canceled email could not be sent to attendee')
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str,
|
||||
keep_fee_percentage: str, keep_fees: bool, send: bool, send_subject: dict,
|
||||
send_message: dict, user: int):
|
||||
send_subject = LazyI18nString(send_subject)
|
||||
send_message = LazyI18nString(send_message)
|
||||
if user:
|
||||
user = User.objects.get(pk=user)
|
||||
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||
orders_to_cancel = event.orders.annotate(pcnt=Subquery(s, output_field=IntegerField())).filter(
|
||||
status__in=[Order.STATUS_PAID, Order.STATUS_PENDING, Order.STATUS_EXPIRED],
|
||||
pcnt__gt=0
|
||||
).all()
|
||||
|
||||
if subevent:
|
||||
subevent = event.subevents.get(pk=subevent)
|
||||
|
||||
has_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).filter(
|
||||
subevent=subevent
|
||||
)
|
||||
has_other_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).exclude(
|
||||
subevent=subevent
|
||||
)
|
||||
orders_to_change = orders_to_cancel.annotate(
|
||||
has_subevent=Exists(has_subevent),
|
||||
has_other_subevent=Exists(has_other_subevent),
|
||||
).filter(
|
||||
has_subevent=True, has_other_subevent=True
|
||||
)
|
||||
orders_to_cancel = orders_to_cancel.annotate(
|
||||
has_subevent=Exists(has_subevent),
|
||||
has_other_subevent=Exists(has_other_subevent),
|
||||
).filter(
|
||||
has_subevent=True, has_other_subevent=False
|
||||
)
|
||||
|
||||
subevent.active = False
|
||||
subevent.save(update_fields=['active'])
|
||||
subevent.log_action(
|
||||
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
else:
|
||||
orders_to_change = event.orders.none()
|
||||
|
||||
for i in event.items.filter(active=True):
|
||||
i.active = False
|
||||
i.save(update_fields=['active'])
|
||||
i.log_action(
|
||||
'pretix.event.item.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
failed = 0
|
||||
|
||||
for o in orders_to_cancel.only('id', 'total'):
|
||||
try:
|
||||
refund_amount = Decimal('0.00')
|
||||
|
||||
fee = Decimal('0.00')
|
||||
if keep_fees:
|
||||
fee += o.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
|
||||
OrderFee.FEE_TYPE_CANCELLATION)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
if keep_fee_percentage:
|
||||
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * (o.total - fee)
|
||||
if keep_fee_fixed:
|
||||
fee += Decimal(keep_fee_fixed)
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
|
||||
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee)
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk)
|
||||
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
|
||||
except LockTimeoutException:
|
||||
logger.exception("Could not cancel order")
|
||||
failed += 1
|
||||
except OrderError:
|
||||
logger.exception("Could not cancel order")
|
||||
failed += 1
|
||||
|
||||
for o in orders_to_change.values_list('id', flat=True):
|
||||
with transaction.atomic():
|
||||
o = event.orders.select_for_update().get(pk=o)
|
||||
refund_amount = Decimal('0.00')
|
||||
total = Decimal('0.00')
|
||||
positions = []
|
||||
|
||||
ocm = OrderChangeManager(o, user=user, notify=False)
|
||||
for p in o.positions.all():
|
||||
if p.subevent == subevent:
|
||||
total += p.price
|
||||
ocm.cancel(p)
|
||||
positions.append(p)
|
||||
|
||||
fee = Decimal('0.00')
|
||||
if keep_fee_fixed:
|
||||
fee += Decimal(keep_fee_fixed)
|
||||
if keep_fee_percentage:
|
||||
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * total
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
if fee:
|
||||
f = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||
value=fee,
|
||||
order=o,
|
||||
tax_rule=o.event.settings.tax_rate_default,
|
||||
)
|
||||
f._calculate_tax()
|
||||
ocm.add_fee(f)
|
||||
|
||||
ocm.commit()
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk)
|
||||
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
|
||||
|
||||
return failed
|
||||
@@ -279,7 +279,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
"""
|
||||
with transaction.atomic():
|
||||
if isinstance(order, int):
|
||||
order = Order.objects.get(pk=order)
|
||||
order = Order.objects.select_for_update().get(pk=order)
|
||||
if isinstance(user, int):
|
||||
user = User.objects.get(pk=user)
|
||||
if isinstance(api_token, int):
|
||||
@@ -1075,6 +1075,7 @@ class OrderChangeManager:
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
|
||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value'))
|
||||
AddFeeOperation = namedtuple('AddFeeOperation', ('fee',))
|
||||
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee',))
|
||||
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
||||
|
||||
@@ -1188,6 +1189,11 @@ class OrderChangeManager:
|
||||
self._operations.append(self.CancelFeeOperation(fee))
|
||||
self._invoice_dirty = True
|
||||
|
||||
def add_fee(self, fee: OrderFee):
|
||||
self._totaldiff += fee.value
|
||||
self._invoice_dirty = True
|
||||
self._operations.append(self.AddFeeOperation(fee))
|
||||
|
||||
def change_fee(self, fee: OrderFee, value: Decimal):
|
||||
value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross')
|
||||
self._totaldiff += value.gross - fee.value
|
||||
@@ -1448,6 +1454,13 @@ class OrderChangeManager:
|
||||
invoice_address=self._invoice_address
|
||||
).gross
|
||||
)
|
||||
elif isinstance(op, self.AddFeeOperation):
|
||||
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={
|
||||
'fee': op.fee.pk,
|
||||
})
|
||||
op.fee.order = self.order
|
||||
op.fee._calculate_tax()
|
||||
op.fee.save()
|
||||
elif isinstance(op, self.FeeValueOperation):
|
||||
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
|
||||
'fee': op.fee.pk,
|
||||
@@ -1800,6 +1813,61 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str
|
||||
raise OrderError(str(error_messages['busy']))
|
||||
|
||||
|
||||
def _try_auto_refund(order):
|
||||
notify_admin = False
|
||||
error = False
|
||||
if isinstance(order, int):
|
||||
order = Order.objects.get(pk=order)
|
||||
refund_amount = order.pending_sum * -1
|
||||
if refund_amount <= Decimal('0.00'):
|
||||
return
|
||||
|
||||
proposals = order.propose_auto_refunds(refund_amount)
|
||||
can_auto_refund = sum(proposals.values()) == refund_amount
|
||||
if can_auto_refund:
|
||||
for p, value in proposals.items():
|
||||
with transaction.atomic():
|
||||
r = order.refunds.create(
|
||||
payment=p,
|
||||
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
provider=p.provider
|
||||
)
|
||||
order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
})
|
||||
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
with transaction.atomic():
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
order.log_action('pretix.event.order.refund.failed', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
'error': str(e)
|
||||
})
|
||||
error = True
|
||||
notify_admin = True
|
||||
else:
|
||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||
notify_admin = True
|
||||
elif refund_amount != Decimal('0.00'):
|
||||
notify_admin = True
|
||||
|
||||
if notify_admin:
|
||||
order.log_action('pretix.event.order.refund.requested')
|
||||
if error:
|
||||
raise OrderError(
|
||||
_(
|
||||
'There was an error while trying to send the money back to you. Please contact the event organizer '
|
||||
'for further information.')
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
@scopes_disabled()
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
@@ -1809,52 +1877,7 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
|
||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||
cancellation_fee)
|
||||
if try_auto_refund:
|
||||
notify_admin = False
|
||||
error = False
|
||||
order = Order.objects.get(pk=order)
|
||||
refund_amount = order.pending_sum * -1
|
||||
proposals = order.propose_auto_refunds(refund_amount)
|
||||
can_auto_refund = sum(proposals.values()) == refund_amount
|
||||
if can_auto_refund:
|
||||
for p, value in proposals.items():
|
||||
with transaction.atomic():
|
||||
r = order.refunds.create(
|
||||
payment=p,
|
||||
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
provider=p.provider
|
||||
)
|
||||
order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
})
|
||||
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
with transaction.atomic():
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
order.log_action('pretix.event.order.refund.failed', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
'error': str(e)
|
||||
})
|
||||
error = True
|
||||
notify_admin = True
|
||||
else:
|
||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||
notify_admin = True
|
||||
elif refund_amount != Decimal('0.00'):
|
||||
notify_admin = True
|
||||
|
||||
if notify_admin:
|
||||
order.log_action('pretix.event.order.refund.requested')
|
||||
if error:
|
||||
raise OrderError(
|
||||
_('There was an error while trying to send the money back to you. Please contact the event organizer for further information.')
|
||||
)
|
||||
_try_auto_refund(order)
|
||||
return ret
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
|
||||
Reference in New Issue
Block a user