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
+16 -1
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,
@@ -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"
@@ -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 %}
@@ -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 %}
+4
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'),
-1
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'
+189 -15
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)
})
@@ -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");
@@ -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 {
+279
View File
@@ -0,0 +1,279 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Daniel, Flavia Bastos, Jahongir
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from datetime import timedelta
from decimal import Decimal
import pytest
from bs4 import BeautifulSoup
from django.core import mail
from django.utils.timezone import now
from django_scopes import scopes_disabled
from tests.base import extract_form_fields
from pretix.base.models import (
Event, Item, Order, OrderPayment, OrderPosition, Organizer, Team, User,
)
@pytest.fixture
def env():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy'
)
event.settings.set('ticketoutput_testdummy__enabled', True)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True, can_manage_customers=True)
t.members.add(user)
t.limit_events.add(event)
ticket = Item.objects.create(event=event, name='Early-bird ticket',
category=None, default_price=23,
admission=True, personalized=True)
event.settings.set('attendee_names_asked', True)
event.settings.set('locales', ['en', 'de'])
return event, user, ticket
@pytest.fixture
def order1(env):
o = Order.objects.create(
code='FOO', event=env[0], email='foo@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=14, locale='en'
)
o.payments.create(
amount=o.total, provider='banktransfer', state=OrderPayment.PAYMENT_STATE_PENDING
)
OrderPosition.objects.create(
order=o,
item=env[2],
variation=None,
price=Decimal("14"),
attendee_name_parts={'full_name': "Peter", "_scheme": "full"}
)
return o
@pytest.fixture
def order2(env):
o = Order.objects.create(
code='BAR', event=env[0], email='bar@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=14, locale='en'
)
o.payments.create(
amount=o.total, provider='banktransfer', state=OrderPayment.PAYMENT_STATE_PENDING
)
OrderPosition.objects.create(
order=o,
item=env[2],
variation=None,
price=Decimal("14"),
attendee_name_parts={'full_name': "Peter", "_scheme": "full"}
)
return o
def _run_bulk_action(client, urlname, filter_post_data, submit_post_data):
resp = client.post(f'/control/event/dummy/dummy/orders/bulk/{urlname}', filter_post_data)
doc = BeautifulSoup(resp.content, "lxml")
data = extract_form_fields(doc.select('.container-fluid form')[0])
for k, v in submit_post_data.items():
if v is None:
data.pop(k, None)
else:
data[k] = v
resp = client.post(f'/control/event/dummy/dummy/orders/bulk/{urlname}', data, follow=True)
assert b'alert-success' in resp.content
@pytest.mark.django_db
def test_order_bulk_approve_explicit_id(client, env, order1, order2):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
order1.require_approval = True
order1.save()
order2.require_approval = True
order2.save()
_run_bulk_action(client, 'approve', {'order': order1.pk}, {'operation': 'confirm'})
order1.refresh_from_db()
order2.refresh_from_db()
assert not order1.require_approval
assert order1.status == Order.STATUS_PENDING
assert order2.require_approval
@pytest.mark.django_db
def test_order_bulk_approve_search_form_all(client, env, order1, order2):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
order1.require_approval = True
order1.save()
order2.require_approval = True
order2.save()
_run_bulk_action(client, 'approve', {'query': 'FOO', '__ALL': 'on'}, {'operation': 'confirm'})
order1.refresh_from_db()
order2.refresh_from_db()
assert not order1.require_approval
assert order1.status == Order.STATUS_PENDING
assert order2.require_approval
@pytest.mark.django_db
def test_order_bulk_approve_expert_search_form_all(client, env, order1, order2):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
order1.require_approval = True
order1.save()
order2.require_approval = True
order2.save()
_run_bulk_action(client, 'approve', {'expert-email': 'foo@dummy.test', '__ALL': 'on'}, {'operation': 'confirm'})
order1.refresh_from_db()
order2.refresh_from_db()
assert not order1.require_approval
assert order1.status == Order.STATUS_PENDING
assert order2.require_approval
@pytest.mark.django_db
def test_order_bulk_approve_ignore_wrong_state(client, env, order1, order2):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
order1.require_approval = True
order1.save()
order2.require_approval = True
order2.status = Order.STATUS_CANCELED
order2.save()
resp = client.post('/control/event/dummy/dummy/orders/bulk/approve', {'__ALL': 'on'})
assert b'FOO' in resp.content
assert b'BAR' not in resp.content
_run_bulk_action(client, 'approve', {'__ALL': 'on'}, {'operation': 'confirm'})
order1.refresh_from_db()
order2.refresh_from_db()
assert not order1.require_approval
assert order2.require_approval
@pytest.mark.django_db
def test_order_bulk_deny_ignore_wrong_state(client, env, order1, order2):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
order1.require_approval = True
order1.save()
order2.require_approval = False
order2.save()
mail.outbox = []
resp = client.post('/control/event/dummy/dummy/orders/bulk/deny', {'__ALL': 'on'})
assert b'FOO' in resp.content
assert b'BAR' not in resp.content
_run_bulk_action(client, 'deny', {'__ALL': 'on'}, {'operation': 'confirm', 'bulkactionform-send_email': 'on'})
assert len(mail.outbox) == 1
order1.refresh_from_db()
order2.refresh_from_db()
assert order1.require_approval
assert order1.status == Order.STATUS_CANCELED
assert not order2.require_approval
assert order2.status == Order.STATUS_PENDING
@pytest.mark.django_db
def test_order_bulk_deny_send_no_mail(client, env, order1, order2):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
order1.require_approval = True
order1.save()
order2.require_approval = False
order2.save()
mail.outbox = []
_run_bulk_action(client, 'deny', {'__ALL': 'on'}, {'operation': 'confirm', 'bulkactionform-send_email': None})
assert len(mail.outbox) == 0
@pytest.mark.django_db
def test_order_bulk_expire_ignore_wrong_state(client, env, order1, order2):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
order1.expires = now() - timedelta(days=1)
order1.save()
order2.expires = now() + timedelta(days=1)
order2.save()
mail.outbox = []
resp = client.post('/control/event/dummy/dummy/orders/bulk/expire', {'__ALL': 'on'})
assert b'FOO' in resp.content
assert b'BAR' not in resp.content
_run_bulk_action(client, 'expire', {'__ALL': 'on'}, {'operation': 'confirm'})
order1.refresh_from_db()
order2.refresh_from_db()
assert order1.status == Order.STATUS_EXPIRED
assert order2.status == Order.STATUS_PENDING
@pytest.mark.django_db
def test_order_bulk_delete_ignore_wrong_state(client, env, order1, order2):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
order1.testmode = True
order1.save()
order2.testmode = False
order2.save()
mail.outbox = []
resp = client.post('/control/event/dummy/dummy/orders/bulk/delete', {'__ALL': 'on'})
assert b'FOO' in resp.content
assert b'BAR' not in resp.content
_run_bulk_action(client, 'delete', {'__ALL': 'on'}, {'operation': 'confirm'})
with scopes_disabled():
assert Order.objects.get() == order2