Allow to reactivate canceled orders (#1601)

This commit is contained in:
Raphael Michel
2020-03-11 11:40:56 +01:00
committed by GitHub
parent 2431a8b767
commit 1ee48a10b5
17 changed files with 411 additions and 11 deletions

View File

@@ -44,7 +44,7 @@ from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _order_placed_email,
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded,
extend_order, mark_order_expired, mark_order_refunded, reactivate_order,
)
from pretix.base.services.pricing import get_price
from pretix.base.services.tickets import generate
@@ -261,6 +261,29 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
def reactivate(self, request, **kwargs):
order = self.get_object()
if order.status != Order.STATUS_CANCELED:
return Response(
{'detail': 'The order is not allowed to be reactivated.'},
status=status.HTTP_400_BAD_REQUEST
)
try:
reactivate_order(
order,
user=request.user if request.user.is_authenticated else None,
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
)
except OrderError as e:
return Response(
{'detail': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
def approve(self, request, **kwargs):
send_mail = request.data.get('send_email', True)

View File

@@ -125,6 +125,10 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.canceled',
_('Order canceled'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.reactivated',
_('Order reactivated'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.expired',
_('Order expired'),

View File

@@ -4,6 +4,7 @@ import json
import logging
import os
import string
from collections import Counter
from datetime import datetime, time, timedelta
from decimal import Decimal
from typing import Any, Dict, List, Union
@@ -694,16 +695,19 @@ class Order(LockModel, LoggedModel):
return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]:
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False,
check_voucher_usage=False) -> Union[bool, str]:
error_messages = {
'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'),
}
now_dt = now_dt or now()
positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher')
quota_cache = {}
v_budget = {}
v_usage = Counter()
try:
for i, op in enumerate(positions):
if op.seat:
@@ -722,6 +726,13 @@ class Order(LockModel, LoggedModel):
))
v_budget[op.voucher] -= disc
if op.voucher and check_voucher_usage:
v_usage[op.voucher.pk] += 1
if v_usage[op.voucher.pk] + op.voucher.redeemed > op.voucher.max_usages:
raise Quota.QuotaExceededException(error_messages['voucher_usages'].format(
voucher=op.voucher.code
))
quotas = list(op.quotas)
if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format(

View File

@@ -223,6 +223,12 @@ def register_default_notification_types(sender, **kwargs):
_('Order canceled'),
_('Order {order.code} has been canceled.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.reactivated',
_('Order reactivated'),
_('Order {order.code} has been reactivated.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.expired',

View File

@@ -94,6 +94,53 @@ def mark_order_paid(*args, **kwargs):
raise NotImplementedError("This method is no longer supported since pretix 1.17.")
def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None):
"""
Reactivates a canceled order. If ``force`` is not set to ``True``, this will fail if there is not
enough quota.
"""
if order.status != Order.STATUS_CANCELED:
raise OrderError('The order was not canceled.')
with order.event.lock() as now_dt:
is_available = force or order._is_still_available(now_dt, count_waitinglist=False, check_voucher_usage=True)
if is_available is True:
if order.payment_refund_sum >= order.total:
order.status = Order.STATUS_PAID
else:
order.status = Order.STATUS_PENDING
order.set_expires(now(),
order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
with transaction.atomic():
order.save(update_fields=['expires', 'status'])
order.log_action(
'pretix.event.order.reactivated',
user=user,
auth=auth,
data={
'expires': order.expires,
}
)
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') + 1))
for gc in position.issued_gift_cards.all():
gc = GiftCard.objects.select_for_update().get(pk=gc.pk)
gc.transactions.create(value=position.price, order=order)
break
else:
raise OrderError(is_available)
order_approved.send(order.event, order=order)
if order.status == Order.STATUS_PAID:
order_paid.send(order.event, order=order)
num_invoices = order.invoices.filter(is_cancellation=False).count()
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices and invoice_qualified(order):
generate_invoice(order)
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
"""
Extends the deadline of an order. If the order is already expired, the quota will be checked to
@@ -117,9 +164,10 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
'state_change': was_expired
}
)
if was_expired:
num_invoices = order.invoices.filter(is_cancellation=False).count()
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices:
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices and invoice_qualified(order):
generate_invoice(order)
if order.status == Order.STATUS_PENDING:
@@ -277,6 +325,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
:param order: The order to change
:param user: The user that performed the change
"""
# If new actions are added to this function, make sure to add the reverse operation to reactivate_order()
with transaction.atomic():
if isinstance(order, int):
order = Order.objects.select_for_update().get(pk=order)

View File

@@ -341,6 +341,16 @@ as the first argument.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
order_reactivated = EventPluginSignal(
providing_args=["order"]
)
"""
This signal is sent out every time a canceled order is reactivated. The order object is given
as the first argument.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
order_expired = EventPluginSignal(
providing_args=["order"]
)

View File

@@ -191,6 +191,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.refunded': _('The order has been refunded.'),
'pretix.event.order.canceled': _('The order has been canceled.'),
'pretix.event.order.reactivated': _('The order has been reactivated.'),
'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'),
'pretix.event.order.placed': _('The order has been created.'),
'pretix.event.order.placed.require_approval': _('The order requires approval before it can continue to be processed.'),

View File

@@ -59,6 +59,10 @@
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=c" class="btn btn-default">
{% trans "Cancel order" %}
</a>
{% elif order.status == 'c' %}
<a href="{% url "control:event.order.reactivate" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
{% trans "Reactivate order" %}
</a>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,38 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% trans "Reactivate order" %}
{% endblock %}
{% block content %}
<h1>
{% trans "Reactivate order" %}
<a class="btn btn-link btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% blocktrans trimmed with order=order.code %}
Back to order {{ order }}
{% endblocktrans %}
</a>
</h1>
<p>
{% blocktrans trimmed %}
By reactivating the order, you reverse its cancellation and transform this back into a pending or paid order.
This is only possible as long as all products in the order are still available.
If the order is pending payment, the expiry date will be reset.
{% endblocktrans %}
</p>
<form method="post" class="form-horizontal" href="">
{% csrf_token %}
<div class="form-group submit-group">
<a class="btn btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% trans "Cancel" %}
</a>
<button class="btn btn-danger btn-save btn-lg" type="submit">
{% trans "Reactivate" %}
</button>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -222,6 +222,8 @@ urlpatterns = [
name='event.order.checkvatid'),
url(r'^orders/(?P<code>[0-9A-Z]+)/extend$', orders.OrderExtend.as_view(),
name='event.order.extend'),
url(r'^orders/(?P<code>[0-9A-Z]+)/reactivate$', orders.OrderReactivate.as_view(),
name='event.order.reactivate'),
url(r'^orders/(?P<code>[0-9A-Z]+)/contact$', orders.OrderContactChange.as_view(),
name='event.order.contact'),
url(r'^orders/(?P<code>[0-9A-Z]+)/locale', orders.OrderLocaleChange.as_view(),

View File

@@ -57,7 +57,7 @@ from pretix.base.services.mail import SendMailException, render_mail
from pretix.base.services.orders import (
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded,
notify_user_changed_order,
notify_user_changed_order, reactivate_order,
)
from pretix.base.services.stats import order_overview
from pretix.base.services.tickets import generate
@@ -1261,6 +1261,42 @@ class OrderExtend(OrderView):
data=self.request.POST if self.request.method == "POST" else None)
class OrderReactivate(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
try:
reactivate_order(
self.order,
user=self.request.user
)
messages.success(self.request, _('The order has been reactivated.'))
except OrderError as e:
messages.error(self.request, str(e))
return self._redirect_here()
except LockTimeoutException:
messages.error(self.request, _('We were not able to process the request completely as the '
'server was too busy.'))
return self._redirect_back()
def dispatch(self, request, *args, **kwargs):
if self.order.status != Order.STATUS_CANCELED:
messages.error(self.request, _('This action is only allowed for canceled orders.'))
return self._redirect_back()
return super().dispatch(request, *kwargs, **kwargs)
def _redirect_here(self):
return redirect('control:event.order.reactivate',
event=self.request.event.slug,
organizer=self.request.event.organizer.slug,
code=self.order.code)
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/reactivate.html', {
'order': self.order,
})
class OrderChange(OrderView):
permission = 'can_change_orders'
template_name = 'pretixcontrol/order/change.html'

View File

@@ -1097,6 +1097,29 @@ def test_order_mark_paid_locked(token_client, organizer, event, order):
assert order.status == Order.STATUS_EXPIRED
@pytest.mark.django_db
def test_order_reactivate(token_client, organizer, event, order, quota):
order.status = Order.STATUS_CANCELED
order.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/reactivate/'.format(
organizer.slug, event.slug, order.code
)
)
assert resp.status_code == 200
assert resp.data['status'] == Order.STATUS_PENDING
@pytest.mark.django_db
def test_order_reactivate_invalid(token_client, organizer, event, order):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/reactivate/'.format(
organizer.slug, event.slug, order.code
)
)
assert resp.status_code == 400
@pytest.mark.django_db
def test_order_mark_canceled_pending(token_client, organizer, event, order):
djmail.outbox = []

View File

@@ -23,7 +23,8 @@ from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.base.services.invoices import generate_invoice
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _create_order, approve_order, cancel_order,
deny_order, expire_orders, send_download_reminders, send_expiry_warnings,
deny_order, expire_orders, reactivate_order, send_download_reminders,
send_expiry_warnings,
)
from pretix.base.signals import register_sales_channels
from pretix.plugins.banktransfer.payment import BankTransfer
@@ -1457,7 +1458,7 @@ class OrderChangeManagerTests(TestCase):
self.ocm.commit()
ops = list(self.order.positions.all())
for op in ops:
assert op.price == Decimal('23.01') # sic. we can't really avoid it.
assert op.price == Decimal('23.01') # sic. we can't really avoid it.
assert op.tax_value == Decimal('1.51')
assert op.tax_rate == Decimal('7.00')
@@ -2169,7 +2170,8 @@ class OrderChangeManagerTests(TestCase):
def test_clear_out_order(self):
self.order.status = Order.STATUS_PAID
self.order.save()
self.order.payments.create(amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED, provider='manual')
self.order.payments.create(amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='manual')
cancel_order(self.order, cancellation_fee=Decimal('5.00'))
self.order.refresh_from_db()
assert self.order.total == Decimal('5.00')
@@ -2187,7 +2189,8 @@ class OrderChangeManagerTests(TestCase):
self.order.total = Decimal('51.1')
self.order.save()
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_PENDING, amount=Decimal('48.5'), fee=fee, provider="banktransfer")
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_PENDING, amount=Decimal('48.5'), fee=fee,
provider="banktransfer")
prov = self.ocm._get_payment_provider()
prov.settings.set('_fee_percent', Decimal('10.00'))
prov.settings.set('_fee_reverse_calc', False)
@@ -2205,7 +2208,8 @@ class OrderChangeManagerTests(TestCase):
self.order.total = Decimal('50.60')
self.order.save()
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_PENDING, amount=Decimal('48.5'), fee=fee, provider="banktransfer")
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_PENDING, amount=Decimal('48.5'), fee=fee,
provider="banktransfer")
prov = self.ocm._get_payment_provider()
prov.settings.set('_fee_percent', Decimal('10.00'))
prov.settings.set('_fee_reverse_calc', False)
@@ -2224,7 +2228,8 @@ class OrderChangeManagerTests(TestCase):
self.order.total = Decimal('50.60')
self.order.save()
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_PENDING, amount=Decimal('48.5'), fee=fee, provider="banktransfer")
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_PENDING, amount=Decimal('48.5'), fee=fee,
provider="banktransfer")
prov = self.ocm._get_payment_provider()
prov.settings.set('_fee_percent', Decimal('10.00'))
prov.settings.set('_fee_reverse_calc', False)
@@ -2463,3 +2468,118 @@ def test_issue_when_paid_and_changed(event):
op2 = order.positions.last()
gc2 = op2.issued_gift_cards.get()
assert gc2.value == op2.price
class OrderReactivateTest(TestCase):
def setUp(self):
super().setUp()
self.o = Organizer.objects.create(name='Dummy', slug='dummy')
with scope(organizer=self.o):
self.event = Event.objects.create(organizer=self.o, name='Dummy', slug='dummy', date_from=now(),
plugins='tests.testdummy')
self.order = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test',
status=Order.STATUS_CANCELED, locale='en',
datetime=now(), expires=now() + timedelta(days=1),
total=Decimal('46.00'),
)
self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
self.op1 = OrderPosition.objects.create(
order=self.order, item=self.ticket, variation=None,
price=Decimal("23.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
)
self.op2 = OrderPosition.objects.create(
order=self.order, item=self.ticket, variation=None,
price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter"}, positionid=2
)
self.stalls = Item.objects.create(event=self.event, name='Stalls',
default_price=Decimal('23.00'), admission=True)
self.plan = SeatingPlan.objects.create(
name="Plan", organizer=self.o, layout="{}"
)
self.event.seat_category_mappings.create(
layout_category='Stalls', product=self.stalls
)
self.quota = self.event.quotas.create(name='Test', size=None)
self.quota.items.add(self.stalls)
self.quota.items.add(self.ticket)
self.seat_a1 = self.event.seats.create(name="A1", product=self.stalls, seat_guid="A1")
generate_invoice(self.order)
djmail.outbox = []
@classscope(attr='o')
def test_paid(self):
self.order.status = Order.STATUS_PAID
self.order.save()
with pytest.raises(OrderError):
reactivate_order(self.order)
@classscope(attr='o')
def test_reactivate_unpaid(self):
e = self.order.expires
reactivate_order(self.order)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
assert self.order.all_logentries().last().action_type == 'pretix.event.order.reactivated'
assert self.order.invoices.count() == 3
assert self.order.expires > e > now()
@classscope(attr='o')
def test_reactivate_paid(self):
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5)
reactivate_order(self.order)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PAID
assert self.order.all_logentries().last().action_type == 'pretix.event.order.reactivated'
assert self.order.invoices.count() == 3
@classscope(attr='o')
def test_reactivate_sold_out(self):
self.quota.size = 0
self.quota.save()
with pytest.raises(OrderError):
reactivate_order(self.order)
@classscope(attr='o')
def test_reactivate_seat_taken(self):
self.op1.item = self.stalls
self.op1.seat = self.seat_a1
self.op1.save()
self.seat_a1.blocked = True
self.seat_a1.save()
with pytest.raises(OrderError):
reactivate_order(self.order)
@classscope(attr='o')
def test_reactivate_voucher_ok(self):
self.op1.voucher = self.event.vouchers.create(code="FOO", item=self.ticket, redeemed=0, max_usages=1)
self.op1.save()
reactivate_order(self.order)
v = self.op1.voucher
v.refresh_from_db()
assert v.redeemed == 1
@classscope(attr='o')
def test_reactivate_voucher_budget(self):
self.op1.voucher = self.event.vouchers.create(code="FOO", item=self.ticket, budget=Decimal('0.00'))
self.op1.price_before_voucher = self.op1.price * 2
self.op1.save()
with pytest.raises(OrderError):
reactivate_order(self.order)
@classscope(attr='o')
def test_reactivate_voucher_used(self):
self.op1.voucher = self.event.vouchers.create(code="FOO", item=self.ticket, redeemed=1, max_usages=1)
self.op1.save()
with pytest.raises(OrderError):
reactivate_order(self.order)
v = self.op1.voucher
v.refresh_from_db()
assert v.redeemed == 1
@classscope(attr='o')
def test_reactivate_gift_card(self):
gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1)
reactivate_order(self.order)
assert gc.value == 23

View File

@@ -574,6 +574,37 @@ def test_order_resend_link(client, env):
assert 'FOO' in mail.outbox[0].body
@pytest.mark.django_db
def test_order_reactivate_not_canceled(client, env):
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
o.status = Order.STATUS_PAID
o.save()
client.login(email='dummy@dummy.dummy', password='dummy')
response = client.get('/control/event/dummy/dummy/orders/FOO/reactivate', follow=True)
assert 'alert-danger' in response.content.decode()
response = client.post('/control/event/dummy/dummy/orders/FOO/reactivate', follow=True)
assert 'alert-danger' in response.content.decode()
@pytest.mark.django_db
def test_order_reactivate(client, env):
with scopes_disabled():
q = Quota.objects.create(event=env[0], size=3)
q.items.add(env[3])
o = Order.objects.get(id=env[2].id)
o.status = Order.STATUS_CANCELED
o.save()
client.login(email='dummy@dummy.dummy', password='dummy')
response = client.post('/control/event/dummy/dummy/orders/FOO/reactivate', {
}, follow=True)
print(response.content.decode())
assert 'alert-success' in response.content.decode()
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
assert o.status == Order.STATUS_PENDING
@pytest.mark.django_db
def test_order_extend_not_pending(client, env):
with scopes_disabled():

View File

@@ -102,6 +102,7 @@ event_urls = [
"orders/ABC/resend",
"orders/ABC/invoice",
"orders/ABC/extend",
"orders/ABC/reactivate",
"orders/ABC/change",
"orders/ABC/contact",
"orders/ABC/comment",
@@ -274,6 +275,7 @@ event_permission_urls = [
("can_view_orders", "orders/", 200),
("can_view_orders", "orders/FOO/", 200),
("can_change_orders", "orders/FOO/extend", 200),
("can_change_orders", "orders/FOO/reactivate", 302),
("can_change_orders", "orders/FOO/contact", 200),
("can_change_orders", "orders/FOO/transition", 405),
("can_change_orders", "orders/FOO/checkvatid", 405),