Allow to bulk-select many tickets to check in or out (#2678)

* Allow to bulk-select many tickets to check in or out

* Update tests

* Add permission test

* Update src/pretix/control/templates/pretixcontrol/checkin/index.html

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Update src/pretix/static/pretixbase/js/asynctask.js

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Remove console.warn

* Simplify stuff

* minor refactor

* fix missing checked-out success message

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2022-06-10 12:14:44 +02:00
committed by GitHub
parent f3a84c1d6e
commit 86085d9368
8 changed files with 183 additions and 34 deletions

View File

@@ -29,12 +29,13 @@ from celery.result import AsyncResult
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import JsonResponse, QueryDict from django.http import HttpResponse, JsonResponse, QueryDict
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.test import RequestFactory from django.test import RequestFactory
from django.utils import timezone, translation from django.utils import timezone, translation
from django.utils.timezone import get_current_timezone from django.utils.timezone import get_current_timezone
from django.utils.translation import get_language, gettext as _ from django.utils.translation import get_language, gettext as _
from django.views import View
from django.views.generic import FormView from django.views.generic import FormView
from redis import ResponseError from redis import ResponseError
@@ -312,3 +313,94 @@ class AsyncFormView(AsyncMixin, FormView):
else: else:
return self.error(res.info) return self.error(res.info)
return redirect(self.get_check_url(res.id, False)) return redirect(self.get_check_url(res.id, False))
class AsyncPostView(AsyncMixin, View):
"""
View variant in which instead of ``post``, an ``async_post`` is executed in a celery task.
Note that this places some severe limitations on the form and the view, e.g. ``async_post`` may not
depend on the request object unless specifically supported by this class. File upload is currently also
not supported.
"""
known_errortypes = ['ValidationError']
expected_exceptions = (ValidationError,)
task_base = ProfiledEventTask
def __init_subclass__(cls):
def async_execute(self, *, request_path, url_args, url_kwargs, query_string, post_data, locale, tz,
organizer=None, event=None, user=None, session_key=None):
view_instance = cls()
req = RequestFactory().post(
request_path + '?' + query_string,
data=post_data,
content_type='application/x-www-form-urlencoded'
)
view_instance.request = req
if event:
view_instance.request.event = event
view_instance.request.organizer = event.organizer
elif organizer:
view_instance.request.organizer = organizer
if user:
view_instance.request.user = User.objects.get(pk=user) if isinstance(user, int) else user
if session_key:
engine = import_module(settings.SESSION_ENGINE)
self.SessionStore = engine.SessionStore
view_instance.request.session = self.SessionStore(session_key)
with translation.override(locale), timezone.override(pytz.timezone(tz)):
return view_instance.async_post(view_instance.request, *url_args, **url_kwargs)
cls.async_execute = app.task(
base=cls.task_base,
bind=True,
name=cls.__module__ + '.' + cls.__name__ + '.async_execute',
throws=cls.expected_exceptions
)(async_execute)
def async_post(self, request, *args, **kwargs):
pass
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
return HttpResponse(status=405)
def post(self, request, *args, **kwargs):
if request.FILES:
raise TypeError('File upload currently not supported in AsyncPostView')
kwargs = {
'request_path': self.request.path,
'query_string': self.request.GET.urlencode(),
'post_data': self.request.POST.urlencode(),
'locale': get_language(),
'url_args': args,
'url_kwargs': kwargs,
'tz': get_current_timezone().zone,
}
if hasattr(self.request, 'organizer'):
kwargs['organizer'] = self.request.organizer.pk
if self.request.user.is_authenticated:
kwargs['user'] = self.request.user.pk
if hasattr(self.request, 'event'):
kwargs['event'] = self.request.event.pk
if hasattr(self.request, 'session'):
kwargs['session_key'] = self.request.session.session_key
try:
res = type(self).async_execute.apply_async(kwargs=kwargs)
except ConnectionError:
# Task very likely not yet sent, due to redis restarting etc. Let's try once again
res = type(self).async_execute.apply_async(kwargs=kwargs)
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
data = self._return_ajax_result(res)
data['check_url'] = self.get_check_url(res.id, True)
return JsonResponse(data)
else:
if res.ready():
if res.successful() and not isinstance(res.info, Exception):
return self.success(res.info)
else:
return self.error(res.info)
return redirect(self.get_check_url(res.id, False))

View File

@@ -71,13 +71,20 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<form method="post" action=""> <form method="post" action="{% url "control:event.orders.checkinlists.bulk_action" event=request.event.slug organizer=request.event.organizer.slug list=checkinlist.pk %}" data-asynctask>
<div class="hidden">
{{ filter_form.as_p }}
<input name="returnquery" type="hidden" value="{{ request.META.QUERY_STRING }}">
</div>
{% csrf_token %} {% csrf_token %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-condensed table-hover"> <table class="table table-condensed table-hover">
<thead> <thead>
<tr> <tr>
<th></th> <th>
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
</th>
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a> <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> <a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Item" %} <a href="?{% url_replace request 'ordering' '-item'%}"><i class="fa fa-caret-down"></i></a> <th>{% trans "Item" %} <a href="?{% url_replace request 'ordering' '-item'%}"><i class="fa fa-caret-down"></i></a>
@@ -100,6 +107,19 @@
<th>{% trans "Timestamp" %} <a href="?{% url_replace request 'ordering' '-timestamp'%}"><i class="fa fa-caret-down"></i></a> <th>{% trans "Timestamp" %} <a href="?{% url_replace request 'ordering' '-timestamp'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'timestamp'%}"><i class="fa fa-caret-up"></i></a></th> <a href="?{% url_replace request 'ordering' 'timestamp'%}"><i class="fa fa-caret-up"></i></a></th>
</tr> </tr>
{% 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="8">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead> </thead>
<tbody> <tbody>
{% for e in entries %} {% for e in entries %}
@@ -180,13 +200,16 @@
</div> </div>
{% if "can_change_orders" in request.eventpermset %} {% if "can_change_orders" in request.eventpermset %}
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">
<span class="fa fa-sign-in" aria-hidden="true"></span>
{% trans "Check-In selected attendees" %} {% trans "Check-In selected attendees" %}
</button> </button>
<button type="submit" class="btn btn-default btn-save" name="checkout" value="true"> <button type="submit" class="btn btn-default btn-save" name="checkout" value="true">
<span class="fa fa-sign-out" aria-hidden="true"></span>
{% trans "Check-Out selected attendees" %} {% trans "Check-Out selected attendees" %}
</button> </button>
<button type="submit" class="btn btn-default btn-save" name="revert" value="true"> <button type="submit" class="btn btn-danger btn-save" name="revert" value="true">
{% trans "Revert selected check-ins" %} <span class="fa fa-trash" aria-hidden="true"></span>
{% trans "Delete all check-ins of selected attendees" %}
</button> </button>
{% endif %} {% endif %}
</form> </form>

View File

@@ -392,6 +392,7 @@ urlpatterns = [
re_path(r'^checkinlists/add$', checkin.CheckinListCreate.as_view(), name='event.orders.checkinlists.add'), re_path(r'^checkinlists/add$', checkin.CheckinListCreate.as_view(), name='event.orders.checkinlists.add'),
re_path(r'^checkinlists/select2$', typeahead.checkinlist_select2, name='event.orders.checkinlists.select2'), re_path(r'^checkinlists/select2$', typeahead.checkinlist_select2, name='event.orders.checkinlists.select2'),
re_path(r'^checkinlists/(?P<list>\d+)/$', checkin.CheckInListShow.as_view(), name='event.orders.checkinlists.show'), re_path(r'^checkinlists/(?P<list>\d+)/$', checkin.CheckInListShow.as_view(), name='event.orders.checkinlists.show'),
re_path(r'^checkinlists/(?P<list>\d+)/bulk_action$', checkin.CheckInListBulkActionView.as_view(), name='event.orders.checkinlists.bulk_action'),
re_path(r'^checkinlists/(?P<list>\d+)/change$', checkin.CheckinListUpdate.as_view(), re_path(r'^checkinlists/(?P<list>\d+)/change$', checkin.CheckinListUpdate.as_view(),
name='event.orders.checkinlists.edit'), name='event.orders.checkinlists.edit'),
re_path(r'^checkinlists/(?P<list>\d+)/delete$', checkin.CheckinListDelete.as_view(), re_path(r'^checkinlists/(?P<list>\d+)/delete$', checkin.CheckinListDelete.as_view(),

View File

@@ -37,7 +37,7 @@ from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import Exists, Max, OuterRef, Prefetch, Subquery from django.db.models import Exists, Max, OuterRef, Prefetch, Subquery
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import is_aware, make_aware, now from django.utils.timezone import is_aware, make_aware, now
@@ -49,6 +49,7 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.models import Checkin, Order, OrderPosition from pretix.base.models import Checkin, Order, OrderPosition
from pretix.base.models.checkin import CheckinList from pretix.base.models.checkin import CheckinList
from pretix.base.signals import checkin_created from pretix.base.signals import checkin_created
from pretix.base.views.tasks import AsyncPostView
from pretix.control.forms.checkin import CheckinListForm from pretix.control.forms.checkin import CheckinListForm
from pretix.control.forms.filter import ( from pretix.control.forms.filter import (
CheckinFilterForm, CheckinListAttendeeFilterForm, CheckinFilterForm, CheckinListAttendeeFilterForm,
@@ -58,11 +59,13 @@ from pretix.control.views import CreateView, PaginationMixin, UpdateView
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView): class CheckInListQueryMixin:
model = Checkin
context_object_name = 'entries' @cached_property
template_name = 'pretixcontrol/checkin/index.html' def request_data(self):
permission = 'can_view_orders' if self.request.method == "POST":
return self.request.POST
return self.request.GET
def get_queryset(self, filter=True): def get_queryset(self, filter=True):
cqs = Checkin.objects.filter( cqs = Checkin.objects.filter(
@@ -105,16 +108,28 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
if filter and self.filter_form.is_valid(): if filter and self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs) qs = self.filter_form.filter_qs(qs)
if 'checkin' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('checkin')
)
return qs return qs
@cached_property @cached_property
def filter_form(self): def filter_form(self):
return CheckinListAttendeeFilterForm( return CheckinListAttendeeFilterForm(
data=self.request.GET, data=self.request_data,
event=self.request.event, event=self.request.event,
list=self.list list=self.list
) )
class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, CheckInListQueryMixin, ListView):
model = Checkin
context_object_name = 'entries'
template_name = 'pretixcontrol/checkin/index.html'
permission = 'can_view_orders'
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.list = get_object_or_404(self.request.event.checkin_lists.all(), pk=kwargs.get("list")) self.list = get_object_or_404(self.request.event.checkin_lists.all(), pk=kwargs.get("list"))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -153,18 +168,23 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
e.last_exit_aware = e.last_exit e.last_exit_aware = e.last_exit
return ctx return ctx
def post(self, request, *args, **kwargs):
if "can_change_orders" not in request.eventpermset:
messages.error(request, _('You do not have permission to perform this action.'))
return redirect(reverse('control:event.orders.checkins', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug
}) + '?' + request.GET.urlencode())
positions = self.get_queryset(filter=False).filter( class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMixin, AsyncPostView):
pk__in=request.POST.getlist('checkin') template_name = 'pretixcontrol/organizers/device_bulk_edit.html'
) permission = 'can_change_orders'
context_object_name = 'device'
def dispatch(self, request, *args, **kwargs):
self.list = get_object_or_404(self.request.event.checkin_lists.all(), pk=kwargs.get("list"))
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return super().get_queryset().prefetch_related(None).order_by()
@transaction.atomic()
def async_post(self, request, *args, **kwargs):
self.list = get_object_or_404(request.event.checkin_lists.all(), pk=kwargs.get("list"))
positions = self.get_queryset()
if request.POST.get('revert') == 'true': if request.POST.get('revert') == 'true':
for op in positions: for op in positions:
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING): if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
@@ -177,7 +197,7 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
}, user=request.user) }, user=request.user)
op.order.touch() op.order.touch()
messages.success(request, _('The selected check-ins have been reverted.')) return 'reverted', request.POST.get('returnquery')
else: else:
for op in positions: for op in positions:
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING): if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
@@ -206,14 +226,22 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
'web': True 'web': True
}, user=request.user) }, user=request.user)
checkin_created.send(op.order.event, checkin=ci) checkin_created.send(op.order.event, checkin=ci)
return 'checked-out' if t == Checkin.TYPE_EXIT else 'checked-in', request.POST.get('returnquery')
messages.success(request, _('The selected tickets have been marked as checked in.')) def get_success_message(self, value):
if value[0] == 'reverted':
return _('The selected check-ins have been reverted.')
elif value[0] == 'checked-out':
return _('The selected tickets have been marked as checked out.')
else:
return _('The selected tickets have been marked as checked in.')
return redirect(reverse('control:event.orders.checkinlists.show', kwargs={ def get_success_url(self, value):
return reverse('control:event.orders.checkinlists.show', kwargs={
'event': self.request.event.slug, 'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug, 'organizer': self.request.event.organizer.slug,
'list': self.list.pk 'list': self.list.pk
}) + '?' + request.GET.urlencode()) }) + ('?' + value[1] if value[1] else '')
class CheckinListList(EventPermissionRequiredMixin, PaginationMixin, ListView): class CheckinListList(EventPermissionRequiredMixin, PaginationMixin, ListView):

