Allow to bulk-edit devices (#2583)

Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2022-04-12 08:54:45 +02:00
committed by GitHub
parent 22920a7318
commit a755bfd22c
8 changed files with 415 additions and 112 deletions

View File

@@ -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),

View File

@@ -0,0 +1,46 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<h1>
{% trans "Change multiple devices" %}
<small>
{% blocktrans trimmed with number=devices.count %}
{{ number }} selected
{% endblocktrans %}
</small>
</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="hidden">
{% for d in devices %}
<input type="hidden" name="device" value="{{ d.pk }}">
{% endfor %}
</div>
<fieldset>
<legend>{% trans "General" %}</legend>
<div class="bulk-edit-field-group">
<label class="field-toggle">
<input type="checkbox" name="_bulk" value="{{ form.prefix }}__events" {% if form.prefix|add:"__events" in bulk_selected %}checked{% endif %}>
{% trans "change" context "form_bulk" %}
</label>
<div class="field-content">
{% bootstrap_field form.all_events layout="control" %}
{% bootstrap_field form.limit_events layout="control" %}
</div>
</div>
</fieldset>
<p>&nbsp;</p>
<fieldset>
<legend>{% trans "Advanced settings" %}</legend>
{% bootstrap_field form.security_profile layout="bulkedit" %}
{% bootstrap_field form.gate layout="bulkedit" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -21,7 +21,7 @@
</p>
<a href="{% url "control:organizer.device.add" organizer=request.organizer.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a>
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a>
</div>
{% else %}
<div class="panel panel-default">
@@ -53,101 +53,139 @@
</div>
<p>
<a href="{% url "control:organizer.device.add" organizer=request.organizer.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a>
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a>
</p>
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Device ID" %}
<a href="?{% url_replace request 'ordering' '-device_id' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'device_id' %}"><i class="fa fa-caret-up"></i></a></th>
</th>
<th>{% trans "Name" %}
<a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a></th>
</th>
<th>{% trans "Hardware model" %}</th>
<th>{% trans "Software" %}</th>
<th>{% trans "Setup date" %}
<a href="?{% url_replace request 'ordering' '-initialized' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'initialized' %}"><i class="fa fa-caret-up"></i></a></th>
</th>
<th>{% trans "Events" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr {% if d.revoked %}class="text-muted"{% endif %}>
<td>
{{ d.device_id }}
</td>
<td>
{% if d.revoked %}<del>{% endif %}
{{ d.name }}
{% if d.revoked %}</del>{% endif %}
{% if d.gate %}
<br>
<small class="text-muted">{{ d.gate.name }}</small>
{% endif %}
<br>
<small class="text-muted">{{ d.unique_serial }}</small>
</td>
<td>
{{ d.hardware_brand|default_if_none:"" }} {{ d.hardware_model|default_if_none:"" }}
</td>
<td>
{{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }}
</td>
<td>
{% if d.initialized %}
{{ d.initialized|date:"SHORT_DATETIME_FORMAT" }}
{% else %}
<em>{% trans "Not yet initialized" %}</em>
{% endif %}
{% if d.revoked %}
<span class="label label-danger">{% trans "Revoked" %}</span>
{% endif %}
</td>
<td>
{% if d.all_events %}
{% trans "All" %}
{% else %}
<ul>
{% for e in d.limit_events.all %}
<li>
<a href="{% url "control:event.index" organizer=request.organizer.slug event=e.slug %}">
{{ e }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</td>
<td class="text-right flip">
{% if not d.initialized %}
<a href="{% url "control:organizer.device.connect" organizer=request.organizer.slug device=d.id %}"
class="btn btn-primary btn-sm"><i class="fa fa-link"></i>
{% trans "Connect" %}</a>
{% elif d.api_token %}
<a href="{% url "control:organizer.device.revoke" organizer=request.organizer.slug device=d.id %}"
class="btn btn-default btn-sm">
{% trans "Revoke access" %}</a>
{% endif %}
<a href="{% url "control:organizer.device.logs" organizer=request.organizer.slug device=d.id %}"
class="btn btn-default btn-sm">
<span class="fa fa-list-alt"></span>
{% trans "Logs" %}
</a>
<a href="{% url "control:organizer.device.edit" organizer=request.organizer.slug device=d.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
</td>
<form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post">
{% csrf_token %}
<div class="hidden">
{{ filter_form.as_p }}
</div>
<div class="table-responsive">
<table class="table table-condensed table-hover table-quotas">
<thead>
<tr>
<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 "Device ID" %}
<a href="?{% url_replace request 'ordering' '-device_id' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'device_id' %}"><i
class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Name" %}
<a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Hardware model" %}</th>
<th>{% trans "Software" %}</th>
<th>{% trans "Setup date" %}
<a href="?{% url_replace request 'ordering' '-initialized' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'initialized' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Events" %}</th>
<th></th>
</tr>
{% endfor %}
</tbody>
</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="7">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for d in devices %}
<tr {% if d.revoked %}class="text-muted"{% endif %}>
<td>
<label aria-label="{% trans "select row for batch-operation" %}"
class="batch-select-label"><input type="checkbox" name="device"
class="batch-select-checkbox"
value="{{ d.pk }}"/></label>
</td>
<td>
{{ d.device_id }}
</td>
<td>
{% if d.revoked %}
<del>{% endif %}
{{ d.name }}
{% if d.revoked %}</del>{% endif %}
{% if d.gate %}
<br>
<small class="text-muted">{{ d.gate.name }}</small>
{% endif %}
<br>
<small class="text-muted">{{ d.unique_serial }}</small>
</td>
<td>
{{ d.hardware_brand|default_if_none:"" }} {{ d.hardware_model|default_if_none:"" }}
</td>
<td>
{{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }}
</td>
<td>
{% if d.initialized %}
{{ d.initialized|date:"SHORT_DATETIME_FORMAT" }}
{% else %}
<em>{% trans "Not yet initialized" %}</em>
{% endif %}
{% if d.revoked %}
<span class="label label-danger">{% trans "Revoked" %}</span>
{% endif %}
</td>
<td>
{% if d.all_events %}
{% trans "All" %}
{% else %}
<ul>
{% for e in d.limit_events.all %}
<li>
<a href="{% url "control:event.index" organizer=request.organizer.slug event=e.slug %}">
{{ e }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</td>
<td class="text-right flip">
{% if not d.initialized %}
<a href="{% url "control:organizer.device.connect" organizer=request.organizer.slug device=d.id %}"
class="btn btn-primary btn-sm"><i class="fa fa-link"></i>
{% trans "Connect" %}</a>
{% elif d.api_token %}
<a href="{% url "control:organizer.device.revoke" organizer=request.organizer.slug device=d.id %}"
class="btn btn-default btn-sm">
{% trans "Revoke access" %}</a>
{% endif %}
<a href="{% url "control:organizer.device.logs" organizer=request.organizer.slug device=d.id %}"
class="btn btn-default btn-sm">
<span class="fa fa-list-alt"></span>
{% trans "Logs" %}
</a>
<a href="{% url "control:organizer.device.edit" organizer=request.organizer.slug device=d.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="batch-select-actions">
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit">
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -188,7 +188,7 @@
<button type="submit" class="btn btn-danger btn-save" name="action" value="delete">
<i class="fa fa-trash"></i>{% trans "Delete selected" %}
</button>
<button type="submit" class="btn btn-primary btn-save" name="action" value="disable"
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"
formaction="{% url "control:event.subevents.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button>

View File

@@ -164,6 +164,8 @@ urlpatterns = [
re_path(r'^organizer/(?P<organizer>[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/add$', organizer.DeviceCreateView.as_view(),
name='organizer.device.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/bulk_edit$', organizer.DeviceBulkUpdateView.as_view(),
name='organizer.device.bulk_edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/edit$', organizer.DeviceUpdateView.as_view(),
name='organizer.device.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/connect$', organizer.DeviceConnectView.as_view(),

View File

@@ -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'

View File

@@ -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]

View File

@@ -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',