diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py
index 28539980da..bdae0bc223 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -39,6 +39,7 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import Q
+from django.forms.utils import ErrorDict
from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
@@ -268,6 +269,69 @@ class DeviceForm(forms.ModelForm):
}
+class DeviceBulkEditForm(forms.ModelForm):
+
+ def __init__(self, *args, **kwargs):
+ organizer = kwargs.pop('organizer')
+ self.mixed_values = kwargs.pop('mixed_values')
+ self.queryset = kwargs.pop('queryset')
+ super().__init__(*args, **kwargs)
+ self.fields['limit_events'].queryset = organizer.events.all().order_by(
+ '-has_subevents', '-date_from'
+ )
+ self.fields['gate'].queryset = organizer.gates.all()
+
+ def clean(self):
+ d = super().clean()
+ if self.prefix + '__events' in self.data.getlist('_bulk') and not d['all_events'] and not d['limit_events']:
+ raise ValidationError(_('Your device will not have access to anything, please select some events.'))
+
+ return d
+
+ class Meta:
+ model = Device
+ fields = ['all_events', 'limit_events', 'security_profile', 'gate']
+ widgets = {
+ 'limit_events': forms.CheckboxSelectMultiple(attrs={
+ 'data-inverse-dependency': '#id_all_events',
+ 'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
+ }),
+ }
+ field_classes = {
+ 'limit_events': SafeEventMultipleChoiceField
+ }
+
+ def save(self, commit=True):
+ objs = list(self.queryset)
+ fields = set()
+
+ check_map = {
+ 'all_events': '__events',
+ 'limit_events': '__events',
+ }
+ for k in self.fields:
+ cb_val = self.prefix + check_map.get(k, k)
+ if cb_val not in self.data.getlist('_bulk'):
+ continue
+
+ fields.add(k)
+ for obj in objs:
+ if k == 'limit_events':
+ getattr(obj, k).set(self.cleaned_data[k])
+ else:
+ setattr(obj, k, self.cleaned_data[k])
+
+ if fields:
+ Device.objects.bulk_update(objs, [f for f in fields if f != 'limit_events'], 200)
+
+ def full_clean(self):
+ if len(self.data) == 0:
+ # form wasn't submitted
+ self._errors = ErrorDict()
+ return
+ super().full_clean()
+
+
class OrganizerSettingsForm(SettingsForm):
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html
new file mode 100644
index 0000000000..1c2615a248
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html
@@ -0,0 +1,46 @@
+{% extends "pretixcontrol/organizers/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block inner %}
+
+ {% trans "Change multiple devices" %}
+
+ {% blocktrans trimmed with number=devices.count %}
+ {{ number }} selected
+ {% endblocktrans %}
+
+
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/devices.html b/src/pretix/control/templates/pretixcontrol/organizers/devices.html
index 9e488f9fa2..5caa0058c4 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/devices.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/devices.html
@@ -21,7 +21,7 @@
{% trans "Connect a device" %}
+ class="btn btn-primary btn-lg"> {% trans "Connect a device" %}
{% else %}
@@ -53,101 +53,139 @@
{% trans "Connect a device" %}
+ class="btn btn-default"> {% trans "Connect a device" %}
-
-
-
-
- | {% trans "Device ID" %}
-
- |
-
- {% trans "Name" %}
-
- |
-
- {% trans "Hardware model" %} |
- {% trans "Software" %} |
- {% trans "Setup date" %}
-
- |
-
- {% trans "Events" %} |
- |
-
-
-
- {% for d in devices %}
-
- |
- {{ d.device_id }}
- |
-
- {% if d.revoked %}{% endif %}
- {{ d.name }}
- {% if d.revoked %}{% endif %}
- {% if d.gate %}
-
- {{ d.gate.name }}
- {% endif %}
-
- {{ d.unique_serial }}
- |
-
- {{ d.hardware_brand|default_if_none:"" }} {{ d.hardware_model|default_if_none:"" }}
- |
-
- {{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }}
- |
-
- {% if d.initialized %}
- {{ d.initialized|date:"SHORT_DATETIME_FORMAT" }}
- {% else %}
- {% trans "Not yet initialized" %}
- {% endif %}
- {% if d.revoked %}
- {% trans "Revoked" %}
- {% endif %}
- |
-
- {% if d.all_events %}
- {% trans "All" %}
- {% else %}
-
- {% for e in d.limit_events.all %}
- -
-
- {{ e }}
-
-
- {% endfor %}
-
- {% endif %}
- |
-
- {% if not d.initialized %}
-
- {% trans "Connect" %}
- {% elif d.api_token %}
-
- {% trans "Revoke access" %}
- {% endif %}
-
-
- {% trans "Logs" %}
-
-
- |
+
+
+
+
+
+
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/subevents/index.html b/src/pretix/control/templates/pretixcontrol/subevents/index.html
index 551469e2fc..f3a3fc3513 100644
--- a/src/pretix/control/templates/pretixcontrol/subevents/index.html
+++ b/src/pretix/control/templates/pretixcontrol/subevents/index.html
@@ -188,7 +188,7 @@
-
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index ed1ac7698c..6363f68627 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -164,6 +164,8 @@ urlpatterns = [
re_path(r'^organizer/(?P[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'),
re_path(r'^organizer/(?P[^/]+)/device/add$', organizer.DeviceCreateView.as_view(),
name='organizer.device.add'),
+ re_path(r'^organizer/(?P[^/]+)/device/bulk_edit$', organizer.DeviceBulkUpdateView.as_view(),
+ name='organizer.device.bulk_edit'),
re_path(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/edit$', organizer.DeviceUpdateView.as_view(),
name='organizer.device.edit'),
re_path(r'^organizer/(?P[^/]+)/device/(?P[^/]+)/connect$', organizer.DeviceConnectView.as_view(),
diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py
index dd5e081865..a27d867b7a 100644
--- a/src/pretix/control/views/organizer.py
+++ b/src/pretix/control/views/organizer.py
@@ -42,14 +42,14 @@ from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File
-from django.db import transaction
+from django.db import connections, transaction
from django.db.models import (
Count, Exists, IntegerField, Max, Min, OuterRef, Prefetch, ProtectedError,
Q, Subquery, Sum,
)
from django.db.models.functions import Coalesce, Greatest
from django.forms import DecimalField
-from django.http import HttpResponseBadRequest, JsonResponse
+from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
@@ -89,11 +89,11 @@ from pretix.control.forms.filter import (
)
from pretix.control.forms.orders import ExporterForm
from pretix.control.forms.organizer import (
- CustomerCreateForm, CustomerUpdateForm, DeviceForm, EventMetaPropertyForm,
- GateForm, GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm,
- MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm,
- OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
- WebHookForm,
+ CustomerCreateForm, CustomerUpdateForm, DeviceBulkEditForm, DeviceForm,
+ EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm,
+ MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
+ OrganizerDeleteForm, OrganizerForm, OrganizerSettingsForm,
+ OrganizerUpdateForm, TeamForm, WebHookForm,
)
from pretix.control.logdisplay import OVERVIEW_BANLIST
from pretix.control.permissions import (
@@ -102,6 +102,7 @@ from pretix.control.permissions import (
from pretix.control.signals import nav_organizer
from pretix.control.views import PaginationMixin
from pretix.control.views.mailsetup import MailSettingsSetupView
+from pretix.helpers import GroupConcat
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -819,11 +820,17 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
})
-class DeviceListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
- model = Device
- template_name = 'pretixcontrol/organizers/devices.html'
- permission = 'can_change_organizer_settings'
- context_object_name = 'devices'
+class DeviceQueryMixin:
+
+ @cached_property
+ def request_data(self):
+ if self.request.method == "POST":
+ return self.request.POST
+ return self.request.GET
+
+ @cached_property
+ def filter_form(self):
+ return DeviceFilterForm(data=self.request.GET, request=self.request)
def get_queryset(self):
qs = self.request.organizer.devices.prefetch_related(
@@ -831,17 +838,27 @@ class DeviceListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
).order_by('revoked', '-device_id')
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
+
+ if 'device' in self.request_data and '__ALL' not in self.request_data:
+ qs = qs.filter(
+ id__in=self.request_data.getlist('device')
+ )
+
return qs
+
+class DeviceListView(DeviceQueryMixin, OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
+ model = Device
+ template_name = 'pretixcontrol/organizers/devices.html'
+ permission = 'can_change_organizer_settings'
+ context_object_name = 'devices'
+ paginate_by = 100
+
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
return ctx
- @cached_property
- def filter_form(self):
- return DeviceFilterForm(data=self.request.GET, request=self.request)
-
class DeviceCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
model = Device
@@ -935,6 +952,125 @@ class DeviceUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
return super().form_invalid(form)
+class DeviceBulkUpdateView(DeviceQueryMixin, OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, FormView):
+ template_name = 'pretixcontrol/organizers/device_bulk_edit.html'
+ permission = 'can_change_organizer_settings'
+ context_object_name = 'device'
+ form_class = DeviceBulkEditForm
+
+ def get_queryset(self):
+ return super().get_queryset().prefetch_related(None).order_by()
+
+ def get(self, request, *args, **kwargs):
+ return HttpResponse(status=405)
+
+ @cached_property
+ def is_submitted(self):
+ # Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always
+ # called with POST method, even if just to pass the selection of objects to work on, so we want to modify
+ # that behaviour
+ return '_bulk' in self.request.POST
+
+ def get_form_kwargs(self):
+ initial = {}
+ mixed_values = set()
+ qs = self.get_queryset().annotate(
+ limit_events_list=Subquery(
+ Device.limit_events.through.objects.filter(
+ device_id=OuterRef('pk')
+ ).order_by('device_id', 'event_id').values('device_id').annotate(
+ g=GroupConcat('event_id', separator=',')
+ ).values('g')
+ )
+ )
+
+ fields = {
+ 'all_events': 'all_events',
+ 'limit_events': 'limit_events_list',
+ 'security_profile': 'security_profile',
+ 'gate': 'gate',
+ }
+ for k, f in fields.items():
+ existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*')))
+ if len(existing_values) == 1:
+ if k == 'limit_events':
+ if existing_values[0][f]:
+ initial[k] = self.request.organizer.events.filter(id__in=existing_values[0][f].split(","))
+ else:
+ initial[k] = []
+ else:
+ initial[k] = existing_values[0][f]
+ elif len(existing_values) > 1:
+ mixed_values.add(k)
+ initial[k] = None
+
+ kwargs = super().get_form_kwargs()
+ kwargs['organizer'] = self.request.organizer
+ kwargs['prefix'] = 'bulkedit'
+ kwargs['initial'] = initial
+ kwargs['queryset'] = self.get_queryset()
+ kwargs['mixed_values'] = mixed_values
+ if not self.is_submitted:
+ kwargs['data'] = None
+ kwargs['files'] = None
+ return kwargs
+
+ def get_object(self, queryset=None):
+ return get_object_or_404(Device, organizer=self.request.organizer, pk=self.kwargs.get('device'))
+
+ def get_success_url(self):
+ return reverse('control:organizer.devices', kwargs={
+ 'organizer': self.request.organizer.slug,
+ })
+
+ @transaction.atomic()
+ def form_valid(self, form):
+ log_entries = []
+
+ # Main form
+ form.save()
+ data = {
+ k: (v if k != 'limit_events' else [e.id for e in v])
+ for k, v in form.cleaned_data.items()
+ if k in form.changed_data
+ }
+ data['_raw_bulk_data'] = self.request.POST.dict()
+ for obj in self.get_queryset():
+ log_entries.append(
+ obj.log_action('pretix.device.changed', data=data, user=self.request.user, save=False)
+ )
+
+ if connections['default'].features.can_return_rows_from_bulk_insert:
+ LogEntry.objects.bulk_create(log_entries, batch_size=200)
+ LogEntry.bulk_postprocess(log_entries)
+ else:
+ for le in log_entries:
+ le.save()
+ LogEntry.bulk_postprocess(log_entries)
+
+ messages.success(self.request, _('Your changes have been saved.'))
+ return super().form_valid(form)
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx['devices'] = self.get_queryset()
+ ctx['bulk_selected'] = self.request.POST.getlist("_bulk")
+ return ctx
+
+ def post(self, request, *args, **kwargs):
+ form = self.get_form()
+ is_valid = (
+ self.is_submitted and
+ form.is_valid()
+ )
+ if is_valid:
+ return self.form_valid(form)
+ else:
+ if self.is_submitted:
+ messages.error(self.request, _('We could not save your changes. See below for details.'))
+ return self.form_invalid(form)
+
+
class DeviceConnectView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
model = Device
template_name = 'pretixcontrol/organizers/device_connect.html'
diff --git a/src/tests/control/test_devices.py b/src/tests/control/test_devices.py
index 22b8e6b7b0..60e2d836e6 100644
--- a/src/tests/control/test_devices.py
+++ b/src/tests/control/test_devices.py
@@ -43,7 +43,7 @@ def event(organizer):
@pytest.fixture
def device(organizer):
- return organizer.devices.create(name='Cashdesk')
+ return organizer.devices.create(name='Cashdesk', all_events=True)
@pytest.fixture
@@ -108,3 +108,19 @@ def test_revoke_device(event, admin_user, admin_team, device, client):
client.post('/control/organizer/dummy/device/{}/revoke'.format(device.pk), {}, follow=True)
device.refresh_from_db()
assert device.revoked
+
+
+@pytest.mark.django_db
+def test_bulk_update_device(event, admin_user, admin_team, device, client):
+ client.login(email='dummy@dummy.dummy', password='dummy')
+ client.post('/control/organizer/dummy/device/bulk_edit', {
+ 'device': str(device.pk),
+ 'bulkedit-limit_events': str(event.pk),
+ '_bulk': ['bulkedit__events', 'bulkeditsecurity_profile'],
+ 'bulkedit-security_profile': 'full',
+ }, follow=True)
+ device.refresh_from_db()
+ assert device.security_profile == 'full'
+ assert not device.all_events
+ with scopes_disabled():
+ assert list(device.limit_events.all()) == [event]
diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py
index 3d59295ae2..c21d8c7f39 100644
--- a/src/tests/control/test_permissions.py
+++ b/src/tests/control/test_permissions.py
@@ -181,6 +181,7 @@ organizer_urls = [
'organizer/abc/team/add',
'organizer/abc/devices',
'organizer/abc/device/add',
+ 'organizer/abc/device/bulk_edit',
'organizer/abc/device/1/edit',
'organizer/abc/device/1/connect',
'organizer/abc/device/1/revoke',