Merge pull request #5288 from netbox-community/5252-table-config

Closes #5252: Introduce an API endpoint for writing user preferences
This commit is contained in:
Jeremy Stretch 2020-10-29 16:26:45 -04:00 committed by GitHub
commit 6edd65c4ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 299 additions and 71 deletions

View File

@ -1019,7 +1019,11 @@ class DeviceView(ObjectView):
consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
'cable', '_path__destination',
)
consoleport_table = tables.DeviceConsolePortTable(consoleports, orderable=False)
consoleport_table = tables.DeviceConsolePortTable(
data=consoleports,
user=request.user,
orderable=False
)
if request.user.has_perm('dcim.change_consoleport') or request.user.has_perm('dcim.delete_consoleport'):
consoleport_table.columns.show('pk')
@ -1029,7 +1033,11 @@ class DeviceView(ObjectView):
).prefetch_related(
'cable', '_path__destination',
)
consoleserverport_table = tables.DeviceConsoleServerPortTable(consoleserverports, orderable=False)
consoleserverport_table = tables.DeviceConsoleServerPortTable(
data=consoleserverports,
user=request.user,
orderable=False
)
if request.user.has_perm('dcim.change_consoleserverport') or \
request.user.has_perm('dcim.delete_consoleserverport'):
consoleserverport_table.columns.show('pk')
@ -1038,7 +1046,11 @@ class DeviceView(ObjectView):
powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
'cable', '_path__destination',
)
powerport_table = tables.DevicePowerPortTable(powerports, orderable=False)
powerport_table = tables.DevicePowerPortTable(
data=powerports,
user=request.user,
orderable=False
)
if request.user.has_perm('dcim.change_powerport') or request.user.has_perm('dcim.delete_powerport'):
powerport_table.columns.show('pk')
@ -1046,7 +1058,11 @@ class DeviceView(ObjectView):
poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
'cable', 'power_port', '_path__destination',
)
poweroutlet_table = tables.DevicePowerOutletTable(poweroutlets, orderable=False)
poweroutlet_table = tables.DevicePowerOutletTable(
data=poweroutlets,
user=request.user,
orderable=False
)
if request.user.has_perm('dcim.change_poweroutlet') or request.user.has_perm('dcim.delete_poweroutlet'):
poweroutlet_table.columns.show('pk')
@ -1056,7 +1072,11 @@ class DeviceView(ObjectView):
Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
'lag', 'cable', '_path__destination', 'tags',
)
interface_table = tables.DeviceInterfaceTable(interfaces, orderable=False)
interface_table = tables.DeviceInterfaceTable(
data=interfaces,
user=request.user,
orderable=False
)
if request.user.has_perm('dcim.change_interface') or request.user.has_perm('dcim.delete_interface'):
interface_table.columns.show('pk')
@ -1064,13 +1084,21 @@ class DeviceView(ObjectView):
frontports = FrontPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
'rear_port', 'cable',
)
frontport_table = tables.DeviceFrontPortTable(frontports, orderable=False)
frontport_table = tables.DeviceFrontPortTable(
data=frontports,
user=request.user,
orderable=False
)
if request.user.has_perm('dcim.change_frontport') or request.user.has_perm('dcim.delete_frontport'):
frontport_table.columns.show('pk')
# Rear ports
rearports = RearPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related('cable')
rearport_table = tables.DeviceRearPortTable(rearports, orderable=False)
rearport_table = tables.DeviceRearPortTable(
data=rearports,
user=request.user,
orderable=False
)
if request.user.has_perm('dcim.change_rearport') or request.user.has_perm('dcim.delete_rearport'):
rearport_table.columns.show('pk')
@ -1078,7 +1106,11 @@ class DeviceView(ObjectView):
devicebays = DeviceBay.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
'installed_device__device_type__manufacturer',
)
devicebay_table = tables.DeviceDeviceBayTable(devicebays, orderable=False)
devicebay_table = tables.DeviceDeviceBayTable(
data=devicebays,
user=request.user,
orderable=False
)
if request.user.has_perm('dcim.change_devicebay') or request.user.has_perm('dcim.delete_devicebay'):
devicebay_table.columns.show('pk')
@ -1086,7 +1118,11 @@ class DeviceView(ObjectView):
inventoryitems = InventoryItem.objects.restrict(request.user, 'view').filter(
device=device
).prefetch_related('manufacturer')
inventoryitem_table = tables.DeviceInventoryItemTable(inventoryitems, orderable=False)
inventoryitem_table = tables.DeviceInventoryItemTable(
data=inventoryitems,
user=request.user,
orderable=False
)
if request.user.has_perm('dcim.change_inventoryitem') or request.user.has_perm('dcim.delete_inventoryitem'):
devicebay_table.columns.show('pk')

View File

