Add bulk operations for orders (#3548)

* Add bulk operations for orders

* UI tweaks

* Fix test failures

* Fix filter form

* Add tests

* Run isort
This commit is contained in:
Raphael Michel
2023-09-06 17:02:21 +02:00
committed by GitHub
parent ce73d4831e
commit c0031e4579
10 changed files with 821 additions and 150 deletions

View File

@@ -159,7 +159,7 @@ class ReactivateOrderForm(ForceQuotaConfirmationForm):
pass
class CancelForm(ForceQuotaConfirmationForm):
class CancelForm(forms.Form):
send_email = forms.BooleanField(
required=False,
label=_('Notify customer by email'),
@@ -188,6 +188,7 @@ class CancelForm(ForceQuotaConfirmationForm):
)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance")
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
self.fields['cancellation_fee'].widget.attrs['placeholder'] = floatformat(
@@ -205,6 +206,20 @@ class CancelForm(ForceQuotaConfirmationForm):
return val
class DenyForm(forms.Form):
send_email = forms.BooleanField(
required=False,
label=_('Notify customer by email'),
initial=True
)
comment = forms.CharField(
label=_('Comment (will be sent to the user)'),
help_text=_('Will be included in the notification email when the respective placeholder is present in the '
'configured email text.'),
required=False,
)
class MarkPaidForm(ConfirmPaymentForm):
send_email = forms.BooleanField(
required=False,

View File

@@ -1,5 +1,6 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% trans "Deny order" %}
{% endblock %}
@@ -13,16 +14,7 @@
<form method="post" href="">
{% csrf_token %}
<div class="checkbox">
<label>
<input type="checkbox" name="send_email" value="on" checked="checked">
{% trans "Notify user by e-mail" %}
</label>
</div>
<p>
<label>{% trans "Comment (will be sent to the user)" %}</label>
<textarea name="comment" class="form-control" rows="5"></textarea>
</p>
{% bootstrap_form form %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"

View File

@@ -0,0 +1,103 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% block title %}{% trans "Modify orders" %}{% endblock %}
{% block content %}
<h1>{% trans "Modify orders" %}</h1>
<form action="{{ request.get_full_path }}" method="post" class="form-horizontal" data-asynctask
data-asynctask-long="">
{% csrf_token %}
<div class="alert alert-info">
{% blocktrans trimmed with label=label allowed=allowed.count total=total %}
The operation <strong>{{ label }}</strong> can be applied to <strong>{{ allowed }}</strong> of the
selected <strong>{{ total }}</strong> orders.
{% endblocktrans %}
</div>
{% if allowed %}
<div class="table-responsive">
<table class="table table-condensed table-hover table-orders">
<thead>
<tr>
<th>{% trans "Order code" %}</th>
<th>{% trans "User" %}</th>
<th>{% trans "Order date" %}</th>
<th class="text-right flip">{% trans "Order total" %}</th>
<th class="text-right flip">{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for o in allowed|slice:":50" %}
<tr>
<td>
<strong>
<a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=o.code %}">
{{ o.code }}</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</td>
<td>
{{ o.email|default_if_none:"" }}
{% if o.invoice_address.name %}
<br>{{ o.invoice_address.name }}
{% endif %}
</td>
<td>
<span class="fa fa-fw fa-{{ o.sales_channel_obj.icon }} text-muted"
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td class="text-right flip">
{{ o.total|money:request.event.currency }}
</td>
<td class="text-right flip">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
</tr>
{% endfor %}
{% if allowed.count > 50 %}
<tr>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<p>
{% blocktrans trimmed %}
Do you want to continue?
{% endblocktrans %}
</p>
<div class="alert alert-warning">
<strong>
{% blocktrans trimmed %}
This operation cannot be reversed.
{% endblocktrans %}
</strong>
</div>
{% for k, l in request.POST.lists %}
{% if "bulkactionform" not in k %}
{% for v in l %}
<input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %}
{% endif %}
{% endfor %}
{% bootstrap_form form layout="control" %}
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.orders" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
{% if allowed %}
<button type="submit" class="btn btn-primary btn-save" value="confirm" name="operation">
{{ label }}
</button>
{% endif %}
</div>
</form>
{% endblock %}

View File

