forked from CGM_Public/pretix_original
Refactor cancelling positions and orders in the data model (#1088)
- [x] Data model - [x] display in order view in backend - [x] review all usages of OrderPositions.objects - [x] review all usages of order.positions - [x] review all other model usages - [x] review plugins - [x] plugins backwards-compatible API? - [x] decide on way forward for REST API - [x] need to cancel fees - [x] tests - [ ] plugins - [ ] gdpr - [ ] reports - [x] docs
This commit is contained in:
@@ -100,14 +100,13 @@ class OrderFilterForm(FilterForm):
|
||||
label=_('Order status'),
|
||||
choices=(
|
||||
('', _('All orders')),
|
||||
('p', _('Paid')),
|
||||
('n', _('Pending')),
|
||||
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||
(Order.STATUS_PENDING, _('Pending')),
|
||||
('o', _('Pending (overdue)')),
|
||||
('np', _('Pending or paid')),
|
||||
('e', _('Expired')),
|
||||
('ne', _('Pending or expired')),
|
||||
('c', _('Canceled')),
|
||||
('r', _('Refunded')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||
(Order.STATUS_EXPIRED, _('Expired')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||
(Order.STATUS_CANCELED, _('Canceled')),
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
@@ -201,14 +200,13 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
label=_('Order status'),
|
||||
choices=(
|
||||
('', _('All orders')),
|
||||
('p', _('Paid')),
|
||||
('n', _('Pending')),
|
||||
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||
(Order.STATUS_PENDING, _('Pending')),
|
||||
('o', _('Pending (overdue)')),
|
||||
('np', _('Pending or paid')),
|
||||
('e', _('Expired')),
|
||||
('ne', _('Pending or expired')),
|
||||
('c', _('Canceled')),
|
||||
('r', _('Refunded')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||
(Order.STATUS_EXPIRED, _('Expired')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||
(Order.STATUS_CANCELED, _('Canceled')),
|
||||
('pa', _('Approval pending')),
|
||||
('overpaid', _('Overpaid')),
|
||||
('underpaid', _('Underpaid')),
|
||||
@@ -246,10 +244,10 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
qs = super().filter_qs(qs)
|
||||
|
||||
if fdata.get('item'):
|
||||
qs = qs.filter(positions__item=fdata.get('item'))
|
||||
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False)
|
||||
|
||||
if fdata.get('subevent'):
|
||||
qs = qs.filter(positions__subevent=fdata.get('subevent'))
|
||||
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False)
|
||||
|
||||
if fdata.get('question') and fdata.get('answer') is not None:
|
||||
q = fdata.get('question')
|
||||
@@ -278,8 +276,8 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
|
||||
if fdata.get('status') == 'overpaid':
|
||||
qs = qs.filter(
|
||||
Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0))
|
||||
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
|
||||
)
|
||||
elif fdata.get('status') == 'pendingpaid':
|
||||
qs = qs.filter(
|
||||
|
||||
@@ -367,8 +367,7 @@ class OrderRefundForm(forms.Form):
|
||||
required=False,
|
||||
widget=forms.RadioSelect,
|
||||
choices=(
|
||||
('mark_refunded', _('Mark the complete order as refunded. The order will be canceled and all tickets will '
|
||||
'no longer work. This can not be reverted.')),
|
||||
('mark_refunded', _('Cancel the order. All tickets will no longer work. This can not be reverted.')),
|
||||
('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another '
|
||||
'payment method.')),
|
||||
('do_nothing', _('Do nothing and keep the order as it is.')),
|
||||
@@ -391,7 +390,7 @@ class OrderRefundForm(forms.Form):
|
||||
self.order = kwargs.pop('order')
|
||||
super().__init__(*args, **kwargs)
|
||||
change_decimal_field(self.fields['partial_amount'], self.order.event.currency)
|
||||
if self.order.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
|
||||
if self.order.status == Order.STATUS_CANCELED:
|
||||
del self.fields['action']
|
||||
|
||||
def clean_partial_amount(self):
|
||||
|
||||
@@ -63,7 +63,7 @@ def _display_order_changed(event: Event, logentry: LogEntry):
|
||||
old_item = str(event.items.get(pk=data['old_item']))
|
||||
if data['old_variation']:
|
||||
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
|
||||
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) removed.').format(
|
||||
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
old_item=old_item,
|
||||
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
|
||||
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option>
|
||||
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option>
|
||||
<option value="r" {% if request.GET.status == "r" %}selected="selected"{% endif %}>{% trans "Refunded" %}</option>
|
||||
</select>
|
||||
<select name="item" class="form-control">
|
||||
<option value="">{% trans "All products" %}</option>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<p>{% blocktrans trimmed %}
|
||||
Do you really want to cancel this order? You cannot revert this action.
|
||||
{% endblocktrans %}</p>
|
||||
{% if order.payment_refund_sum > 0 %}
|
||||
<p>{% blocktrans trimmed %}
|
||||
This will <strong>not</strong> automatically transfer the money back, but you will be offered options to
|
||||
refund the payment afterwards.
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" href="">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
<label>
|
||||
<input name="{{ position.form.prefix }}-operation" type="radio" value="cancel"
|
||||
{% if position.form.operation.value == "cancel" %}checked="checked"{% endif %}>
|
||||
{% trans "Remove from order" %}
|
||||
{% trans "Cancel position" %}
|
||||
{% if position.addons.exists %}
|
||||
<em class="text-danger">
|
||||
{% trans "Removing this position will also remove all add-ons to this position." %}
|
||||
|
||||
@@ -50,11 +50,6 @@
|
||||
{% trans "Cancel order" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if order.payment_refund_sum > 0 %}
|
||||
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
{% trans "Create a refund" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<a href="{% eventurl request.event "presale:event.order" order=order.code secret=order.secret %}"
|
||||
@@ -171,11 +166,11 @@
|
||||
action="{% url "control:event.order.reissueinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs"
|
||||
{% if order.status != "r" and order.status != "c" %}
|
||||
{% if order.status != "c" %}
|
||||
data-toggle="tooltip"
|
||||
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
|
||||
{% endif %}>
|
||||
{% if order.status == "r" or order.status == "c" %}
|
||||
{% if order.status == "c" %}
|
||||
{% trans "Generate cancellation" %}
|
||||
{% else %}
|
||||
{% trans "Cancel and reissue" %}
|
||||
@@ -234,7 +229,7 @@
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% for line in items.positions %}
|
||||
<div class="row-fluid product-row">
|
||||
<div class="row-fluid product-row {% if line.canceled %}pos-canceled{% endif %}">
|
||||
<div class="col-md-9 col-xs-6">
|
||||
{% if line.addon_to %}
|
||||
<span class="addon-signifier">+</span>
|
||||
@@ -260,24 +255,26 @@
|
||||
<br/>
|
||||
<span class="fa fa-calendar"></span> {{ line.subevent.name }} · {{ line.subevent.get_date_range_display }}
|
||||
{% endif %}
|
||||
<div class="position-buttons">
|
||||
{% if not line.addon_to or request.event.settings.ticket_download_addons %}
|
||||
{% if line.item.admission or request.event.settings.ticket_download_nonadm %}
|
||||
{% for b in download_buttons %}
|
||||
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-xs btn-default">
|
||||
<span class="fa {{ b.icon }}"></span> {{ b.text }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% if not line.canceled %}
|
||||
<div class="position-buttons">
|
||||
{% if not line.addon_to or request.event.settings.ticket_download_addons %}
|
||||
{% if line.item.admission or request.event.settings.ticket_download_nonadm %}
|
||||
{% for b in download_buttons %}
|
||||
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-xs btn-default">
|
||||
<span class="fa {{ b.icon }}"></span> {{ b.text }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %}
|
||||
</div>
|
||||
{% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if line.has_questions %}
|
||||
<dl>
|
||||
{% if line.item.admission and event.settings.attendee_names_asked %}
|
||||
@@ -486,6 +483,11 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if order.payment_refund_sum > 0 %}
|
||||
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
{% trans "Create a refund" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
<span class="label label-warning {{ class }}">{% trans "Pending" %}</span>
|
||||
{% endif %}
|
||||
{% elif order.status == "p" %}
|
||||
<span class="label label-success {{ class }}">{% trans "Paid" %}</span>
|
||||
{% if order.count_positions == 0 %}
|
||||
<span class="label label-info {{ class }}">{% trans "Canceled (paid fee)" %}</span>
|
||||
{% else %}
|
||||
<span class="label label-success {{ class }}">{% trans "Paid" %}</span>
|
||||
{% endif %}
|
||||
{% elif order.status == "e" %} {# expired #}
|
||||
<span class="label label-danger {{ class }}">{% trans "Expired" %}</span>
|
||||
{% elif order.status == "c" %}
|
||||
<span class="label label-danger {{ class }}">{% trans "Canceled" %}</span>
|
||||
{% elif order.status == "r" %}
|
||||
<span class="label label-danger {{ class }}">{% trans "Refunded" %}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
{% endif %}
|
||||
{{ o.total|money:request.event.currency }}
|
||||
</td>
|
||||
<td class="text-right">{{ o.pcnt }}</td>
|
||||
<td class="text-right">{{ o.pcnt|default_if_none:"0" }}</td>
|
||||
<td class="text-right">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Product" %}</th>
|
||||
<th>{% trans "Canceled" %}</th>
|
||||
<th>{% trans "Refunded" %}</th>
|
||||
<th>{% trans "Canceled" %}¹</th>
|
||||
<th>{% trans "Expired" %}</th>
|
||||
<th colspan="3">{% trans "Purchased" %}</th>
|
||||
</tr>
|
||||
@@ -39,7 +38,6 @@
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>{% trans "Pending" %}</th>
|
||||
<th>{% trans "Paid" %}</th>
|
||||
<th>{% trans "Total" %}</th>
|
||||
@@ -51,7 +49,6 @@
|
||||
<tr class="category">
|
||||
<th>{{ tup.0 }}</th>
|
||||
<th>{{ tup.0.num.canceled|togglesum:request.event.currency }}</th>
|
||||
<th>{{ tup.0.num.refunded|togglesum:request.event.currency }}</th>
|
||||
<th>{{ tup.0.num.expired|togglesum:request.event.currency }}</th>
|
||||
<th>{{ tup.0.num.pending|togglesum:request.event.currency }}</th>
|
||||
<th>{{ tup.0.num.paid|togglesum:request.event.currency }}</th>
|
||||
@@ -66,11 +63,6 @@
|
||||
{{ item.num.canceled|togglesum:request.event.currency }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ listurl }}?item={{ item.id }}&status=r&provider={{ item.provider }}">
|
||||
{{ item.num.refunded|togglesum:request.event.currency }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ listurl }}?item={{ item.id }}&status=e&provider={{ item.provider }}">
|
||||
{{ item.num.expired|togglesum:request.event.currency }}
|
||||
@@ -95,7 +87,6 @@
|
||||
<tr class="variation {% if tup.0 %}categorized{% endif %}">
|
||||
<td>{{ var }}</td>
|
||||
<td>{{ var.num.canceled|togglesum:request.event.currency }}</td>
|
||||
<td>{{ var.num.refunded|togglesum:request.event.currency }}</td>
|
||||
<td>{{ var.num.expired|togglesum:request.event.currency }}</td>
|
||||
<td>{{ var.num.pending|togglesum:request.event.currency }}</td>
|
||||
<td>{{ var.num.paid|togglesum:request.event.currency }}</td>
|
||||
@@ -110,7 +101,6 @@
|
||||
<tr class="total">
|
||||
<th>{% trans "Total" %}</th>
|
||||
<th>{{ total.num.canceled|togglesum:request.event.currency }}</th>
|
||||
<th>{{ total.num.refunded|togglesum:request.event.currency }}</th>
|
||||
<th>{{ total.num.expired|togglesum:request.event.currency }}</th>
|
||||
<th>{{ total.num.pending|togglesum:request.event.currency }}</th>
|
||||
<th>{{ total.num.paid|togglesum:request.event.currency }}</th>
|
||||
@@ -119,4 +109,7 @@
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
¹ {% trans "If you click links in this column, you will only find orders that are canceled completely, while the numbers also include single canceled positions within valid orders." %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -272,8 +272,8 @@ def event_index(request, organizer, event):
|
||||
}
|
||||
|
||||
ctx['has_overpaid_orders'] = Order.annotate_overpayments(request.event.orders).filter(
|
||||
Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0))
|
||||
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
|
||||
).exists()
|
||||
ctx['has_pending_orders_with_full_payment'] = Order.annotate_overpayments(request.event.orders).filter(
|
||||
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) & Q(require_approval=False)
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, IntegerField, OuterRef, Subquery
|
||||
from django.http import (
|
||||
FileResponse, Http404, HttpResponseNotAllowed, JsonResponse,
|
||||
)
|
||||
@@ -82,9 +82,12 @@ class OrderList(EventPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||
qs = Order.objects.filter(
|
||||
event=self.request.event
|
||||
).annotate(pcnt=Count('positions', distinct=True)).select_related('invoice_address')
|
||||
).annotate(pcnt=Subquery(s, output_field=IntegerField())).select_related('invoice_address')
|
||||
|
||||
qs = Order.annotate_overpayments(qs)
|
||||
|
||||
@@ -188,7 +191,7 @@ class OrderDetail(OrderView):
|
||||
return buttons
|
||||
|
||||
def get_items(self):
|
||||
queryset = self.object.positions.all()
|
||||
queryset = self.object.all_positions
|
||||
|
||||
cartpos = queryset.order_by(
|
||||
'item', 'variation'
|
||||
@@ -565,7 +568,9 @@ class OrderRefundView(OrderView):
|
||||
def start_form(self):
|
||||
return OrderRefundForm(
|
||||
order=self.order,
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
data=self.request.POST if self.request.method == "POST" else (
|
||||
self.request.GET if "start-action" in self.request.GET else None
|
||||
),
|
||||
prefix='start',
|
||||
initial={
|
||||
'partial_amount': self.order.payment_refund_sum,
|
||||
@@ -576,204 +581,209 @@ class OrderRefundView(OrderView):
|
||||
}
|
||||
)
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
if self.start_form.is_valid():
|
||||
payments = self.order.payments.filter(
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
for p in payments:
|
||||
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
|
||||
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = p.amount - p.refunded_amount
|
||||
def choose_form(self):
|
||||
payments = self.order.payments.filter(
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
for p in payments:
|
||||
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
|
||||
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = p.amount - p.refunded_amount
|
||||
|
||||
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
|
||||
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
|
||||
|
||||
# Algorithm to choose which payments are to be refunded to create the least hassle
|
||||
if self.start_form.cleaned_data.get('mode') == 'full':
|
||||
to_refund = full_refund = self.order.payment_refund_sum
|
||||
# Algorithm to choose which payments are to be refunded to create the least hassle
|
||||
if self.start_form.cleaned_data.get('mode') == 'full':
|
||||
to_refund = full_refund = self.order.payment_refund_sum
|
||||
else:
|
||||
to_refund = full_refund = self.start_form.cleaned_data.get('partial_amount')
|
||||
|
||||
while to_refund and unused_payments:
|
||||
bigger = sorted([p for p in unused_payments if p.available_amount > to_refund],
|
||||
key=lambda p: p.available_amount)
|
||||
same = [p for p in unused_payments if p.available_amount == to_refund]
|
||||
smaller = sorted([p for p in unused_payments if p.available_amount < to_refund],
|
||||
key=lambda p: p.available_amount,
|
||||
reverse=True)
|
||||
if same:
|
||||
for payment in same:
|
||||
if payment.full_refund_possible or payment.partial_refund_possible:
|
||||
payment.propose_refund = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
break
|
||||
elif bigger:
|
||||
for payment in bigger:
|
||||
if payment.partial_refund_possible:
|
||||
payment.propose_refund = to_refund
|
||||
to_refund -= to_refund
|
||||
unused_payments.remove(payment)
|
||||
break
|
||||
elif smaller:
|
||||
for payment in smaller:
|
||||
if payment.full_refund_possible or payment.partial_refund_possible:
|
||||
payment.propose_refund = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
break
|
||||
|
||||
if 'perform' in self.request.POST:
|
||||
refund_selected = Decimal('0.00')
|
||||
refunds = []
|
||||
|
||||
is_valid = True
|
||||
manual_value = self.request.POST.get('refund-manual', '0') or '0'
|
||||
manual_value = formats.sanitize_separators(manual_value)
|
||||
try:
|
||||
manual_value = Decimal(manual_value)
|
||||
except (DecimalException, TypeError):
|
||||
messages.error(self.request, _('You entered an invalid number.'))
|
||||
is_valid = False
|
||||
else:
|
||||
to_refund = full_refund = self.start_form.cleaned_data.get('partial_amount')
|
||||
refund_selected += manual_value
|
||||
if manual_value:
|
||||
refunds.append(OrderRefund(
|
||||
order=self.order,
|
||||
payment=None,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN,
|
||||
state=(
|
||||
OrderRefund.REFUND_STATE_DONE
|
||||
if self.request.POST.get('manual_state') == 'done'
|
||||
else OrderRefund.REFUND_STATE_CREATED
|
||||
),
|
||||
amount=manual_value,
|
||||
provider='manual'
|
||||
))
|
||||
|
||||
while to_refund and unused_payments:
|
||||
bigger = sorted([p for p in unused_payments if p.available_amount > to_refund],
|
||||
key=lambda p: p.available_amount)
|
||||
same = [p for p in unused_payments if p.available_amount == to_refund]
|
||||
smaller = sorted([p for p in unused_payments if p.available_amount < to_refund],
|
||||
key=lambda p: p.available_amount,
|
||||
reverse=True)
|
||||
if same:
|
||||
for payment in same:
|
||||
if payment.full_refund_possible or payment.partial_refund_possible:
|
||||
payment.propose_refund = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
break
|
||||
elif bigger:
|
||||
for payment in bigger:
|
||||
if payment.partial_refund_possible:
|
||||
payment.propose_refund = to_refund
|
||||
to_refund -= to_refund
|
||||
unused_payments.remove(payment)
|
||||
break
|
||||
elif smaller:
|
||||
for payment in smaller:
|
||||
if payment.full_refund_possible or payment.partial_refund_possible:
|
||||
payment.propose_refund = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
break
|
||||
|
||||
if 'perform' in self.request.POST:
|
||||
refund_selected = Decimal('0.00')
|
||||
refunds = []
|
||||
|
||||
is_valid = True
|
||||
manual_value = self.request.POST.get('refund-manual', '0') or '0'
|
||||
manual_value = formats.sanitize_separators(manual_value)
|
||||
try:
|
||||
manual_value = Decimal(manual_value)
|
||||
except (DecimalException, TypeError):
|
||||
messages.error(self.request, _('You entered an invalid number.'))
|
||||
is_valid = False
|
||||
else:
|
||||
refund_selected += manual_value
|
||||
if manual_value:
|
||||
offsetting_value = self.request.POST.get('refund-offsetting', '0') or '0'
|
||||
offsetting_value = formats.sanitize_separators(offsetting_value)
|
||||
try:
|
||||
offsetting_value = Decimal(offsetting_value)
|
||||
except (DecimalException, TypeError):
|
||||
messages.error(self.request, _('You entered an invalid number.'))
|
||||
is_valid = False
|
||||
else:
|
||||
if offsetting_value:
|
||||
refund_selected += offsetting_value
|
||||
try:
|
||||
order = Order.objects.get(code=self.request.POST.get('order-offsetting'),
|
||||
event__organizer=self.request.organizer)
|
||||
except Order.DoesNotExist:
|
||||
messages.error(self.request, _('You entered an order that could not be found.'))
|
||||
is_valid = False
|
||||
else:
|
||||
refunds.append(OrderRefund(
|
||||
order=self.order,
|
||||
payment=None,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN,
|
||||
state=(
|
||||
OrderRefund.REFUND_STATE_DONE
|
||||
if self.request.POST.get('manual_state') == 'done'
|
||||
else OrderRefund.REFUND_STATE_CREATED
|
||||
),
|
||||
amount=manual_value,
|
||||
provider='manual'
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
execution_date=now(),
|
||||
amount=offsetting_value,
|
||||
provider='offsetting',
|
||||
info=json.dumps({
|
||||
'orders': [order.code]
|
||||
})
|
||||
))
|
||||
|
||||
offsetting_value = self.request.POST.get('refund-offsetting', '0') or '0'
|
||||
offsetting_value = formats.sanitize_separators(offsetting_value)
|
||||
for p in payments:
|
||||
value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0'
|
||||
value = formats.sanitize_separators(value)
|
||||
try:
|
||||
offsetting_value = Decimal(offsetting_value)
|
||||
value = Decimal(value)
|
||||
except (DecimalException, TypeError):
|
||||
messages.error(self.request, _('You entered an invalid number.'))
|
||||
is_valid = False
|
||||
else:
|
||||
if offsetting_value:
|
||||
refund_selected += offsetting_value
|
||||
try:
|
||||
order = Order.objects.get(code=self.request.POST.get('order-offsetting'),
|
||||
event__organizer=self.request.organizer)
|
||||
except Order.DoesNotExist:
|
||||
messages.error(self.request, _('You entered an order that could not be found.'))
|
||||
is_valid = False
|
||||
else:
|
||||
refunds.append(OrderRefund(
|
||||
order=self.order,
|
||||
payment=None,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN,
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
execution_date=now(),
|
||||
amount=offsetting_value,
|
||||
provider='offsetting',
|
||||
info=json.dumps({
|
||||
'orders': [order.code]
|
||||
})
|
||||
))
|
||||
|
||||
for p in payments:
|
||||
value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0'
|
||||
value = formats.sanitize_separators(value)
|
||||
try:
|
||||
value = Decimal(value)
|
||||
except (DecimalException, TypeError):
|
||||
messages.error(self.request, _('You entered an invalid number.'))
|
||||
if value == 0:
|
||||
continue
|
||||
elif value > p.available_amount:
|
||||
messages.error(self.request, _('You can not refund more than the amount of a '
|
||||
'payment that is not yet refunded.'))
|
||||
is_valid = False
|
||||
else:
|
||||
if value == 0:
|
||||
continue
|
||||
elif value > p.available_amount:
|
||||
messages.error(self.request, _('You can not refund more than the amount of a '
|
||||
'payment that is not yet refunded.'))
|
||||
is_valid = False
|
||||
break
|
||||
elif value != p.amount and not p.partial_refund_possible:
|
||||
messages.error(self.request, _('You selected a partial refund for a payment method that '
|
||||
'only supports full refunds.'))
|
||||
is_valid = False
|
||||
break
|
||||
elif (p.partial_refund_possible or p.full_refund_possible) and value > 0:
|
||||
refund_selected += value
|
||||
refunds.append(OrderRefund(
|
||||
order=self.order,
|
||||
payment=p,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
provider=p.provider
|
||||
))
|
||||
break
|
||||
elif value != p.amount and not p.partial_refund_possible:
|
||||
messages.error(self.request, _('You selected a partial refund for a payment method that '
|
||||
'only supports full refunds.'))
|
||||
is_valid = False
|
||||
break
|
||||
elif (p.partial_refund_possible or p.full_refund_possible) and value > 0:
|
||||
refund_selected += value
|
||||
refunds.append(OrderRefund(
|
||||
order=self.order,
|
||||
payment=p,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
provider=p.provider
|
||||
))
|
||||
|
||||
any_success = False
|
||||
if refund_selected == full_refund and is_valid:
|
||||
for r in refunds:
|
||||
r.save()
|
||||
self.order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
}, user=self.request.user)
|
||||
if r.payment or r.provider == "offsetting":
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
messages.error(self.request, _('One of the refunds failed to be processed. You should '
|
||||
'retry to refund in a different way. The error message '
|
||||
'was: {}').format(str(e)))
|
||||
else:
|
||||
any_success = True
|
||||
if r.state == OrderRefund.REFUND_STATE_DONE:
|
||||
messages.success(self.request, _('A refund of {} has been processed.').format(
|
||||
money_filter(r.amount, self.request.event.currency)
|
||||
))
|
||||
elif r.state == OrderRefund.REFUND_STATE_CREATED:
|
||||
messages.info(self.request, _('A refund of {} has been saved, but not yet '
|
||||
'fully executed. You can mark it as complete '
|
||||
'below.').format(
|
||||
money_filter(r.amount, self.request.event.currency)
|
||||
))
|
||||
any_success = False
|
||||
if refund_selected == full_refund and is_valid:
|
||||
for r in refunds:
|
||||
r.save()
|
||||
self.order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
}, user=self.request.user)
|
||||
if r.payment or r.provider == "offsetting":
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
messages.error(self.request, _('One of the refunds failed to be processed. You should '
|
||||
'retry to refund in a different way. The error message '
|
||||
'was: {}').format(str(e)))
|
||||
else:
|
||||
any_success = True
|
||||
if r.state == OrderRefund.REFUND_STATE_DONE:
|
||||
messages.success(self.request, _('A refund of {} has been processed.').format(
|
||||
money_filter(r.amount, self.request.event.currency)
|
||||
))
|
||||
elif r.state == OrderRefund.REFUND_STATE_CREATED:
|
||||
messages.info(self.request, _('A refund of {} has been saved, but not yet '
|
||||
'fully executed. You can mark it as complete '
|
||||
'below.').format(
|
||||
money_filter(r.amount, self.request.event.currency)
|
||||
))
|
||||
else:
|
||||
any_success = True
|
||||
|
||||
if any_success:
|
||||
if self.start_form.cleaned_data.get('action') == 'mark_refunded':
|
||||
mark_order_refunded(self.order, user=self.request.user)
|
||||
elif self.start_form.cleaned_data.get('action') == 'mark_pending':
|
||||
if not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0):
|
||||
self.order.status = Order.STATUS_PENDING
|
||||
self.order.set_expires(
|
||||
now(),
|
||||
self.order.event.subevents.filter(
|
||||
id__in=self.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
self.order.save(update_fields=['status', 'expires'])
|
||||
if any_success:
|
||||
if self.start_form.cleaned_data.get('action') == 'mark_refunded':
|
||||
mark_order_refunded(self.order, user=self.request.user)
|
||||
elif self.start_form.cleaned_data.get('action') == 'mark_pending':
|
||||
if not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0):
|
||||
self.order.status = Order.STATUS_PENDING
|
||||
self.order.set_expires(
|
||||
now(),
|
||||
self.order.event.subevents.filter(
|
||||
id__in=self.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
self.order.save(update_fields=['status', 'expires'])
|
||||
|
||||
return redirect(self.get_order_url())
|
||||
else:
|
||||
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
|
||||
'amount.'))
|
||||
return redirect(self.get_order_url())
|
||||
else:
|
||||
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
|
||||
'amount.'))
|
||||
|
||||
return render(self.request, 'pretixcontrol/order/refund_choose.html', {
|
||||
'payments': payments,
|
||||
'remainder': to_refund,
|
||||
'order': self.order,
|
||||
'partial_amount': self.request.POST.get('start-partial_amount'),
|
||||
'start_form': self.start_form
|
||||
})
|
||||
return render(self.request, 'pretixcontrol/order/refund_choose.html', {
|
||||
'payments': payments,
|
||||
'remainder': to_refund,
|
||||
'order': self.order,
|
||||
'partial_amount': self.request.POST.get('start-partial_amount'),
|
||||
'start_form': self.start_form
|
||||
})
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
if self.start_form.is_valid():
|
||||
return self.choose_form()
|
||||
return self.get(*args, **kwargs)
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
if self.start_form.is_valid():
|
||||
return self.choose_form()
|
||||
return render(self.request, 'pretixcontrol/order/refund_start.html', {
|
||||
'form': self.start_form,
|
||||
'order': self.order,
|
||||
@@ -839,6 +849,19 @@ class OrderTransition(OrderView):
|
||||
messages.success(self.request, _('The payment has been created successfully.'))
|
||||
elif self.order.cancel_allowed() and to == 'c':
|
||||
cancel_order(self.order, user=self.request.user, send_mail=self.request.POST.get("send_email") == "on")
|
||||
self.order.refresh_from_db()
|
||||
|
||||
if self.order.pending_sum < 0:
|
||||
messages.success(self.request, _('The order has been canceled. You can now select how you want to '
|
||||
'transfer the money back to the user.'))
|
||||
return redirect(reverse('control:event.order.refunds.start', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'code': self.order.code
|
||||
}) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}'.format(
|
||||
self.order.pending_sum * -1
|
||||
))
|
||||
|
||||
messages.success(self.request, _('The order has been canceled.'))
|
||||
elif self.order.status == Order.STATUS_PENDING and to == 'e':
|
||||
mark_order_expired(self.order, user=self.request.user)
|
||||
@@ -972,7 +995,7 @@ class OrderInvoiceReissue(OrderView):
|
||||
messages.error(self.request, _('The invoice has been cleaned of personal data.'))
|
||||
else:
|
||||
c = generate_cancellation(inv)
|
||||
if self.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
|
||||
if self.order.status != Order.STATUS_CANCELED:
|
||||
inv = generate_invoice(self.order)
|
||||
else:
|
||||
inv = c
|
||||
@@ -1313,7 +1336,7 @@ class OrderContactChange(OrderView):
|
||||
if self.form.cleaned_data['regenerate_secrets']:
|
||||
changed = True
|
||||
self.order.secret = generate_secret()
|
||||
for op in self.order.positions.all():
|
||||
for op in self.order.all_positions.all():
|
||||
op.secret = generate_position_secret()
|
||||
op.save()
|
||||
CachedTicket.objects.filter(order_position__order=self.order).delete()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from django.db.models import Q
|
||||
from django.db.models import Count, IntegerField, OuterRef, Q, Subquery
|
||||
from django.utils.functional import cached_property
|
||||
from django.views.generic import ListView
|
||||
|
||||
from pretix.base.models import Order
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
from pretix.control.forms.filter import OrderSearchFilterForm
|
||||
from pretix.control.views import LargeResultSetPaginator, PaginationMixin
|
||||
|
||||
@@ -24,6 +24,12 @@ class OrderSearch(PaginationMixin, ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Order.objects.select_related('invoice_address')
|
||||
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||
qs = qs.annotate(pcnt=Subquery(s, output_field=IntegerField()))
|
||||
|
||||
if not self.request.user.has_active_staff_session(self.request.session.session_key):
|
||||
qs = qs.filter(
|
||||
Q(event__organizer_id__in=self.request.user.teams.filter(
|
||||
|
||||
Reference in New Issue
Block a user