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 %} + +

+
+ {% csrf_token %} + {% bootstrap_form_errors form %} + +
+ {% trans "General" %} +
+ +
+ {% bootstrap_field form.all_events layout="control" %} + {% bootstrap_field form.limit_events layout="control" %} +
+
+
+

 

+
+ {% trans "Advanced settings" %} + {% bootstrap_field form.security_profile layout="bulkedit" %} + {% bootstrap_field form.gate layout="bulkedit" %} +
+
+ +
+
+{% 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" %}

-
- - - - - - - - - - - - - - - - - {% for d in devices %} - - - - - - - - + + {% csrf_token %} + +
+
{% trans "Device ID" %} - - {% trans "Name" %} - - {% trans "Hardware model" %}{% trans "Software" %}{% trans "Setup date" %} - - {% trans "Events" %}
- {{ 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" %} - - -
+ + + + + + + + + + - {% endfor %} - -
+ + {% trans "Device ID" %} + + + {% trans "Name" %} + + + {% trans "Hardware model" %}{% trans "Software" %}{% trans "Setup date" %} + + + {% trans "Events" %}
-
+ {% if page_obj.paginator.num_pages > 1 %} + + + + + + + + + {% endif %} + + + {% 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 %} + + {% endif %} + + + {% if not d.initialized %} + + {% trans "Connect" %} + {% elif d.api_token %} + + {% trans "Revoke access" %} + {% endif %} + + + {% trans "Logs" %} + + + + + {% endfor %} + + + +
+ +
+ {% 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',