View File

@@ -830,7 +830,7 @@ class DeviceQueryMixin:
@cached_property @cached_property
def filter_form(self): def filter_form(self):
return DeviceFilterForm(data=self.request.GET, request=self.request) return DeviceFilterForm(data=self.request_data, request=self.request)
def get_queryset(self): def get_queryset(self):
qs = self.request.organizer.devices.prefetch_related( qs = self.request.organizer.devices.prefetch_related(

View File

@@ -218,14 +218,17 @@ $(function () {
'this page and try again.' 'this page and try again.'
)); ));
var formData = new FormData(this);
console.log($(this).get(0))
var formData = new FormData($(this).get(0))
formData.append('ajax', '1'); formData.append('ajax', '1');
// Not supported on IE, may lead to wrong results, but we don't support IE in the backend anymore
var submitter = e.originalEvent.submitter;
if (submitter && submitter.name) {
formData.append(submitter.name, submitter.value);
}
$.ajax( $.ajax(
{ {
'type': 'POST', 'type': 'POST',
'url': $(this).attr('action'), 'url': this.action,
'data': formData, 'data': formData,
processData: false, processData: false,
contentType: false, contentType: false,

View File

@@ -297,7 +297,7 @@ def test_manual_checkins(client, checkin_list_env):
client.login(email='dummy@dummy.dummy', password='dummy') client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled(): with scopes_disabled():
assert not checkin_list_env[5][3].checkins.exists() assert not checkin_list_env[5][3].checkins.exists()
client.post('/control/event/dummy/dummy/checkinlists/{}/'.format(checkin_list_env[6].pk), { client.post('/control/event/dummy/dummy/checkinlists/{}/bulk_action'.format(checkin_list_env[6].pk), {
'checkin': [checkin_list_env[5][3].pk] 'checkin': [checkin_list_env[5][3].pk]
}) })
with scopes_disabled(): with scopes_disabled():
@@ -312,10 +312,10 @@ def test_manual_checkins_revert(client, checkin_list_env):
client.login(email='dummy@dummy.dummy', password='dummy') client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled(): with scopes_disabled():
assert not checkin_list_env[5][3].checkins.exists() assert not checkin_list_env[5][3].checkins.exists()
client.post('/control/event/dummy/dummy/checkinlists/{}/'.format(checkin_list_env[6].pk), { client.post('/control/event/dummy/dummy/checkinlists/{}/bulk_action'.format(checkin_list_env[6].pk), {
'checkin': [checkin_list_env[5][3].pk] 'checkin': [checkin_list_env[5][3].pk]
}) })
client.post('/control/event/dummy/dummy/checkinlists/{}/'.format(checkin_list_env[6].pk), { client.post('/control/event/dummy/dummy/checkinlists/{}/bulk_action'.format(checkin_list_env[6].pk), {
'checkin': [checkin_list_env[5][3].pk], 'checkin': [checkin_list_env[5][3].pk],
'revert': 'true' 'revert': 'true'
}) })

View File

@@ -169,6 +169,7 @@ event_urls = [
"checkinlists/1/", "checkinlists/1/",
"checkinlists/1/change", "checkinlists/1/change",
"checkinlists/1/delete", "checkinlists/1/delete",
"checkinlists/1/bulk_action",
"waitinglist/", "waitinglist/",
"waitinglist/auto_assign", "waitinglist/auto_assign",
"waitinglist/action", "waitinglist/action",
@@ -380,6 +381,7 @@ event_permission_urls = [
("can_view_orders", "checkins/", 200, HTTP_GET), ("can_view_orders", "checkins/", 200, HTTP_GET),
("can_view_orders", "checkinlists/", 200, HTTP_GET), ("can_view_orders", "checkinlists/", 200, HTTP_GET),
("can_view_orders", "checkinlists/1/", 404, HTTP_GET), ("can_view_orders", "checkinlists/1/", 404, HTTP_GET),
("can_change_orders", "checkinlists/1/bulk_action", 404, HTTP_POST),
("can_change_event_settings", "checkinlists/add", 200, HTTP_GET), ("can_change_event_settings", "checkinlists/add", 200, HTTP_GET),
("can_change_event_settings", "checkinlists/1/change", 404, HTTP_GET), ("can_change_event_settings", "checkinlists/1/change", 404, HTTP_GET),
("can_change_event_settings", "checkinlists/1/delete", 404, HTTP_GET), ("can_change_event_settings", "checkinlists/1/delete", 404, HTTP_GET),