diff --git a/src/pretix/base/views/tasks.py b/src/pretix/base/views/tasks.py index bc3206c11c..023ea779a1 100644 --- a/src/pretix/base/views/tasks.py +++ b/src/pretix/base/views/tasks.py @@ -29,12 +29,13 @@ from celery.result import AsyncResult from django.conf import settings from django.contrib import messages 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.test import RequestFactory from django.utils import timezone, translation from django.utils.timezone import get_current_timezone from django.utils.translation import get_language, gettext as _ +from django.views import View from django.views.generic import FormView from redis import ResponseError @@ -312,3 +313,94 @@ class AsyncFormView(AsyncMixin, FormView): else: return self.error(res.info) 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)) diff --git a/src/pretix/control/templates/pretixcontrol/checkin/index.html b/src/pretix/control/templates/pretixcontrol/checkin/index.html index b0a3bd62f7..8bf1c71169 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/index.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/index.html @@ -71,13 +71,20 @@

{% else %} -
+ + {% csrf_token %}
- + + {% if page_obj.paginator.num_pages > 1 %} + + + + + {% endif %} {% for e in entries %} @@ -180,13 +200,16 @@ {% if "can_change_orders" in request.eventpermset %} - {% endif %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index af75a2768b..ed43ea9cad 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -392,6 +392,7 @@ urlpatterns = [ 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/(?P\d+)/$', checkin.CheckInListShow.as_view(), name='event.orders.checkinlists.show'), + re_path(r'^checkinlists/(?P\d+)/bulk_action$', checkin.CheckInListBulkActionView.as_view(), name='event.orders.checkinlists.bulk_action'), re_path(r'^checkinlists/(?P\d+)/change$', checkin.CheckinListUpdate.as_view(), name='event.orders.checkinlists.edit'), re_path(r'^checkinlists/(?P\d+)/delete$', checkin.CheckinListDelete.as_view(), diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py index 33d163bf3e..6e141178b3 100644 --- a/src/pretix/control/views/checkin.py +++ b/src/pretix/control/views/checkin.py @@ -37,7 +37,7 @@ from django.contrib import messages from django.db import transaction from django.db.models import Exists, Max, OuterRef, Prefetch, Subquery 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.utils.functional import cached_property 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.checkin import CheckinList 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.filter import ( CheckinFilterForm, CheckinListAttendeeFilterForm, @@ -58,11 +59,13 @@ from pretix.control.views import CreateView, PaginationMixin, UpdateView from pretix.helpers.models import modelcopy -class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView): - model = Checkin - context_object_name = 'entries' - template_name = 'pretixcontrol/checkin/index.html' - permission = 'can_view_orders' +class CheckInListQueryMixin: + + @cached_property + def request_data(self): + if self.request.method == "POST": + return self.request.POST + return self.request.GET def get_queryset(self, filter=True): cqs = Checkin.objects.filter( @@ -105,16 +108,28 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView): if filter and self.filter_form.is_valid(): 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 @cached_property def filter_form(self): return CheckinListAttendeeFilterForm( - data=self.request.GET, + data=self.request_data, event=self.request.event, 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): self.list = get_object_or_404(self.request.event.checkin_lists.all(), pk=kwargs.get("list")) return super().dispatch(request, *args, **kwargs) @@ -153,18 +168,23 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView): e.last_exit_aware = e.last_exit 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( - pk__in=request.POST.getlist('checkin') - ) +class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMixin, AsyncPostView): + 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': for op in positions: 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) op.order.touch() - messages.success(request, _('The selected check-ins have been reverted.')) + return 'reverted', request.POST.get('returnquery') else: for op in positions: 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 }, user=request.user) 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, 'organizer': self.request.event.organizer.slug, 'list': self.list.pk - }) + '?' + request.GET.urlencode()) + }) + ('?' + value[1] if value[1] else '') class CheckinListList(EventPermissionRequiredMixin, PaginationMixin, ListView): diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index a27d867b7a..26a0c97bd4 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -830,7 +830,7 @@ class DeviceQueryMixin: @cached_property 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): qs = self.request.organizer.devices.prefetch_related( diff --git a/src/pretix/static/pretixbase/js/asynctask.js b/src/pretix/static/pretixbase/js/asynctask.js index 7d7f760247..b0aa75d5c8 100644 --- a/src/pretix/static/pretixbase/js/asynctask.js +++ b/src/pretix/static/pretixbase/js/asynctask.js @@ -218,14 +218,17 @@ $(function () { 'this page and try again.' )); - - console.log($(this).get(0)) - var formData = new FormData($(this).get(0)) + var formData = new FormData(this); 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( { 'type': 'POST', - 'url': $(this).attr('action'), + 'url': this.action, 'data': formData, processData: false, contentType: false, diff --git a/src/tests/control/test_checkins.py b/src/tests/control/test_checkins.py index d5a34437b1..4f3d2a7514 100644 --- a/src/tests/control/test_checkins.py +++ b/src/tests/control/test_checkins.py @@ -297,7 +297,7 @@ def test_manual_checkins(client, checkin_list_env): client.login(email='dummy@dummy.dummy', password='dummy') with scopes_disabled(): 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] }) with scopes_disabled(): @@ -312,10 +312,10 @@ def test_manual_checkins_revert(client, checkin_list_env): client.login(email='dummy@dummy.dummy', password='dummy') with scopes_disabled(): 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] }) - 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], 'revert': 'true' }) diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index b8a1b12190..05fa66c125 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -169,6 +169,7 @@ event_urls = [ "checkinlists/1/", "checkinlists/1/change", "checkinlists/1/delete", + "checkinlists/1/bulk_action", "waitinglist/", "waitinglist/auto_assign", "waitinglist/action", @@ -380,6 +381,7 @@ event_permission_urls = [ ("can_view_orders", "checkins/", 200, HTTP_GET), ("can_view_orders", "checkinlists/", 200, 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/1/change", 404, HTTP_GET), ("can_change_event_settings", "checkinlists/1/delete", 404, HTTP_GET),
+ + {% trans "Order code" %} {% trans "Item" %} @@ -100,6 +107,19 @@ {% trans "Timestamp" %}