@ -193,10 +193,6 @@ table.component-list td.subtable td {
padding-bottom: 6px;
padding-top: 6px;
}
table.interface-ips th {
font-size: 80%;
font-weight: normal;
}
/* Reports */
table.reports td.method {

View File

@ -0,0 +1,47 @@
$(document).ready(function() {
$('form.userconfigform input.reset').click(function(event) {
// Deselect all columns when the reset button is clicked
$('select[name="columns"]').val([]);
});
$('form.userconfigform').submit(function(event) {
event.preventDefault();
// Derive an array from the dotted path to the config root
let path = this.getAttribute('data-config-root').split('.');
let data = {};
let pointer = data;
// Construct a nested JSON object from the path
let node;
for (node of path) {
pointer[node] = {};
pointer = pointer[node];
}
// Assign the form data to the child node
let field;
$.each($(this).find('[id^="id_"]:input'), function(index, value) {
field = $(value);
pointer[field.attr("name")] = field.val();
});
// Make the REST API request
$.ajax({
url: netbox_api_path + 'users/config/',
async: true,
contentType: 'application/json',
dataType: 'json',
type: 'PATCH',
beforeSend: function(xhr, settings) {
xhr.setRequestHeader("X-CSRFToken", netbox_csrf_token);
},
data: JSON.stringify(data),
}).done(function () {
// Reload the page
window.location.reload(true);
}).fail(function (xhr, status, error) {
alert("Failed to update user config (" + status + "): " + error);
});
});
});

View File

@ -96,6 +96,7 @@
onerror="window.location='{% url 'media_failure' %}?filename=js/forms.js'"></script>
<script type="text/javascript">
var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
var netbox_csrf_token = "{{ csrf_token }}";
var loading = $(".loading");
$(document).ajaxStart(function() {
loading.show();

View File

@ -485,7 +485,12 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="col-md-2 pull-right noprint">
<div class="pull-right noprint">
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" data-target="#DeviceInterfaceTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
</div>
<div class="pull-right col-md-2 noprint">
<input class="form-control interface-filter" type="text" placeholder="Filter" title="Filter text (regular expressions supported)" style="height: 23px" />
</div>
</div>
@ -527,6 +532,11 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Front Ports</strong>
<div class="pull-right noprint">
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" data-target="#DeviceFrontPortTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
</div>
</div>
{% include 'responsive_table.html' with table=frontport_table %}
<div class="panel-footer noprint">
@ -564,6 +574,11 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Rear Ports</strong>
<div class="pull-right noprint">
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" data-target="#DeviceRearPortTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
</div>
</div>
{% include 'responsive_table.html' with table=rearport_table %}
<div class="panel-footer noprint">
@ -601,6 +616,11 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Ports</strong>
<div class="pull-right noprint">
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" data-target="#DeviceConsolePortTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
</div>
</div>
{% include 'responsive_table.html' with table=consoleport_table %}
<div class="panel-footer noprint">
@ -638,6 +658,11 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Server Ports</strong>
<div class="pull-right noprint">
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" data-target="#DeviceConsoleServerPortTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
</div>
</div>
{% include 'responsive_table.html' with table=consoleserverport_table %}
<div class="panel-footer noprint">
@ -675,6 +700,11 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Ports</strong>
<div class="pull-right noprint">
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" data-target="#DevicePowerPortTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
</div>
</div>
{% include 'responsive_table.html' with table=powerport_table %}
<div class="panel-footer noprint">
@ -711,6 +741,11 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Outlets</strong>
<div class="pull-right noprint">
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" data-target="#DevicePowerOutletTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
</div>
</div>
{% include 'responsive_table.html' with table=poweroutlet_table %}
<div class="panel-footer noprint">
@ -748,6 +783,11 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Device Bays</strong>
<div class="pull-right noprint">
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" data-target="#DeviceDeviceBayTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
</div>
</div>
{% include 'responsive_table.html' with table=devicebay_table %}
<div class="panel-footer noprint">
@ -779,6 +819,11 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Inventory Items</strong>
<div class="pull-right noprint">
{% if request.user.is_authenticated %}
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" data-target="#DeviceInventoryItemTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
</div>
</div>
{% include 'responsive_table.html' with table=inventoryitem_table %}
<div class="panel-footer noprint">
@ -811,6 +856,15 @@
</div>
</div>
{% include 'secrets/inc/private_key_modal.html' %}
{% table_config_form interface_table %}
{% table_config_form frontport_table %}
{% table_config_form rearport_table %}
{% table_config_form consoleport_table %}
{% table_config_form consoleserverport_table %}
{% table_config_form powerport_table %}
{% table_config_form poweroutlet_table %}
{% table_config_form devicebay_table %}
{% table_config_form inventoryitem_table %}
{% endblock %}
{% block javascript %}
@ -864,4 +918,5 @@ $(".cable-toggle").click(function() {
</script>
<script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/tableconfig.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@ -1,12 +1,13 @@
{% extends 'base.html' %}
{% load buttons %}
{% load helpers %}
{% load static %}
{% block content %}
<div class="pull-right noprint">
{% block buttons %}{% endblock %}
{% if request.user.is_authenticated and table_config_form %}
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#tableconfig" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#ObjectTable_config" title="Configure table"><i class="fa fa-cog"></i> Configure</button>
{% endif %}
{% if permissions.add and 'add' in action_buttons %}
{% add_button content_type.model_class|validated_viewname:"add" %}
@ -71,9 +72,6 @@
{% endwith %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div>
{% if table_config_form %}
{% include 'inc/table_config_form.html' %}
{% endif %}
</div>
{% if filter_form %}
<div class="col-md-3 noprint">
@ -82,4 +80,9 @@
</div>
{% endif %}
</div>
{% table_config_form table table_name="ObjectTable" %}
{% endblock %}
{% block javascript %}
<script src="{% static 'js/tableconfig.js' %}"></script>
{% endblock %}

View File

@ -1,5 +1,5 @@
{% load form_helpers %}
<div class="modal fade" tabindex="-1" id="tableconfig">
<div class="modal fade" tabindex="-1" id="{{ table_name }}_config">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@ -7,8 +7,7 @@
<h4 class="modal-title">Table Configuration</h4>
</div>
<div class="modal-body">
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<form class="form-horizontal userconfigform" data-config-root="tables.{{ table_config_form.table_name }}">
{% render_form table_config_form %}
<div class="row">
<div class="col-md-9 col-md-offset-3">
@ -18,8 +17,8 @@
</div>
</div>
<div class="text-right">
<input type="submit" class="btn btn-primary" name="set" value="Save" />
<input type="submit" class="btn btn-danger" name="clear" value="Reset" />
<input type="submit" class="btn btn-primary" value="Save" />
<input type="submit" class="btn btn-danger reset" value="Reset" />
</div>
</form>
</div>

View File

@ -12,5 +12,8 @@ router.register('groups', views.GroupViewSet)
# Permissions
router.register('permissions', views.ObjectPermissionViewSet)
# User preferences
router.register('config', views.UserConfigViewSet, basename='userconfig')
app_name = 'users-api'
urlpatterns = router.urls

View File

@ -1,11 +1,15 @@
from django.contrib.auth.models import Group, User
from django.db.models import Count
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import ViewSet
from netbox.api.views import ModelViewSet
from users import filters
from users.models import ObjectPermission
from users.models import ObjectPermission, UserConfig
from utilities.querysets import RestrictedQuerySet
from utilities.utils import deepmerge
from . import serializers
@ -41,3 +45,36 @@ class ObjectPermissionViewSet(ModelViewSet):
queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users')
serializer_class = serializers.ObjectPermissionSerializer
filterset_class = filters.ObjectPermissionFilterSet
#
# User preferences
#
class UserConfigViewSet(ViewSet):
"""
An API endpoint via which a user can update his or her own UserConfig data (but no one else's).
"""
permission_classes = [IsAuthenticated]
def get_queryset(self):
return UserConfig.objects.filter(user=self.request.user)
def list(self, request):
"""
Return the UserConfig for the currently authenticated User.
"""
userconfig = self.get_queryset().first()
return Response(userconfig.data)
def patch(self, request):
"""
Update the UserConfig for the currently authenticated User.
"""
# TODO: How can we validate this data?
userconfig = self.get_queryset().first()
userconfig.data = deepmerge(userconfig.data, request.data)
userconfig.save()
return Response(userconfig.data)

View File

@ -1,11 +1,10 @@
from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from users.models import ObjectPermission
from utilities.testing import APIViewTestCases, APITestCase, disable_warnings
from utilities.testing import APIViewTestCases, APITestCase
from utilities.utils import deepmerge
class AppTest(APITestCase):
@ -132,3 +131,56 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
'constraints': {'name': 'TEST6'},
},
]
class UserConfigTest(APITestCase):
def test_get(self):
"""
Retrieve user configuration via GET request.
"""
userconfig = self.user.config
url = reverse('users-api:userconfig-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data, {})
data = {
"a": 123,
"b": 456,
"c": 789,
}
userconfig.data = data
userconfig.save()
response = self.client.get(url, **self.header)
self.assertEqual(response.data, data)
def test_patch(self):
"""
Set user config via PATCH requests.
"""
userconfig = self.user.config
url = reverse('users-api:userconfig-list')
data = {
"a": {
"a1": "X",
"a2": "Y",
},
"b": {
"b1": "Z",
}
}
response = self.client.patch(url, data=data, format='json', **self.header)
self.assertDictEqual(response.data, data)
userconfig.refresh_from_db()
self.assertDictEqual(userconfig.data, data)
update_data = {
"c": 123
}
response = self.client.patch(url, data=update_data, format='json', **self.header)
new_data = deepmerge(data, update_data)
self.assertDictEqual(response.data, new_data)
userconfig.refresh_from_db()
self.assertDictEqual(userconfig.data, new_data)

View File

@ -161,6 +161,7 @@ class TableConfigForm(BootstrapMixin, forms.Form):
"""
columns = forms.MultipleChoiceField(
choices=[],
required=False,
widget=forms.SelectMultiple(
attrs={'size': 10}
),
@ -168,8 +169,14 @@ class TableConfigForm(BootstrapMixin, forms.Form):
)
def __init__(self, table, *args, **kwargs):
self.table = table
super().__init__(*args, **kwargs)
# Initialize columns field based on table attributes
self.fields['columns'].choices = table.configurable_columns
self.fields['columns'].initial = table.visible_columns
@property
def table_name(self):
return self.table.__class__.__name__

View File

@ -1,4 +1,5 @@
import django_tables2 as tables
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField
@ -11,10 +12,11 @@ class BaseTable(tables.Table):
"""
Default table for object lists
:param add_prefetch: By default, modify the queryset passed to the table upon initialization to automatically
prefetch related data. Set this to False if it's necessary to avoid modifying the queryset (e.g. to
accommodate PrefixQuerySet.annotate_depth()).
:param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
"""
# By default, modify the queryset passed to the table upon initialization to automatically prefetch related
# data. Set this to False if it's necessary to avoid modifying the queryset (e.g. to accommodate
# PrefixQuerySet.annotate_depth()).
add_prefetch = True
class Meta:
@ -22,7 +24,7 @@ class BaseTable(tables.Table):
'class': 'table table-hover table-headings',
}
def __init__(self, *args, columns=None, **kwargs):
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
# Set default empty_text if none was provided
@ -36,25 +38,27 @@ class BaseTable(tables.Table):
if column.name not in default_columns:
self.columns.hide(column.name)
# Apply custom column ordering
if columns is not None:
pk = self.base_columns.pop('pk', None)
actions = self.base_columns.pop('actions', None)
# Apply custom column ordering for user
if user is not None and not isinstance(user, AnonymousUser):
columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
if columns:
pk = self.base_columns.pop('pk', None)
actions = self.base_columns.pop('actions', None)
for name, column in self.base_columns.items():
if name in columns:
self.columns.show(name)
else:
self.columns.hide(name)
self.sequence = [c for c in columns if c in self.base_columns]
for name, column in self.base_columns.items():
if name in columns:
self.columns.show(name)
else:
self.columns.hide(name)
self.sequence = [c for c in columns if c in self.base_columns]
# Always include PK and actions column, if defined on the table
if pk:
self.base_columns['pk'] = pk
self.sequence.insert(0, 'pk')
if actions:
self.base_columns['actions'] = actions
self.sequence.append('actions')
# Always include PK and actions column, if defined on the table
if pk:
self.base_columns['pk'] = pk
self.sequence.insert(0, 'pk')
if actions:
self.base_columns['actions'] = actions
self.sequence.append('actions')
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
if self.add_prefetch and isinstance(self.data, TableQuerysetData):

View File

@ -10,6 +10,7 @@ from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from markdown import markdown
from utilities.forms import TableConfigForm
from utilities.utils import foreground_color
register = template.Library()
@ -261,3 +262,11 @@ def badge(value, show_empty=False):
'value': value,
'show_empty': show_empty,
}
@register.inclusion_tag('utilities/templatetags/table_config_form.html')
def table_config_form(table, table_name=None):
return {
'table_name': table_name or table.__class__.__name__,
'table_config_form': TableConfigForm(table=table),
}

View File

@ -289,12 +289,8 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
perm_name = get_permission_for_model(model, action)
permissions[action] = request.user.has_perm(perm_name)
# Construct the table based on the user's permissions
if request.user.is_authenticated:
columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
else:
columns = None
table = self.table(self.queryset, columns=columns)
# Construct the objects table
table = self.table(self.queryset, user=request.user)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
@ -317,23 +313,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
return render(request, self.template_name, context)
@method_decorator(login_required)
def post(self, request):
# Update the user's table configuration
table = self.table(self.queryset)
form = TableConfigForm(table=table, data=request.POST)
preference_name = f"tables.{self.table.__name__}.columns"
if form.is_valid():
if 'set' in request.POST:
request.user.config.set(preference_name, form.cleaned_data['columns'], commit=True)
elif 'clear' in request.POST:
request.user.config.clear(preference_name, commit=True)
messages.success(request, "Your preferences have been updated.")
return redirect(request.get_full_path())
def extra_context(self):
return {}