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

View File

@ -193,10 +193,6 @@ table.component-list td.subtable td {
padding-bottom: 6px; padding-bottom: 6px;
padding-top: 6px; padding-top: 6px;
} }
table.interface-ips th {
font-size: 80%;
font-weight: normal;
}
/* Reports */ /* Reports */
table.reports td.method { 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> onerror="window.location='{% url 'media_failure' %}?filename=js/forms.js'"></script>
<script type="text/javascript"> <script type="text/javascript">
var netbox_api_path = "/{{ settings.BASE_PATH }}api/"; var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
var netbox_csrf_token = "{{ csrf_token }}";
var loading = $(".loading"); var loading = $(".loading");
$(document).ajaxStart(function() { $(document).ajaxStart(function() {
loading.show(); loading.show();

View File

@ -485,7 +485,12 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Interfaces</strong> <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" /> <input class="form-control interface-filter" type="text" placeholder="Filter" title="Filter text (regular expressions supported)" style="height: 23px" />
</div> </div>
</div> </div>
@ -527,6 +532,11 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Front Ports</strong> <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> </div>
{% include 'responsive_table.html' with table=frontport_table %} {% include 'responsive_table.html' with table=frontport_table %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
@ -564,6 +574,11 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Rear Ports</strong> <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> </div>
{% include 'responsive_table.html' with table=rearport_table %} {% include 'responsive_table.html' with table=rearport_table %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
@ -601,6 +616,11 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Console Ports</strong> <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> </div>
{% include 'responsive_table.html' with table=consoleport_table %} {% include 'responsive_table.html' with table=consoleport_table %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
@ -638,6 +658,11 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Console Server Ports</strong> <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> </div>
{% include 'responsive_table.html' with table=consoleserverport_table %} {% include 'responsive_table.html' with table=consoleserverport_table %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
@ -675,6 +700,11 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Power Ports</strong> <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> </div>
{% include 'responsive_table.html' with table=powerport_table %} {% include 'responsive_table.html' with table=powerport_table %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
@ -711,6 +741,11 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Power Outlets</strong> <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> </div>
{% include 'responsive_table.html' with table=poweroutlet_table %} {% include 'responsive_table.html' with table=poweroutlet_table %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
@ -748,6 +783,11 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Device Bays</strong> <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> </div>
{% include 'responsive_table.html' with table=devicebay_table %} {% include 'responsive_table.html' with table=devicebay_table %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
@ -779,6 +819,11 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Inventory Items</strong> <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> </div>
{% include 'responsive_table.html' with table=inventoryitem_table %} {% include 'responsive_table.html' with table=inventoryitem_table %}
<div class="panel-footer noprint"> <div class="panel-footer noprint">
@ -811,6 +856,15 @@
</div> </div>
</div> </div>
{% include 'secrets/inc/private_key_modal.html' %} {% 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 %} {% endblock %}
{% block javascript %} {% block javascript %}
@ -864,4 +918,5 @@ $(".cable-toggle").click(function() {
</script> </script>
<script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></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/secrets.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/tableconfig.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %} {% endblock %}

View File

@ -1,12 +1,13 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %} {% load helpers %}
{% load static %}
{% block content %} {% block content %}
<div class="pull-right noprint"> <div class="pull-right noprint">
{% block buttons %}{% endblock %} {% block buttons %}{% endblock %}
{% if request.user.is_authenticated and table_config_form %} {% 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 %} {% endif %}
{% if permissions.add and 'add' in action_buttons %} {% if permissions.add and 'add' in action_buttons %}
{% add_button content_type.model_class|validated_viewname:"add" %} {% add_button content_type.model_class|validated_viewname:"add" %}
@ -71,9 +72,6 @@
{% endwith %} {% endwith %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div> <div class="clearfix"></div>
{% if table_config_form %}
{% include 'inc/table_config_form.html' %}
{% endif %}
</div> </div>
{% if filter_form %} {% if filter_form %}
<div class="col-md-3 noprint"> <div class="col-md-3 noprint">
@ -82,4 +80,9 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% table_config_form table table_name="ObjectTable" %}
{% endblock %}
{% block javascript %}
<script src="{% static 'js/tableconfig.js' %}"></script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@ -1,11 +1,15 @@
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.db.models import Count 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.routers import APIRootView
from rest_framework.viewsets import ViewSet
from netbox.api.views import ModelViewSet from netbox.api.views import ModelViewSet
from users import filters from users import filters
from users.models import ObjectPermission from users.models import ObjectPermission, UserConfig
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import deepmerge
from . import serializers from . import serializers
@ -41,3 +45,36 @@ class ObjectPermissionViewSet(ModelViewSet):
queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users') queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users')
serializer_class = serializers.ObjectPermissionSerializer serializer_class = serializers.ObjectPermissionSerializer
filterset_class = filters.ObjectPermissionFilterSet 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.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from rest_framework import status
from users.models import ObjectPermission 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): class AppTest(APITestCase):
@ -132,3 +131,56 @@ class ObjectPermissionTest(APIViewTestCases.APIViewTestCase):
'constraints': {'name': 'TEST6'}, '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( columns = forms.MultipleChoiceField(
choices=[], choices=[],
required=False,
widget=forms.SelectMultiple( widget=forms.SelectMultiple(
attrs={'size': 10} attrs={'size': 10}
), ),
@ -168,8 +169,14 @@ class TableConfigForm(BootstrapMixin, forms.Form):
) )
def __init__(self, table, *args, **kwargs): def __init__(self, table, *args, **kwargs):
self.table = table
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Initialize columns field based on table attributes # Initialize columns field based on table attributes
self.fields['columns'].choices = table.configurable_columns self.fields['columns'].choices = table.configurable_columns
self.fields['columns'].initial = table.visible_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 import django_tables2 as tables
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField from django.db.models.fields.related import RelatedField
@ -11,10 +12,11 @@ class BaseTable(tables.Table):
""" """
Default table for object lists Default table for object lists
:param add_prefetch: By default, modify the queryset passed to the table upon initialization to automatically :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
prefetch related data. Set this to False if it's necessary to avoid modifying the queryset (e.g. to
accommodate PrefixQuerySet.annotate_depth()).
""" """
# 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 add_prefetch = True
class Meta: class Meta:
@ -22,7 +24,7 @@ class BaseTable(tables.Table):
'class': 'table table-hover table-headings', 'class': 'table table-hover table-headings',
} }
def __init__(self, *args, columns=None, **kwargs): def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Set default empty_text if none was provided # Set default empty_text if none was provided
@ -36,25 +38,27 @@ class BaseTable(tables.Table):
if column.name not in default_columns: if column.name not in default_columns:
self.columns.hide(column.name) self.columns.hide(column.name)
# Apply custom column ordering # Apply custom column ordering for user
if columns is not None: if user is not None and not isinstance(user, AnonymousUser):
pk = self.base_columns.pop('pk', None) columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
actions = self.base_columns.pop('actions', None) if columns:
pk = self.base_columns.pop('pk', None)
actions = self.base_columns.pop('actions', None)
for name, column in self.base_columns.items(): for name, column in self.base_columns.items():
if name in columns: if name in columns:
self.columns.show(name) self.columns.show(name)
else: else:
self.columns.hide(name) self.columns.hide(name)
self.sequence = [c for c in columns if c in self.base_columns] self.sequence = [c for c in columns if c in self.base_columns]
# Always include PK and actions column, if defined on the table # Always include PK and actions column, if defined on the table
if pk: if pk:
self.base_columns['pk'] = pk self.base_columns['pk'] = pk
self.sequence.insert(0, 'pk') self.sequence.insert(0, 'pk')
if actions: if actions:
self.base_columns['actions'] = actions self.base_columns['actions'] = actions
self.sequence.append('actions') self.sequence.append('actions')
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched # Dynamically update the table's QuerySet to ensure related fields are pre-fetched
if self.add_prefetch and isinstance(self.data, TableQuerysetData): 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 django.utils.safestring import mark_safe
from markdown import markdown from markdown import markdown
from utilities.forms import TableConfigForm
from utilities.utils import foreground_color from utilities.utils import foreground_color
register = template.Library() register = template.Library()
@ -261,3 +262,11 @@ def badge(value, show_empty=False):
'value': value, 'value': value,
'show_empty': show_empty, '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) perm_name = get_permission_for_model(model, action)
permissions[action] = request.user.has_perm(perm_name) permissions[action] = request.user.has_perm(perm_name)
# Construct the table based on the user's permissions # Construct the objects table
if request.user.is_authenticated: table = self.table(self.queryset, user=request.user)
columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
else:
columns = None
table = self.table(self.queryset, columns=columns)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk') table.columns.show('pk')
@ -317,23 +313,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
return render(request, self.template_name, context) 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): def extra_context(self):
return {} return {}