@@ -17,11 +17,12 @@
{% if not request.event.live %}
<a href="{% url "control:event.live" event=request.event.slug organizer=request.event.organizer.slug %}"
class="btn btn-primary btn-lg">
class="btn btn-primary btn-lg">
{% trans "Take your shop live" %}
</a>
{% else %}
<a href="{% eventurl request.event "presale:event.index" %}" class="btn btn-primary btn-lg" target="_blank">
<a href="{% eventurl request.event "presale:event.index" %}" class="btn btn-primary btn-lg"
target="_blank">
{% trans "Go to the ticket shop" %}
</a>
{% endif %}
@@ -40,9 +41,10 @@
</p>
{% else %}
<form class="form-inline"
action="{% url "control:event.orders.go" event=request.event.slug organizer=request.event.organizer.slug %}">
action="{% url "control:event.orders.go" event=request.event.slug organizer=request.event.organizer.slug %}">
<p class="input-group">
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}" autofocus>
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}"
autofocus>
<span class="input-group-btn">
<button class="btn btn-primary" type="submit">{% trans "Go!" %}</button>
</span>
@@ -82,7 +84,8 @@
{% endif %}
</div>
<div class="text-right flip">
<a href="{% url "control:event.orders.search" event=request.event.slug organizer=request.event.organizer.slug %}" class="btn btn-default btn-lg">
<a href="{% url "control:event.orders.search" event=request.event.slug organizer=request.event.organizer.slug %}"
class="btn btn-default btn-lg">
{% trans "Advanced search" %}
</a>
<button class="btn btn-primary btn-lg" type="submit">
@@ -99,134 +102,216 @@
{% blocktrans trimmed with question=filter_form.cleaned_data.question.question %}
List filtered by answers to question "{{ question }}".
{% endblocktrans %}
<a href="?{% url_replace request 'question' '' 'answer' ''%}" class="text-muted">
<a href="?{% url_replace request 'question' '' 'answer' '' %}" class="text-muted">
<span class="fa fa-times"></span>
{% trans "Remove filter" %}
</a>
</p>
{% endif %}
<div class="table-responsive">
<table class="table table-condensed table-hover table-orders">
<thead>
<tr>
<th>{% trans "Order code" %}
<a href="?{% url_replace request 'ordering' '-code' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'code' %}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "User" %}
<a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Order date" %}
<a href="?{% url_replace request 'ordering' '-datetime' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'datetime' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th class="text-right flip">{% trans "Order paid / total" %}
<a href="?{% url_replace request 'ordering' '-total' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'total' %}"><i class="fa fa-caret-up"></i></a></th>
<th class="text-right flip">{% trans "Positions" %}</th>
<th class="text-right flip">{% trans "Status" %}
<a href="?{% url_replace request 'ordering' '-status' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'status' %}"><i class="fa fa-caret-up"></i></a></th>
</tr>
</thead>
<tbody>
{% for o in orders %}
<tr>
<td>
<strong>
<a
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=o.code %}">
{{ o.code }}</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
{% if o.custom_followup_due %}
<span class="label label-danger">{% blocktrans with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span>
{% elif o.custom_followup_at %}
<span class="label label-default">{% blocktrans with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span>
{% endif %}
</td>
<td>
{{ o.email|default_if_none:"" }}
{% if o.invoice_address.name %}
<br>{{ o.invoice_address.name }}
{% endif %}
</td>
<td>
<span class="fa fa-fw fa-{{ o.sales_channel_obj.icon }} text-muted"
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td class="text-right flip">
{% if o.has_cancellation_request %}
<span class="label label-warning">{% trans "CANCELLATION REQUESTED" %}</span>
{% endif %}
{% if o.has_external_refund or o.has_pending_refund %}
<span class="label label-danger">{% trans "REFUND PENDING" %}</span>
{% elif o.has_pending_refund %}
<span class="label label-warning">{% trans "REFUND PENDING" %}</span>
{% endif %}
{% if o.is_overpaid %}
<span class="label label-warning">{% trans "OVERPAID" %}</span>
{% elif o.is_underpaid %}
<span class="label label-danger">{% trans "UNDERPAID" %}</span>
{% elif o.is_pending_with_full_payment %}
<span class="label label-danger">{% trans "FULLY PAID" %}</span>
{% endif %}
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
<span class="text-muted">
{% endif %}
{{ o.computed_payment_refund_sum|money:request.event.currency }} /
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
</span>
{% endif %}
{{ o.total|money:request.event.currency }}
{% if o.status == "c" and o.icnt %}
<br>
<span class="label label-warning">{% trans "INVOICE NOT CANCELED" %}</span>
{% endif %}
</td>
<td class="text-right flip">{{ o.pcnt|default_if_none:"0" }}</td>
<td class="text-right flip">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
</tr>
<form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}
{% endfor %}
{% for form in filter_forms %}
{% for field in form %}
{{ field.as_hidden }}
{% endfor %}
</tbody>
{% if sums %}
<tfoot>
{% endfor %}
<div class="table-responsive">
<table class="table table-condensed table-hover table-orders">
<thead>
<tr>
<th>{% trans "Sum over all pages" %}</th>
<th></th>
<th>
{% blocktrans trimmed count s=sums.c %}
1 order
{% plural %}
{{ s }} orders
{% endblocktrans %}
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
</th>
<th class="text-right flip">
{% if not filter_form.filtered %}
<span class="fa fa-info-circle text-muted" data-toggle="tooltip"
title='{% trans 'This sum includes canceled orders. For your ticket revenue, look at the "order overview".' %}'></span>
{% if sums.s|default_if_none:"none" != "none" %}
{{ sums.s|money:request.event.currency }}
{% endif %}
{% endif %}
<th>{% trans "Order code" %}
<a href="?{% url_replace request 'ordering' '-code' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'code' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th class="text-right flip">
{% if not filter_form.filtered %}
<span class="fa fa-info-circle text-muted" data-toggle="tooltip"
title='{% trans 'This sum includes canceled orders. For your ticket revenue, look at the "order overview".' %}'></span>
{% if sums.pc %}
{{ sums.pc }}
{% endif %}
{% endif %}
<th>{% trans "User" %}
<a href="?{% url_replace request 'ordering' '-email' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Order date" %}
<a href="?{% url_replace request 'ordering' '-datetime' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'datetime' %}"><i
class="fa fa-caret-up"></i></a>
</th>
<th class="text-right flip">{% trans "Order paid / total" %}
<a href="?{% url_replace request 'ordering' '-total' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'total' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th class="text-right flip">{% trans "Positions" %}</th>
<th class="text-right flip">{% trans "Status" %}
<a href="?{% url_replace request 'ordering' '-status' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'status' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th></th>
</tr>
</tfoot>
{% endif %}
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all"
data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="6">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for o in orders %}
<tr>
<td>
<label aria-label="{% trans "select row for batch-operation" %}"
class="batch-select-label"><input type="checkbox" name="order"
class="batch-select-checkbox"
value="{{ o.pk }}"/></label>
</td>
<td>
<strong>
<a
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=o.code %}">
{{ o.code }}</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
{% if o.custom_followup_due %}
<span class="label label-danger">{% blocktrans with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}
TODO {{ date }}{% endblocktrans %}</span>
{% elif o.custom_followup_at %}
<span class="label label-default">{% blocktrans with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}
TODO {{ date }}{% endblocktrans %}</span>
{% endif %}
</td>
<td>
{{ o.email|default_if_none:"" }}
{% if o.invoice_address.name %}
<br>{{ o.invoice_address.name }}
{% endif %}
</td>
<td>
<span class="fa fa-fw fa-{{ o.sales_channel_obj.icon }} text-muted"
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td class="text-right flip">
{% if o.has_cancellation_request %}
<span class="label label-warning">{% trans "CANCELLATION REQUESTED" %}</span>
{% endif %}
{% if o.has_external_refund or o.has_pending_refund %}
<span class="label label-danger">{% trans "REFUND PENDING" %}</span>
{% elif o.has_pending_refund %}
<span class="label label-warning">{% trans "REFUND PENDING" %}</span>
{% endif %}
{% if o.is_overpaid %}
<span class="label label-warning">{% trans "OVERPAID" %}</span>
{% elif o.is_underpaid %}
<span class="label label-danger">{% trans "UNDERPAID" %}</span>
{% elif o.is_pending_with_full_payment %}
<span class="label label-danger">{% trans "FULLY PAID" %}</span>
{% endif %}
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
<span class="text-muted">
{% endif %}
{{ o.computed_payment_refund_sum|money:request.event.currency }} /
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
</span>
{% endif %}
{{ o.total|money:request.event.currency }}
{% if o.status == "c" and o.icnt %}
<br>
<span class="label label-warning">{% trans "INVOICE NOT CANCELED" %}</span>
{% endif %}
</td>
<td class="text-right flip">{{ o.pcnt|default_if_none:"0" }}</td>
<td class="text-right flip">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
</tr>
{% endfor %}
</tbody>
{% if sums %}
<tfoot>
<tr>
<th></th>
<th>{% trans "Sum over all pages" %}</th>
<th></th>
<th>
{% blocktrans trimmed count s=sums.c %}
1 order
{% plural %}
{{ s }} orders
{% endblocktrans %}
</th>
<th class="text-right flip">
{% if not filter_form.filtered %}
<span class="fa fa-info-circle text-muted" data-toggle="tooltip"
title='{% trans 'This sum includes canceled orders. For your ticket revenue, look at the "order overview".' %}'></span>
{% if sums.s|default_if_none:"none" != "none" %}
{{ sums.s|money:request.event.currency }}
{% endif %}
{% endif %}
</th>
<th class="text-right flip">
{% if not filter_form.filtered %}
<span class="fa fa-info-circle text-muted" data-toggle="tooltip"
title='{% trans 'This sum includes canceled orders. For your ticket revenue, look at the "order overview".' %}'></span>
{% if sums.pc %}
{{ sums.pc }}
{% endif %}
{% endif %}
</th>
<th></th>
</tr>
</tfoot>
{% endif %}
</table>
</div>
<div class="batch-select-actions">
<div class="btn-group dropup">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
{% trans "Select action" %}
</button>
<ul class="dropdown-menu">
<li>
<button type="submit" class="btn"
formaction="{% url "control:event.orders.bulk.approve" organizer=request.organizer.slug event=request.event.slug %}">
<i class="fa fa-thumbs-up fa-fw text-green"></i> {% trans "Approve" %}
</button>
</li>
<li>
<button type="submit" class="btn"
formaction="{% url "control:event.orders.bulk.deny" organizer=request.organizer.slug event=request.event.slug %}">
<i class="fa fa-thumbs-down fa-fw text-danger"></i> {% trans "Deny" %}
</button>
</li>
{% if not request.event.settings.payment_term_expire_automatically %}
<li>
<button type="submit" class="btn"
formaction="{% url "control:event.orders.bulk.expire" organizer=request.organizer.slug event=request.event.slug %}">
<i class="fa fa-times fa-fw"></i>{% trans "Mark as expired if overdue" %}
</button>
</li>
{% endif %}
<li>
<button type="submit" class="btn"
formaction="{% url "control:event.orders.bulk.delete" organizer=request.organizer.slug event=request.event.slug %}">
<i class="fa fa-trash fa-fw text-danger"></i>{% trans "Delete (test mode only)" %}
</button>
</li>
</ul>
</div>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -414,6 +414,10 @@ urlpatterns = [
re_path(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),
re_path(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
re_path(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
re_path(r'^orders/bulk/approve$', orders.OrderApproveBulkActionView.as_view(), name='event.orders.bulk.approve'),
re_path(r'^orders/bulk/deny$', orders.OrderDenyBulkActionView.as_view(), name='event.orders.bulk.deny'),
re_path(r'^orders/bulk/expire$', orders.OrderExpireBulkActionView.as_view(), name='event.orders.bulk.expire'),
re_path(r'^orders/bulk/delete$', orders.OrderDeleteBulkActionView.as_view(), name='event.orders.bulk.delete'),
re_path(r'^orders/search$', orders.OrderSearch.as_view(), name='event.orders.search'),
re_path(r'^dangerzone/$', event.DangerZone.as_view(), name='event.dangerzone'),
re_path(r'^cancel/$', orders.EventCancel.as_view(), name='event.cancel'),

View File

@@ -192,7 +192,6 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, CheckInList
class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMixin, AsyncPostView):
template_name = 'pretixcontrol/organizers/device_bulk_edit.html'
permission = ('can_change_orders', 'can_checkin_orders')
context_object_name = 'device'

View File

@@ -45,12 +45,12 @@ from urllib.parse import quote, urlencode
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File
from django.db import transaction
from django.db.models import (
Count, Exists, F, IntegerField, OuterRef, Prefetch, ProtectedError, Q,
Subquery, Sum,
QuerySet, Subquery, Sum,
)
from django.forms import formset_factory
from django.http import (
@@ -111,16 +111,16 @@ from pretix.base.signals import (
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.mixins import OrderQuestionsViewMixin
from pretix.base.views.tasks import AsyncAction
from pretix.base.views.tasks import AsyncAction, AsyncFormView
from pretix.control.forms.exports import ScheduledEventExportForm
from pretix.control.forms.filter import (
EventOrderExpertFilterForm, EventOrderFilterForm, OverviewFilterForm,
RefundFilterForm,
)
from pretix.control.forms.orders import (
CancelForm, CommentForm, ConfirmPaymentForm, EventCancelForm, ExporterForm,
ExtendForm, MarkPaidForm, OrderContactForm, OrderFeeChangeForm,
OrderLocaleForm, OrderMailForm, OrderPositionAddForm,
CancelForm, CommentForm, ConfirmPaymentForm, DenyForm, EventCancelForm,
ExporterForm, ExtendForm, MarkPaidForm, OrderContactForm,
OrderFeeChangeForm, OrderLocaleForm, OrderMailForm, OrderPositionAddForm,
OrderPositionAddFormset, OrderPositionChangeForm, OrderPositionMailForm,
OrderRefundForm, OtherOperationsForm, ReactivateOrderForm,
)
@@ -137,10 +137,17 @@ logger = logging.getLogger(__name__)
class OrderSearchMixin:
@cached_property
def request_data(self):
if self.request.method == "POST":
return self.request.POST
return self.request.GET
def get_forms(self):
f = [
EventOrderExpertFilterForm(
data=self.request.GET,
data=self.request_data,
event=self.request.event,
prefix='expert',
)
@@ -160,6 +167,167 @@ class OrderSearch(OrderSearchMixin, EventPermissionRequiredMixin, TemplateView):
return ctx
class BaseOrderBulkActionView(OrderSearchMixin, EventPermissionRequiredMixin, AsyncFormView):
template_name = 'pretixcontrol/orders/bulk_action.html'
permission = 'can_change_orders'
form_class = forms.Form
def get_queryset(self):
qs = Order.objects.filter(
event=self.request.event
).select_related('invoice_address')
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
for f in self.get_forms():
if any(k.startswith(f.prefix) for k in self.request.POST.keys()):
if not f.is_valid():
raise PermissionDenied("Invalid query") # better safe than sorry with this one
qs = f.filter_qs(qs)
if 'order' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('order')
)
elif '__ALL' not in self.request_data:
raise PermissionDenied("Invalid query") # better safe than sorry with this one
return qs
@cached_property
def filter_form(self):
return EventOrderFilterForm(data=self.request.POST, event=self.request.event)
@property
def label(self) -> str:
raise NotImplementedError()
def allowed_for(self, queryset: QuerySet) -> QuerySet:
raise NotImplementedError()
def execute_single(self, instance, form: forms.Form):
raise NotImplementedError()
def execute_bulk(self, queryset: QuerySet, form: forms.Form):
qs = self.allowed_for(self.allowed_for(self.get_queryset()))
total = qs.count()
for i, o in enumerate(qs):
self.execute_single(o, form)
if i % 100 == 0:
self.async_set_progress(i / total * 100)
def get_error_url(self):
return self.get_success_url(None)
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
return render(request, self.template_name, self.get_context_data())
def get_success_url(self, value):
return reverse('control:event.orders', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['total'] = self.get_queryset().count()
ctx['allowed'] = self.allowed_for(self.get_queryset())
ctx['label'] = self.label
ctx['form'] = self.get_form()
return ctx
def get_form_kwargs(self):
kwargs = {
"initial": self.get_initial(),
"prefix": self.get_prefix(),
}
if self.request.method in ("POST", "PUT") and self.request.POST.get("operation") == "confirm":
kwargs.update(
{
"data": self.request.POST,
"files": self.request.FILES,
}
)
return kwargs
def post(self, request, *args, **kwargs):
"""
Handle POST requests: instantiate a form instance with the passed
POST variables and then check if it's valid.
"""
form = self.get_form()
if self.request.POST.get("operation") == "confirm" and form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def get_prefix(self):
return "bulkactionform"
@transaction.atomic()
def async_form_valid(self, task, form):
self.execute_bulk(self.allowed_for(self.get_queryset()), form)
class OrderApproveBulkActionView(BaseOrderBulkActionView):
label = _("Approve")
def allowed_for(self, queryset):
return queryset.filter(
status=Order.STATUS_PENDING,
require_approval=True,
)
def execute_single(self, instance, form: forms.Form):
approve_order(instance, user=self.request.user)
class OrderDenyBulkActionView(BaseOrderBulkActionView):
label = _("Deny")
form_class = DenyForm
def allowed_for(self, queryset):
return queryset.filter(
status=Order.STATUS_PENDING,
require_approval=True,
)
def execute_single(self, instance, form: forms.Form):
deny_order(instance, user=self.request.user,
comment=form.cleaned_data.get('comment') or None,
send_mail=form.cleaned_data['send_email'])
class OrderExpireBulkActionView(BaseOrderBulkActionView):
label = _("Mark as expired if overdue")
def allowed_for(self, queryset):
return queryset.filter(
status=Order.STATUS_PENDING,
require_approval=False,
expires__lt=now(),
)
def execute_single(self, instance, form: forms.Form):
mark_order_expired(instance, user=self.request.user)
class OrderDeleteBulkActionView(BaseOrderBulkActionView):
label = _("Delete")
def allowed_for(self, queryset):
return queryset.filter(
testmode=True,
)
def execute_single(self, instance, form: forms.Form):
instance.gracefully_delete(user=self.request.user)
class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin, ListView):
model = Order
context_object_name = 'orders'
@@ -183,6 +351,7 @@ class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin,
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
ctx['filter_forms'] = self.get_forms()
ctx['filter_strings'] = []
for f in self.get_forms():
@@ -607,21 +776,26 @@ class OrderDelete(OrderView):
class OrderDeny(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
def post(self, request, *args, **kwargs):
if self.order.require_approval:
try:
deny_order(self.order, user=self.request.user,
comment=self.request.POST.get('comment'),
send_mail=self.request.POST.get('send_email') == 'on')
except OrderError as e:
messages.error(self.request, str(e))
form = DenyForm(self.request.POST if self.request.method == "POST" else None)
if form.is_valid():
try:
deny_order(self.order, user=self.request.user,
comment=self.request.POST.get('comment'),
send_mail=self.request.POST.get('send_email') == 'on')
except OrderError as e:
messages.error(self.request, str(e))
else:
messages.success(self.request, _('The order has been denied and is therefore now canceled.'))
else:
messages.success(self.request, _('The order has been denied and is therefore now canceled.'))
return self.get(request, *args, **kwargs)
return redirect(self.get_order_url())
def get(self, *args, **kwargs):
return render(self.request, 'pretixcontrol/order/deny.html', {
'order': self.order,
'form': DenyForm(self.request.POST if self.request.method == "POST" else None)
})

View File

@@ -784,7 +784,7 @@ function setup_basics(el) {
el.find("input[data-toggle-table]").each(function (ev) {
var $toggle = $(this);
var $actionButtons = $(".batch-select-actions button", this.form);
var countLabels = $("<span></span>").appendTo($actionButtons);
var countLabels = $("<span></span>").appendTo($actionButtons.filter(function () { return !$(this).closest(".dropdown-menu").length }));
var $table = $toggle.closest("table");
var $selectAll = $table.find(".table-select-all");
var $rows = $table.find("tbody tr");

View File

@@ -788,12 +788,32 @@ table td > .checkbox input[type="checkbox"] {
padding-bottom: 5px;
}
.batch-select-label {
display: block;
width: 100%;
height: 1.5em;
cursor: pointer;
margin: 0;
}
.dropdown-menu > li > button.btn {
display: block;
padding: 3px 20px;
clear: both;
font-weight: 400;
line-height: $line-height-base;
color: $dropdown-link-color;
white-space: nowrap;
background: inherit;
border: 0;
width: 100%;
text-align: left;
&:hover, &:focus {
color: $dropdown-link-hover-color;
text-decoration: none;
background-color: $dropdown-link-hover-bg;
}
}
.bulk-edit-field-group {