From 3fe3151af7db860d8c65c6eff1bb7a27323e0f6c Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 18 Mar 2017 21:10:36 +0100 Subject: [PATCH 01/65] Filter on mac address on interface Extension to be able filter on mac address via API --- netbox/dcim/filters.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index eca792a12..d8a6e1307 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -405,6 +405,10 @@ class InterfaceFilter(django_filters.FilterSet): method='filter_type', label='Interface type', ) + mac_address = django_filters.CharFilter( + method='_mac_address', + label='MAC address', + ) class Meta: model = Interface @@ -420,6 +424,14 @@ class InterfaceFilter(django_filters.FilterSet): return queryset.filter(form_factor=IFACE_FF_LAG) return queryset + def _mac_address(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + try: + return queryset.filter(mac_address=value).distinct() + except AddrFormatError: + return queryset.none() class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( From f2dc287f1429b2338b44169e2c358cc55b95c93a Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 18 Mar 2017 21:21:49 +0100 Subject: [PATCH 02/65] Filter on mac address on interface --- netbox/dcim/filters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d8a6e1307..e6c2cd465 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -433,6 +433,7 @@ class InterfaceFilter(django_filters.FilterSet): except AddrFormatError: return queryset.none() + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', From f26253ec491d699844f995a05f6a9b36e481c724 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 18 Mar 2017 21:26:33 +0100 Subject: [PATCH 03/65] Filter on mac address on interface --- netbox/dcim/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e6c2cd465..af8f3a089 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -433,7 +433,7 @@ class InterfaceFilter(django_filters.FilterSet): except AddrFormatError: return queryset.none() - + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', From 007fe6a030a3d78cb57cd1419a2f8f85a687ad55 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Mar 2017 10:49:20 -0400 Subject: [PATCH 04/65] Markdown fixes --- docs/api/authentication.md | 2 +- docs/api/overview.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/authentication.md b/docs/api/authentication.md index a2a8648a3..cb6da3bd1 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -24,7 +24,7 @@ $ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/ } ``` -However, if the `[LOGIN_REQUIRED](../configuration/optional-settings/#login_required)` configuration setting has been set to `True`, all requests must be authenticated. +However, if the [`LOGIN_REQUIRED`](../configuration/optional-settings/#login_required) configuration setting has been set to `True`, all requests must be authenticated. ``` $ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/ diff --git a/docs/api/overview.md b/docs/api/overview.md index 4086d9fad..5f8e43973 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -120,7 +120,7 @@ Vary: Accept } ``` -The default page size derives from the `[PAGINATE_COUNT](../configuration/optional-settings/#paginate_count)` configuration setting, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: +The default page size derives from the [`PAGINATE_COUNT`](../configuration/optional-settings/#paginate_count) configuration setting, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: ``` http://localhost:8000/api/dcim/devices/?limit=100 From 66a6a8f33caedb678c3aab8fd2ae5ff6c24e56e4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Mar 2017 16:58:56 -0400 Subject: [PATCH 05/65] Closes #983: Include peer device names when listing circuits in device view --- netbox/templates/dcim/inc/interface.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 86c2e4090..7d7d66c2e 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -35,7 +35,13 @@ {% if peer_termination %} - {{ peer_termination.site }} via + {% if peer_termination.interface %} + {{ peer_termination.interface.device }} + ({{ peer_termination.site }}) + {% else %} + {{ peer_termination.site }} + {% endif %} + via {% endif %} {{ iface.circuit_termination.circuit }} From 32bf17c076741b0e4e615a796b6129c1e52efd83 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Mar 2017 17:29:47 -0400 Subject: [PATCH 06/65] Closes #978: Allow filtering device types by function and subdevice role --- netbox/dcim/forms.py | 17 ++++++++++++++++- netbox/dcim/tables.py | 13 ++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c79f65d53..07a28f936 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,7 +23,7 @@ from .models import ( Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, - VIRTUAL_IFACE_TYPES + SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES ) @@ -375,6 +375,21 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), to_field_name='slug' ) + is_console_server = forms.BooleanField( + required=False, label='Is a console server', widget=forms.CheckboxInput(attrs={'value': 'True'})) + is_pdu = forms.BooleanField( + required=False, label='Is a PDU', widget=forms.CheckboxInput(attrs={'value': 'True'}) + ) + is_network_device = forms.BooleanField( + required=False, label='Is a network device', widget=forms.CheckboxInput(attrs={'value': 'True'}) + ) + subdevice_role = forms.NullBooleanField( + required=False, label='Subdevice role', widget=forms.Select(choices=( + ('', '---------'), + (SUBDEVICE_ROLE_PARENT, 'Parent'), + (SUBDEVICE_ROLE_CHILD, 'Child'), + )) + ) # diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 42c6a39bf..6af772fde 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -100,6 +100,10 @@ DEVICE_PRIMARY_IP = """ {{ record.primary_ip4.address.ip|default:"" }} """ +SUBDEVICE_ROLE_TEMPLATE = """ +{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %} +""" + UTILIZATION_GRAPH = """ {% load helpers %} {% utilization_graph value %} @@ -249,11 +253,18 @@ class DeviceTypeTable(BaseTable): model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type') part_number = tables.Column(verbose_name='Part Number') is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') + is_console_server = tables.BooleanColumn(verbose_name='CS') + is_pdu = tables.BooleanColumn(verbose_name='PDU') + is_network_device = tables.BooleanColumn(verbose_name='Net') + subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role') instance_count = tables.Column(verbose_name='Instances') class Meta(BaseTable.Meta): model = DeviceType - fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count') + fields = ( + 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', + 'is_network_device', 'subdevice_role', 'instance_count' + ) # From 0899a1052ead0aa0359d6d173259ff52fab55e8f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Mar 2017 17:43:29 -0400 Subject: [PATCH 07/65] Only attempt to process session key if user is authenticated --- netbox/secrets/api/views.py | 39 +++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 63476b126..4a44776c3 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -64,27 +64,28 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): super(SecretViewSet, self).initial(request, *args, **kwargs) - # Read session key from HTTP cookie or header if it has been provided. The session key must be provided in order - # to encrypt/decrypt secrets. - if 'session_key' in request.COOKIES: - session_key = base64.b64decode(request.COOKIES['session_key']) - elif 'HTTP_X_SESSION_KEY' in request.META: - session_key = base64.b64decode(request.META['HTTP_X_SESSION_KEY']) - else: - session_key = None + if request.user.is_authenticated(): - # We can't encrypt secret plaintext without a session key. - # assert False, self.action - if self.action in ['create', 'update'] and session_key is None: - raise ValidationError("A session key must be provided when creating or updating secrets.") + # Read session key from HTTP cookie or header if it has been provided. The session key must be provided in + # order to encrypt/decrypt secrets. + if 'session_key' in request.COOKIES: + session_key = base64.b64decode(request.COOKIES['session_key']) + elif 'HTTP_X_SESSION_KEY' in request.META: + session_key = base64.b64decode(request.META['HTTP_X_SESSION_KEY']) + else: + session_key = None - # Attempt to retrieve the master key for encryption/decryption if a session key has been provided. - if session_key is not None: - try: - sk = SessionKey.objects.get(userkey__user=request.user) - self.master_key = sk.get_master_key(session_key) - except (SessionKey.DoesNotExist, InvalidSessionKey): - raise ValidationError("Invalid session key.") + # We can't encrypt secret plaintext without a session key. + if self.action in ['create', 'update'] and session_key is None: + raise ValidationError("A session key must be provided when creating or updating secrets.") + + # Attempt to retrieve the master key for encryption/decryption if a session key has been provided. + if session_key is not None: + try: + sk = SessionKey.objects.get(userkey__user=request.user) + self.master_key = sk.get_master_key(session_key) + except (SessionKey.DoesNotExist, InvalidSessionKey): + raise ValidationError("Invalid session key.") def retrieve(self, request, *args, **kwargs): From b875cea10d5f6a59a250a37a023d32867285ec8b Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 23 Mar 2017 12:57:35 +0100 Subject: [PATCH 08/65] Filter on mac address on interface via API --- netbox/dcim/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index af8f3a089..55cdadfbb 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -429,7 +429,7 @@ class InterfaceFilter(django_filters.FilterSet): if not value: return queryset try: - return queryset.filter(mac_address=value).distinct() + return queryset.filter(mac_address=value) except AddrFormatError: return queryset.none() From c0417c19897af27c01147449ec57581384a632ef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Mar 2017 10:07:02 -0400 Subject: [PATCH 09/65] Closes #972: Add ability to filter connections list by device name --- netbox/dcim/filters.py | 48 +++++++++++++++++++++++++++++++----------- netbox/dcim/forms.py | 3 +++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index fb16955c7..bf390e17b 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -443,42 +443,58 @@ class ConsoleConnectionFilter(django_filters.FilterSet): method='filter_site', label='Site (slug)', ) - - class Meta: - model = ConsoleServerPort - fields = [] + device = django_filters.CharFilter( + method='filter_device', + label='Device', + ) def filter_site(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter(cs_port__device__site__slug=value) + def filter_device(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(device__name__icontains=value) | + Q(cs_port__device__name__icontains=value) + ) + class PowerConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', ) - - class Meta: - model = PowerOutlet - fields = [] + device = django_filters.CharFilter( + method='filter_device', + label='Device', + ) def filter_site(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter(power_outlet__device__site__slug=value) + def filter_device(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(device__name__icontains=value) | + Q(power_outlet__device__name__icontains=value) + ) + class InterfaceConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', label='Site (slug)', ) - - class Meta: - model = InterfaceConnection - fields = [] + device = django_filters.CharFilter( + method='filter_device', + label='Device', + ) def filter_site(self, queryset, name, value): if not value.strip(): @@ -487,3 +503,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet): Q(interface_a__device__site__slug=value) | Q(interface_b__device__site__slug=value) ) + + def filter_device(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(interface_a__device__name__icontains=value) | + Q(interface_b__device__name__icontains=value) + ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 07a28f936..0e09adbb2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1658,14 +1658,17 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') + device = forms.CharField(required=False, label='Device name') class PowerConnectionFilterForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') + device = forms.CharField(required=False, label='Device name') class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') + device = forms.CharField(required=False, label='Device name') # From 47120fae0123491cd2bf2c07c4dce80839f194ff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Mar 2017 15:36:24 -0400 Subject: [PATCH 10/65] Rack assignment is optional for devices --- netbox/templates/dcim/device_import.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index c3915b9c3..50d2f81db 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -73,7 +73,7 @@ Rack - Rack name + Rack name (optional) R101 From ef59f38ec4a1528b85719fdd828dd7602e5fd9f4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Mar 2017 16:24:35 -0400 Subject: [PATCH 11/65] Release v1.9.3 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 856fee9ff..ae81d2689 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.9.2-dev' +VERSION = '1.9.3' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From a51f5edbc8b18a2d549212c1a3039eb62a3daa55 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Mar 2017 16:29:42 -0400 Subject: [PATCH 12/65] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ae81d2689..4d8599063 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.9.3' +VERSION = '1.9.4-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From 48141c06930f19e9fd2f39f378c42fd463dbd44b Mon Sep 17 00:00:00 2001 From: Zach Moody Date: Fri, 24 Mar 2017 17:38:06 -0500 Subject: [PATCH 13/65] Fixes #996 --- netbox/circuits/forms.py | 2 +- netbox/dcim/forms.py | 10 +++++----- netbox/ipam/forms.py | 2 +- netbox/project-static/js/livesearch.js | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 940ae939a..167ab8943 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -183,7 +183,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): label='Device', widget=Livesearch( query_key='q', - query_url='dcim-api:device_list', + query_url='dcim-api:device-list', field_to_update='device' ) ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 8f3e73e04..f6f3eeeac 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -929,7 +929,7 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): label='Console Server', widget=Livesearch( query_key='q', - query_url='dcim-api:device_list', + query_url='dcim-api:device-list', field_to_update='console_server', ) ) @@ -1030,7 +1030,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): label='Device', widget=Livesearch( query_key='q', - query_url='dcim-api:device_list', + query_url='dcim-api:device-list', field_to_update='device' ) ) @@ -1197,7 +1197,7 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): label='PDU', widget=Livesearch( query_key='q', - query_url='dcim-api:device_list', + query_url='dcim-api:device-list', field_to_update='pdu' ) ) @@ -1296,7 +1296,7 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form): label='Device', widget=Livesearch( query_key='q', - query_url='dcim-api:device_list', + query_url='dcim-api:device-list', field_to_update='device' ) ) @@ -1459,7 +1459,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): label='Device', widget=Livesearch( query_key='q', - query_url='dcim-api:device_list', + query_url='dcim-api:device-list', field_to_update='device_b' ) ) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 72b73208c..ca89f0cc6 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -404,7 +404,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): label='Device', widget=Livesearch( query_key='q', - query_url='dcim-api:device_list', + query_url='dcim-api:device-list', field_to_update='device' ) ) diff --git a/netbox/project-static/js/livesearch.js b/netbox/project-static/js/livesearch.js index 919fccf3b..f387372b4 100644 --- a/netbox/project-static/js/livesearch.js +++ b/netbox/project-static/js/livesearch.js @@ -27,7 +27,7 @@ $(document).ready(function() { data: search_key + '=' + request.term, success: function(data) { var choices = []; - $.each(data, function (index, choice) { + $.each(data.results, function (index, choice) { choices.push({ value: choice.id, label: choice[label] From 066a3b8b5234d907c6b638b689ed1b8902726201 Mon Sep 17 00:00:00 2001 From: Zach Moody Date: Fri, 24 Mar 2017 18:42:23 -0500 Subject: [PATCH 14/65] update `api_url` with new interfaces endpoint. --- netbox/circuits/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 167ab8943..384fc053d 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -192,7 +192,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): required=False, label='Interface', widget=APISelect( - api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical', + api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical', disabled_indicator='is_connected' ) ) From 22bfac746ef17b22ea757fc104f634ce76cc4e29 Mon Sep 17 00:00:00 2001 From: Zach Moody Date: Fri, 24 Mar 2017 18:58:19 -0500 Subject: [PATCH 15/65] fix remaining legacy api url paths. --- netbox/dcim/forms.py | 10 +++++----- netbox/ipam/forms.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f6f3eeeac..6473e5e56 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -937,7 +937,7 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): queryset=ConsoleServerPort.objects.all(), label='Port', widget=APISelect( - api_url='/api/dcim/devices/{{console_server}}/console-server-ports/', + api_url='/api/dcim/console-server-ports/?device_id={{device}}', disabled_indicator='connected_console', ) ) @@ -1038,7 +1038,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): queryset=ConsolePort.objects.all(), label='Port', widget=APISelect( - api_url='/api/dcim/devices/{{device}}/console-ports/', + api_url='/api/dcim/console-ports/?device_id={{device}}', disabled_indicator='cs_port' ) ) @@ -1205,7 +1205,7 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): queryset=PowerOutlet.objects.all(), label='Outlet', widget=APISelect( - api_url='/api/dcim/devices/{{pdu}}/power-outlets/', + api_url='/api/dcim/power-outlets/?device_id={{device}}', disabled_indicator='connected_port' ) ) @@ -1304,7 +1304,7 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form): queryset=PowerPort.objects.all(), label='Port', widget=APISelect( - api_url='/api/dcim/devices/{{device}}/power-ports/', + api_url='/api/dcim/power-ports/?device_id={{device}}', disabled_indicator='power_outlet' ) ) @@ -1467,7 +1467,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): queryset=Interface.objects.all(), label='Interface', widget=APISelect( - api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical', + api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical', disabled_indicator='is_connected' ) ) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index ca89f0cc6..b55fb492f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -412,7 +412,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): queryset=Interface.objects.all(), label='Interface', widget=APISelect( - api_url='/api/dcim/devices/{{device}}/interfaces/' + api_url='/api/dcim/interfaces/?device_id={{device}}' ) ) set_as_primary = forms.BooleanField( From 8103c399d55409e23861c7eb91350760dd962e6b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Mar 2017 10:53:32 -0400 Subject: [PATCH 16/65] Fixes #991: Correct server error on "create and connect another" interface connection --- netbox/dcim/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 246fe06f0..f2b042599 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1450,9 +1450,10 @@ def interfaceconnection_add(request, pk): )) if '_addanother' in request.POST: base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk}) + device_b = interfaceconnection.interface_b.device params = urlencode({ - 'rack_b': interfaceconnection.interface_b.device.rack.pk, - 'device_b': interfaceconnection.interface_b.device.pk, + 'rack_b': device_b.rack.pk if device_b.rack else '', + 'device_b': device_b.pk, }) return HttpResponseRedirect('{}?{}'.format(base_url, params)) else: From e8fd0f3531113852b426156cc900dd9d494f5e69 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Mar 2017 10:55:54 -0400 Subject: [PATCH 17/65] Order interfaces naturally for Device A --- netbox/dcim/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 0e09adbb2..e2cd829b8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1481,7 +1481,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): super(InterfaceConnectionForm, self).__init__(*args, **kwargs) # Initialize interface A choices - device_a_interfaces = Interface.objects.filter(device=device_a).exclude( + device_a_interfaces = Interface.objects.order_naturally().filter(device=device_a).exclude( form_factor__in=VIRTUAL_IFACE_TYPES ).select_related( 'circuit_termination', 'connected_as_a', 'connected_as_b' From 5d022a575a1f17efe192203870a4c011790ea154 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Mar 2017 11:13:13 -0400 Subject: [PATCH 18/65] Closes #985: Added preserve_key to get-session-key endpoint --- netbox/secrets/api/views.py | 34 ++++++++++++++++++++++++---------- netbox/secrets/exceptions.py | 4 ++-- netbox/secrets/models.py | 15 +++++++++++++-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 4a44776c3..4e20ee63c 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,7 +1,6 @@ import base64 from Crypto.PublicKey import RSA -from django.core.urlresolvers import reverse from django.http import HttpResponseBadRequest from rest_framework.exceptions import ValidationError @@ -9,7 +8,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ViewSet -from secrets.exceptions import InvalidSessionKey +from secrets.exceptions import InvalidKey from secrets.filters import SecretFilter from secrets.models import Secret, SecretRole, SessionKey, UserKey from utilities.api import WritableSerializerMixin @@ -84,7 +83,7 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): try: sk = SessionKey.objects.get(userkey__user=request.user) self.master_key = sk.get_master_key(session_key) - except (SessionKey.DoesNotExist, InvalidSessionKey): + except (SessionKey.DoesNotExist, InvalidKey): raise ValidationError("Invalid session key.") def retrieve(self, request, *args, **kwargs): @@ -140,6 +139,9 @@ class GetSessionKeyViewSet(ViewSet): { "session_key": "+8t4SI6XikgVmB5+/urhozx9O5qCQANyOk1MNe6taRf=" } + + This endpoint accepts one optional parameter: `preserve_key`. If True and a session key exists, the existing session + key will be returned instead of a new one. """ permission_classes = [IsAuthenticated] @@ -163,14 +165,26 @@ class GetSessionKeyViewSet(ViewSet): if master_key is None: return HttpResponseBadRequest(ERR_PRIVKEY_INVALID) - # Delete the existing SessionKey for this user if one exists - SessionKey.objects.filter(userkey__user=request.user).delete() + try: + current_session_key = SessionKey.objects.get(userkey__user_id=request.user.pk) + except SessionKey.DoesNotExist: + current_session_key = None - # Create a new SessionKey - sk = SessionKey(userkey=user_key) - sk.save(master_key=master_key) - encoded_key = base64.b64encode(sk.key) - # b64decode() returns a bytestring under Python 3 + if current_session_key and request.GET.get('preserve_key', False): + + # Retrieve the existing session key + key = current_session_key.get_session_key(master_key) + + else: + + # Create a new SessionKey + SessionKey.objects.filter(userkey__user=request.user).delete() + sk = SessionKey(userkey=user_key) + sk.save(master_key=master_key) + key = sk.key + + # Encode the key using base64. (b64decode() returns a bytestring under Python 3.) + encoded_key = base64.b64encode(key) if not isinstance(encoded_key, str): encoded_key = encoded_key.decode() diff --git a/netbox/secrets/exceptions.py b/netbox/secrets/exceptions.py index 417d135de..c163e5907 100644 --- a/netbox/secrets/exceptions.py +++ b/netbox/secrets/exceptions.py @@ -1,5 +1,5 @@ -class InvalidSessionKey(Exception): +class InvalidKey(Exception): """ - Raised when the a provided session key is invalid. + Raised when a provided key is invalid. """ pass diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 7d7b1cd50..9afe2af84 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -13,7 +13,7 @@ from django.utils.encoding import force_bytes, python_2_unicode_compatible from dcim.models import Device from utilities.models import CreatedUpdatedModel -from .exceptions import InvalidSessionKey +from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -221,13 +221,24 @@ class SessionKey(models.Model): # Validate the provided session key if not check_password(session_key, self.hash): - raise InvalidSessionKey() + raise InvalidKey("Invalid session key") # Decrypt master key using provided session key master_key = xor_keys(session_key, bytes(self.cipher)) return master_key + def get_session_key(self, master_key): + + # Recover session key using the master key + session_key = xor_keys(master_key, bytes(self.cipher)) + + # Validate the recovered session key + if not check_password(session_key, self.hash): + raise InvalidKey("Invalid master key") + + return session_key + @python_2_unicode_compatible class SecretRole(models.Model): From 116ceb6f939915a1b09a1a3d6b90badef195ed59 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Mar 2017 11:30:38 -0400 Subject: [PATCH 19/65] Added tests for get-session-key API endpoint --- netbox/secrets/tests/test_api.py | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 854e90882..f6f8172cf 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -226,3 +226,46 @@ class SecretTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Secret.objects.count(), 2) + + +class GetSessionKeyTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + + userkey = UserKey(user=user, public_key=PUBLIC_KEY) + userkey.save() + master_key = userkey.get_master_key(PRIVATE_KEY) + self.session_key = SessionKey(userkey=userkey) + self.session_key.save(master_key) + + self.header = { + 'HTTP_AUTHORIZATION': 'Token {}'.format(token.key), + } + + def test_get_session_key(self): + + url = reverse('secrets-api:get-session-key-list') + data = { + 'private_key': PRIVATE_KEY, + } + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertIsNotNone(response.data.get('session_key')) + self.assertNotEqual(response.data.get('session_key'), self.session_key.key) + + def test_get_session_key_preserved(self): + + encoded_session_key = base64.b64encode(self.session_key.key) + + url = reverse('secrets-api:get-session-key-list') + '?preserve_key=True' + data = { + 'private_key': PRIVATE_KEY, + } + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data.get('session_key'), encoded_session_key) From 69e54ab410b5f412bfe0fc81e2ff888091a38fa3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Mar 2017 12:19:08 -0400 Subject: [PATCH 20/65] Token admin form improvements --- netbox/users/admin.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 29c149f21..adfa151d5 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,8 +1,18 @@ +from django import forms from django.contrib import admin from .models import Token +class TokenAdminForm(forms.ModelForm): + key = forms.CharField(required=False, help_text="If no key is provided, one will be generated automatically.") + + class Meta: + fields = ['user', 'key', 'write_enabled', 'expires', 'description'] + model = Token + + @admin.register(Token) class TokenAdmin(admin.ModelAdmin): - list_display = ['user', 'key', 'created', 'expires', 'write_enabled', 'description'] + form = TokenAdminForm + list_display = ['key', 'user', 'created', 'expires', 'write_enabled', 'description'] From 28761fc96029525d213baaa5c2fdcc8fb720da56 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Mar 2017 15:57:50 -0400 Subject: [PATCH 21/65] Closes #362: Added per_page query parameter to control pagination page length --- netbox/ipam/views.py | 22 +++++++++++++++++++--- netbox/utilities/paginator.py | 3 ++- netbox/utilities/views.py | 9 ++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index bcbb80e8b..864909878 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,6 +1,7 @@ from django_tables2 import RequestConfig import netaddr +from django.conf import settings from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib import messages @@ -295,7 +296,12 @@ def aggregate(request, pk): prefix_table = tables.PrefixTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): prefix_table.base_columns['pk'].visible = True - RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table) + + paginate = { + 'klass': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(prefix_table) # Compile permissions list for rendering the object table permissions = { @@ -427,7 +433,12 @@ def prefix(request, pk): child_prefix_table = tables.PrefixTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): child_prefix_table.base_columns['pk'].visible = True - RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table) + + paginate = { + 'klass': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(child_prefix_table) # Compile permissions list for rendering the object table permissions = { @@ -500,7 +511,12 @@ def prefix_ipaddresses(request, pk): ip_table = tables.IPAddressTable(ipaddresses) if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): ip_table.base_columns['pk'].visible = True - RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table) + + paginate = { + 'klass': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(ip_table) # Compile permissions list for rendering the object table permissions = { diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py index 2c0e2fb3d..ae915f773 100644 --- a/netbox/utilities/paginator.py +++ b/netbox/utilities/paginator.py @@ -5,7 +5,8 @@ from django.core.paginator import Paginator, Page class EnhancedPaginator(Paginator): def __init__(self, object_list, per_page, **kwargs): - per_page = getattr(settings, 'PAGINATE_COUNT', 50) + if not isinstance(per_page, int) or per_page < 1: + per_page = getattr(settings, 'PAGINATE_COUNT', 50) super(EnhancedPaginator, self).__init__(object_list, per_page, **kwargs) def _get_page(self, *args, **kwargs): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index f38d9a0ab..08bf3f65a 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,6 +1,7 @@ from collections import OrderedDict from django_tables2 import RequestConfig +from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError @@ -101,7 +102,13 @@ class ObjectListView(View): table = self.table(self.queryset) if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.base_columns['pk'].visible = True - RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(table) + + # Apply the request context + paginate = { + 'klass': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(table) context = { 'table': table, From 58e4bf1cc3bf83e453e72f03fed44b94ee84b536 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Mar 2017 16:41:53 -0400 Subject: [PATCH 22/65] Closes #973: Removed extraneous admin UI functions --- netbox/circuits/admin.py | 29 ------ netbox/dcim/admin.py | 212 --------------------------------------- netbox/ipam/admin.py | 81 --------------- netbox/secrets/admin.py | 15 --- netbox/tenancy/admin.py | 23 ----- 5 files changed, 360 deletions(-) delete mode 100644 netbox/circuits/admin.py delete mode 100644 netbox/dcim/admin.py delete mode 100644 netbox/ipam/admin.py delete mode 100644 netbox/tenancy/admin.py diff --git a/netbox/circuits/admin.py b/netbox/circuits/admin.py deleted file mode 100644 index 281ed2104..000000000 --- a/netbox/circuits/admin.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.contrib import admin - -from .models import Provider, CircuitType, Circuit - - -@admin.register(Provider) -class ProviderAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['name'], - } - list_display = ['name', 'slug', 'asn'] - - -@admin.register(CircuitType) -class CircuitTypeAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['name'], - } - list_display = ['name', 'slug'] - - -@admin.register(Circuit) -class CircuitAdmin(admin.ModelAdmin): - list_display = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate_human'] - list_filter = ['provider', 'type', 'tenant'] - - def get_queryset(self, request): - qs = super(CircuitAdmin, self).get_queryset(request) - return qs.select_related('provider', 'type', 'tenant') diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py deleted file mode 100644 index a1b64f235..000000000 --- a/netbox/dcim/admin.py +++ /dev/null @@ -1,212 +0,0 @@ -from django.contrib import admin -from django.db.models import Count - -from mptt.admin import MPTTModelAdmin - -from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform, - PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, - Site, -) - - -@admin.register(Region) -class RegionAdmin(MPTTModelAdmin): - list_display = ['name', 'parent', 'slug'] - prepopulated_fields = { - 'slug': ['name'], - } - - -@admin.register(Site) -class SiteAdmin(admin.ModelAdmin): - list_display = ['name', 'slug', 'facility', 'asn'] - prepopulated_fields = { - 'slug': ['name'], - } - - -@admin.register(RackGroup) -class RackGroupAdmin(admin.ModelAdmin): - list_display = ['name', 'slug', 'site'] - prepopulated_fields = { - 'slug': ['name'], - } - - -@admin.register(RackRole) -class RackRoleAdmin(admin.ModelAdmin): - list_display = ['name', 'slug', 'color'] - prepopulated_fields = { - 'slug': ['name'], - } - - -@admin.register(Rack) -class RackAdmin(admin.ModelAdmin): - list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height'] - - -@admin.register(RackReservation) -class RackRackReservationAdmin(admin.ModelAdmin): - list_display = ['rack', 'units', 'description', 'user', 'created'] - - -# -# Device types -# - -@admin.register(Manufacturer) -class ManufacturerAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['name'], - } - list_display = ['name', 'slug'] - - -class ConsolePortTemplateAdmin(admin.TabularInline): - model = ConsolePortTemplate - - -class ConsoleServerPortTemplateAdmin(admin.TabularInline): - model = ConsoleServerPortTemplate - - -class PowerPortTemplateAdmin(admin.TabularInline): - model = PowerPortTemplate - - -class PowerOutletTemplateAdmin(admin.TabularInline): - model = PowerOutletTemplate - - -class InterfaceTemplateAdmin(admin.TabularInline): - model = InterfaceTemplate - - -class DeviceBayTemplateAdmin(admin.TabularInline): - model = DeviceBayTemplate - - -@admin.register(DeviceType) -class DeviceTypeAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['model'], - } - inlines = [ - ConsolePortTemplateAdmin, - ConsoleServerPortTemplateAdmin, - PowerPortTemplateAdmin, - PowerOutletTemplateAdmin, - InterfaceTemplateAdmin, - DeviceBayTemplateAdmin, - ] - list_display = ['model', 'manufacturer', 'slug', 'part_number', 'u_height', 'console_ports', 'console_server_ports', - 'power_ports', 'power_outlets', 'interfaces', 'device_bays'] - list_filter = ['manufacturer'] - - def get_queryset(self, request): - return DeviceType.objects.annotate( - console_port_count=Count('console_port_templates', distinct=True), - cs_port_count=Count('cs_port_templates', distinct=True), - power_port_count=Count('power_port_templates', distinct=True), - power_outlet_count=Count('power_outlet_templates', distinct=True), - interface_count=Count('interface_templates', distinct=True), - devicebay_count=Count('device_bay_templates', distinct=True), - ) - - def console_ports(self, instance): - return instance.console_port_count - - def console_server_ports(self, instance): - return instance.cs_port_count - - def power_ports(self, instance): - return instance.power_port_count - - def power_outlets(self, instance): - return instance.power_outlet_count - - def interfaces(self, instance): - return instance.interface_count - - def device_bays(self, instance): - return instance.devicebay_count - - -# -# Devices -# - -@admin.register(DeviceRole) -class DeviceRoleAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['name'], - } - list_display = ['name', 'slug', 'color'] - - -@admin.register(Platform) -class PlatformAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['name'], - } - list_display = ['name', 'rpc_client'] - - -class ConsolePortAdmin(admin.TabularInline): - model = ConsolePort - readonly_fields = ['cs_port'] - - -class ConsoleServerPortAdmin(admin.TabularInline): - model = ConsoleServerPort - - -class PowerPortAdmin(admin.TabularInline): - model = PowerPort - readonly_fields = ['power_outlet'] - - -class PowerOutletAdmin(admin.TabularInline): - model = PowerOutlet - - -class InterfaceAdmin(admin.TabularInline): - model = Interface - - -class DeviceBayAdmin(admin.TabularInline): - model = DeviceBay - fk_name = 'device' - readonly_fields = ['installed_device'] - - -class InventoryItemAdmin(admin.TabularInline): - model = InventoryItem - readonly_fields = ['parent', 'discovered'] - - -@admin.register(Device) -class DeviceAdmin(admin.ModelAdmin): - inlines = [ - ConsolePortAdmin, - ConsoleServerPortAdmin, - PowerPortAdmin, - PowerOutletAdmin, - InterfaceAdmin, - DeviceBayAdmin, - InventoryItemAdmin, - ] - list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag', - 'serial'] - list_filter = ['device_role'] - - def get_queryset(self, request): - qs = super(DeviceAdmin, self).get_queryset(request) - return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip4', 'primary_ip6', 'rack') - - def device_type_full_name(self, obj): - return obj.device_type.full_name - device_type_full_name.short_description = 'Device type' diff --git a/netbox/ipam/admin.py b/netbox/ipam/admin.py deleted file mode 100644 index f3f914129..000000000 --- a/netbox/ipam/admin.py +++ /dev/null @@ -1,81 +0,0 @@ -from django.contrib import admin - -from .models import ( - Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF, -) - - -@admin.register(VRF) -class VRFAdmin(admin.ModelAdmin): - list_display = ['name', 'rd', 'tenant', 'enforce_unique'] - list_filter = ['tenant'] - - def get_queryset(self, request): - qs = super(VRFAdmin, self).get_queryset(request) - return qs.select_related('tenant') - - -@admin.register(Role) -class RoleAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['name'], - } - list_display = ['name', 'slug', 'weight'] - - -@admin.register(RIR) -class RIRAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['name'], - } - list_display = ['name', 'slug', 'is_private'] - - -@admin.register(Aggregate) -class AggregateAdmin(admin.ModelAdmin): - list_display = ['prefix', 'rir', 'date_added'] - list_filter = ['family', 'rir'] - search_fields = ['prefix'] - - -@admin.register(Prefix) -class PrefixAdmin(admin.ModelAdmin): - list_display = ['prefix', 'vrf', 'tenant', 'site', 'status', 'role', 'vlan'] - list_filter = ['family', 'site', 'status', 'role'] - search_fields = ['prefix'] - - def get_queryset(self, request): - qs = super(PrefixAdmin, self).get_queryset(request) - return qs.select_related('vrf', 'site', 'role', 'vlan') - - -@admin.register(IPAddress) -class IPAddressAdmin(admin.ModelAdmin): - list_display = ['address', 'vrf', 'tenant', 'nat_inside'] - list_filter = ['family'] - fields = ['address', 'vrf', 'device', 'interface', 'nat_inside'] - readonly_fields = ['interface', 'device', 'nat_inside'] - search_fields = ['address'] - - def get_queryset(self, request): - qs = super(IPAddressAdmin, self).get_queryset(request) - return qs.select_related('vrf', 'nat_inside') - - -@admin.register(VLANGroup) -class VLANGroupAdmin(admin.ModelAdmin): - list_display = ['name', 'site', 'slug'] - prepopulated_fields = { - 'slug': ['name'], - } - - -@admin.register(VLAN) -class VLANAdmin(admin.ModelAdmin): - list_display = ['site', 'vid', 'name', 'tenant', 'status', 'role'] - list_filter = ['site', 'tenant', 'status', 'role'] - search_fields = ['vid', 'name'] - - def get_queryset(self, request): - qs = super(VLANAdmin, self).get_queryset(request) - return qs.select_related('site', 'tenant', 'role') diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py index ac0cf1b8a..1dbe6173e 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -54,18 +54,3 @@ class UserKeyAdmin(admin.ModelAdmin): 'form': form, }) activate_selected.short_description = "Activate selected user keys" - - -@admin.register(SecretRole) -class SecretRoleAdmin(admin.ModelAdmin): - list_display = ['name', 'slug'] - prepopulated_fields = { - 'slug': ['name'], - } - - -@admin.register(Secret) -class SecretAdmin(admin.ModelAdmin): - list_display = ['device', 'role', 'name', 'created', 'last_updated'] - fields = ['device', 'role', 'name', 'hash', 'created', 'last_updated'] - readonly_fields = ['device', 'hash', 'created', 'last_updated'] diff --git a/netbox/tenancy/admin.py b/netbox/tenancy/admin.py deleted file mode 100644 index efd0d2ac8..000000000 --- a/netbox/tenancy/admin.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.contrib import admin - -from .models import Tenant, TenantGroup - - -@admin.register(TenantGroup) -class TenantGroupAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['name'], - } - list_display = ['name', 'slug'] - - -@admin.register(Tenant) -class TenantAdmin(admin.ModelAdmin): - prepopulated_fields = { - 'slug': ['name'], - } - list_display = ['name', 'slug', 'group', 'description'] - - def get_queryset(self, request): - qs = super(TenantAdmin, self).get_queryset(request) - return qs.select_related('group') From afdb24610d8743260a2248ba7d9e455521755496 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Mar 2017 12:04:57 -0400 Subject: [PATCH 23/65] Initial work on global search --- netbox/netbox/forms.py | 40 +++++++++ netbox/netbox/urls.py | 5 +- netbox/netbox/views.py | 157 ++++++++++++++++++++++++++++++++++- netbox/secrets/views.py | 2 +- netbox/templates/home.html | 63 ++------------ netbox/templates/search.html | 74 +++++++++++++++++ 6 files changed, 278 insertions(+), 63 deletions(-) create mode 100644 netbox/netbox/forms.py create mode 100644 netbox/templates/search.html diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py new file mode 100644 index 000000000..2842668b4 --- /dev/null +++ b/netbox/netbox/forms.py @@ -0,0 +1,40 @@ +from django import forms + +from utilities.forms import BootstrapMixin + + +OBJ_TYPE_CHOICES = ( + ('', 'All Objects'), + ('Circuits', ( + ('provider', 'Providers'), + ('circuit', 'Circuits'), + )), + ('DCIM', ( + ('site', 'Sites'), + ('rack', 'Racks'), + ('devicetype', 'Device types'), + ('device', 'Devices'), + )), + ('IPAM', ( + ('vrf', 'VRFs'), + ('aggregate', 'Aggregates'), + ('prefix', 'Prefixes'), + ('ipaddress', 'IP addresses'), + ('vlan', 'VLANs'), + )), + ('Secrets', ( + ('secret', 'Secrets'), + )), + ('Tenancy', ( + ('tenant', 'Tenants'), + )), +) + + +class SearchForm(BootstrapMixin, forms.Form): + q = forms.CharField( + label='Query' + ) + obj_type = forms.ChoiceField( + choices=OBJ_TYPE_CHOICES, required=False, label='Type' + ) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index e7a76bed9..724ab3090 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -2,7 +2,7 @@ from django.conf import settings from django.conf.urls import include, url from django.contrib import admin -from netbox.views import APIRootView, home, handle_500, trigger_500 +from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500 from users.views import login, logout @@ -10,8 +10,9 @@ handler500 = handle_500 _patterns = [ - # Default page + # Base views url(r'^$', home, name='home'), + url(r'^search/$', SearchView.as_view(), name='search'), # Login/logout url(r'^login/$', login, name='login'), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 17a1e19d2..12df44e6f 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -1,18 +1,122 @@ import sys -from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.reverse import reverse +from django.db.models import Count from django.shortcuts import render +from django.views.generic import View -from circuits.models import Provider, Circuit -from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection +from circuits.filters import CircuitFilter, ProviderFilter +from circuits.models import Circuit, Provider +from circuits.tables import CircuitTable, ProviderTable +from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter +from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site +from dcim.tables import DeviceTable, DeviceTypeTable, RackTable, SiteTable from extras.models import UserAction -from ipam.models import Aggregate, Prefix, IPAddress, VLAN, VRF +from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter +from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF +from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable +from secrets.filters import SecretFilter from secrets.models import Secret +from secrets.tables import SecretTable +from tenancy.filters import TenantFilter from tenancy.models import Tenant +from tenancy.tables import TenantTable +from .forms import SearchForm + + +SEARCH_MAX_RESULTS = 15 +SEARCH_TYPES = { + # Circuits + 'provider': { + 'queryset': Provider.objects.annotate(count_circuits=Count('circuits')), + 'filter': ProviderFilter, + 'table': ProviderTable, + 'url': 'circuits:provider_list', + }, + 'circuit': { + 'queryset': Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related( + 'terminations__site' + ), + 'filter': CircuitFilter, + 'table': CircuitTable, + 'url': 'circuits:circuit_list', + }, + # DCIM + 'site': { + 'queryset': Site.objects.select_related('region', 'tenant'), + 'filter': SiteFilter, + 'table': SiteTable, + 'url': 'dcim:site_list', + }, + 'rack': { + 'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type').annotate(device_count=Count('devices', distinct=True)), + 'filter': RackFilter, + 'table': RackTable, + 'url': 'dcim:rack_list', + }, + 'devicetype': { + 'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')), + 'filter': DeviceTypeFilter, + 'table': DeviceTypeTable, + 'url': 'dcim:devicetype_list', + }, + 'device': { + 'queryset': Device.objects.select_related( + 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6' + ), + 'filter': DeviceFilter, + 'table': DeviceTable, + 'url': 'dcim:device_list', + }, + # IPAM + 'vrf': { + 'queryset': VRF.objects.select_related('tenant'), + 'filter': VRFFilter, + 'table': VRFTable, + 'url': 'ipam:vrf_list', + }, + 'aggregate': { + 'queryset': Aggregate.objects.select_related('rir'), + 'filter': AggregateFilter, + 'table': AggregateTable, + 'url': 'ipam:aggregate_list', + }, + 'prefix': { + 'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), + 'filter': PrefixFilter, + 'table': PrefixTable, + 'url': 'ipam:prefix_list', + }, + 'ipaddress': { + 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'), + 'filter': IPAddressFilter, + 'table': IPAddressTable, + 'url': 'ipam:ipaddress_list', + }, + 'vlan': { + 'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes'), + 'filter': VLANFilter, + 'table': VLANTable, + 'url': 'ipam:vlan_list', + }, + # Secrets + 'secret': { + 'queryset': Secret.objects.select_related('role', 'device'), + 'filter': SecretFilter, + 'table': SecretTable, + 'url': 'secrets:secret_list', + }, + # Tenancy + 'tenant': { + 'queryset': Tenant.objects.select_related('group'), + 'filter': TenantFilter, + 'table': TenantTable, + 'url': 'tenancy:tenant_list', + }, +} def home(request): @@ -47,11 +151,56 @@ def home(request): } return render(request, 'home.html', { + 'search_form': SearchForm(), 'stats': stats, 'recent_activity': UserAction.objects.select_related('user')[:50] }) +class SearchView(View): + + def get(self, request): + + # No query + if 'q' not in request.GET: + return render(request, 'search.html', { + 'form': SearchForm(), + }) + + form = SearchForm(request.GET) + results = [] + + if form.is_valid(): + + # Searching for a single type of object + if form.cleaned_data['obj_type']: + obj_types = [form.cleaned_data['obj_type']] + # Searching all object types + else: + obj_types = SEARCH_TYPES.keys() + + for obj_type in obj_types: + queryset = SEARCH_TYPES[obj_type]['queryset'] + filter = SEARCH_TYPES[obj_type]['filter'] + table = SEARCH_TYPES[obj_type]['table'] + url = SEARCH_TYPES[obj_type]['url'] + filtered_queryset = filter({'q': form.cleaned_data['q']}, queryset=queryset).qs + total_count = filtered_queryset.count() + if total_count: + results.append({ + 'name': queryset.model._meta.verbose_name_plural, + 'table': table(filtered_queryset[:SEARCH_MAX_RESULTS]), + 'total': total_count, + 'url': '{}?q={}'.format(reverse(url), form.cleaned_data['q']) + }) + + return render(request, 'search.html', { + 'form': form, + 'results': results, + }) + + + class APIRootView(APIView): _ignore_model_permissions = True exclude_from_schema = True diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 8a1b35a6b..308e9de3d 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -48,7 +48,7 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @method_decorator(login_required, name='dispatch') class SecretListView(ObjectListView): - queryset = Secret.objects.select_related('role').prefetch_related('device') + queryset = Secret.objects.select_related('role', 'device') filter = filters.SecretFilter filter_form = forms.SecretFilterForm table = tables.SecretTable diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 043526332..b489d90e5 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -3,62 +3,13 @@ {% block content %}
diff --git a/netbox/templates/search.html b/netbox/templates/search.html new file mode 100644 index 000000000..aca275639 --- /dev/null +++ b/netbox/templates/search.html @@ -0,0 +1,74 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block content %} + {% if request.GET.q %} +
+
+ {# Compressed search form #} +
+ {{ form.q }} + {{ form.obj_type }} + +
+
+
+
+
+ {% for obj_type in results %} +

{{ obj_type.name|title }}

+ {% include 'table.html' with table=obj_type.table %} + {% if obj_type.total > obj_type.table.rows|length %} + + + All {{ obj_type.total }} results + + {% endif %} +
+ {% empty %} +

No results found

+ {% endfor %} +
+
+ {% if results %} +
+
+ Search Results +
+
+ {% for obj_type in results %} + + {{ obj_type.name|title }} + {{ obj_type.total }} + + {% endfor %} +
+
+ {% endif %} +
+
+ {% else %} + {# Larger search form #} +
+
+
+
+
+ Search +
+
+ {% render_form form %} +
+ +
+
+
+
+ {% endif %} +{% endblock %} From 6813787fc766e6b9062bee8e1e0a2f17a88fbcdd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Mar 2017 12:15:14 -0400 Subject: [PATCH 24/65] Fixes #1013: Show edit/delete reservation buttons on rack view --- netbox/templates/dcim/rack.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index d6529c2a4..40c9e812e 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -216,12 +216,12 @@ {{ resv.user }} · {{ resv.created }} - {% if perms.change_rackreservation %} + {% if perms.dcim.change_rackreservation %} {% endif %} - {% if perms.delete_rackreservation %} + {% if perms.dcim.delete_rackreservation %} From 6542b8b198c298d3a3cf342e02397455f1453fa0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Mar 2017 13:39:59 -0400 Subject: [PATCH 25/65] Base64 decoding tweaks --- netbox/secrets/api/views.py | 4 +--- netbox/secrets/tests/test_api.py | 6 ++++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 4e20ee63c..601f7aece 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -184,9 +184,7 @@ class GetSessionKeyViewSet(ViewSet): key = sk.key # Encode the key using base64. (b64decode() returns a bytestring under Python 3.) - encoded_key = base64.b64encode(key) - if not isinstance(encoded_key, str): - encoded_key = encoded_key.decode() + encoded_key = base64.b64encode(key).decode() # Craft the response response = Response({ diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index f6f8172cf..227478b99 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -247,6 +247,8 @@ class GetSessionKeyTest(HttpStatusMixin, APITestCase): def test_get_session_key(self): + encoded_session_key = base64.b64encode(self.session_key.key).decode() + url = reverse('secrets-api:get-session-key-list') data = { 'private_key': PRIVATE_KEY, @@ -255,11 +257,11 @@ class GetSessionKeyTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) self.assertIsNotNone(response.data.get('session_key')) - self.assertNotEqual(response.data.get('session_key'), self.session_key.key) + self.assertNotEqual(response.data.get('session_key'), encoded_session_key) def test_get_session_key_preserved(self): - encoded_session_key = base64.b64encode(self.session_key.key) + encoded_session_key = base64.b64encode(self.session_key.key).decode() url = reverse('secrets-api:get-session-key-list') + '?preserve_key=True' data = { From d04436aa0a0d1c8bf97a561c581a3279f0cfa7ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Mar 2017 14:22:27 -0400 Subject: [PATCH 26/65] Search form improvements --- netbox/netbox/forms.py | 2 +- netbox/templates/home.html | 10 +--- netbox/templates/search.html | 79 ++++++++++++++----------------- netbox/templates/search_form.html | 9 ++++ 4 files changed, 46 insertions(+), 54 deletions(-) create mode 100644 netbox/templates/search_form.html diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index 2842668b4..63af2e04b 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms.py @@ -33,7 +33,7 @@ OBJ_TYPE_CHOICES = ( class SearchForm(BootstrapMixin, forms.Form): q = forms.CharField( - label='Query' + label='Query', widget=forms.TextInput(attrs={'style': 'width: 350px'}) ) obj_type = forms.ChoiceField( choices=OBJ_TYPE_CHOICES, required=False, label='Type' diff --git a/netbox/templates/home.html b/netbox/templates/home.html index b489d90e5..15965b13f 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -2,15 +2,7 @@ {% load render_table from django_tables2 %} {% block content %} - +{% include 'search_form.html' %}
diff --git a/netbox/templates/search.html b/netbox/templates/search.html index aca275639..5e72e3396 100644 --- a/netbox/templates/search.html +++ b/netbox/templates/search.html @@ -3,52 +3,43 @@ {% block content %} {% if request.GET.q %} -
-
- {# Compressed search form #} -
- {{ form.q }} - {{ form.obj_type }} - -
-
-
-
-
- {% for obj_type in results %} -

{{ obj_type.name|title }}

- {% include 'table.html' with table=obj_type.table %} - {% if obj_type.total > obj_type.table.rows|length %} - - - All {{ obj_type.total }} results - + {% include 'search_form.html' with search_form=form %} + {% if results %} +
+
+ {% for obj_type in results %} +

{{ obj_type.name|title }}

+ {% include 'table.html' with table=obj_type.table %} + {% if obj_type.total > obj_type.table.rows|length %} + + + All {{ obj_type.total }} results + + {% endif %} +
+ {% endfor %} +
+
+ {% if results %} +
+
+ Search Results +
+
+ {% for obj_type in results %} + + {{ obj_type.name|title }} + {{ obj_type.total }} + + {% endfor %} +
+
{% endif %} -
- {% empty %} -

No results found

- {% endfor %} +
-
- {% if results %} -
-
- Search Results -
-
- {% for obj_type in results %} - - {{ obj_type.name|title }} - {{ obj_type.total }} - - {% endfor %} -
-
- {% endif %} -
-
+ {% else %} +

No results found

+ {% endif %} {% else %} {# Larger search form #}
diff --git a/netbox/templates/search_form.html b/netbox/templates/search_form.html new file mode 100644 index 000000000..b60d879b7 --- /dev/null +++ b/netbox/templates/search_form.html @@ -0,0 +1,9 @@ +
+
+
+ {{ search_form.q }} + {{ search_form.obj_type }} + +
+
+
From a5dc91c17514826d23ac5b278eabe558d0018d0b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Mar 2017 16:05:23 -0400 Subject: [PATCH 27/65] Introduced SearchTable for improved performance --- netbox/circuits/tables.py | 52 ++++++++++----- netbox/dcim/filters.py | 6 +- netbox/dcim/tables.py | 103 ++++++++++++++++++++++------- netbox/ipam/tables.py | 122 ++++++++++++++++++++++++----------- netbox/netbox/views.py | 69 ++++++++++---------- netbox/secrets/tables.py | 14 ++-- netbox/templates/search.html | 36 +++++------ netbox/templates/table.html | 4 +- netbox/tenancy/tables.py | 13 ++-- netbox/utilities/tables.py | 15 ++++- 10 files changed, 290 insertions(+), 144 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index ab877a8ce..07e2c4477 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, SearchTable, ToggleColumn from .models import Circuit, CircuitType, Provider @@ -19,9 +19,7 @@ CIRCUITTYPE_ACTIONS = """ class ProviderTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn('circuits:provider', args=[Accessor('slug')], verbose_name='Name') - asn = tables.Column(verbose_name='ASN') - account = tables.Column(verbose_name='Account') + name = tables.LinkColumn() circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits') class Meta(BaseTable.Meta): @@ -29,17 +27,25 @@ class ProviderTable(BaseTable): fields = ('pk', 'name', 'asn', 'account', 'circuit_count') +class ProviderSearchTable(SearchTable): + name = tables.LinkColumn() + + class Meta(SearchTable.Meta): + model = Provider + fields = ('name', 'asn', 'account') + + # # Circuit types # class CircuitTypeTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') + name = tables.LinkColumn() circuit_count = tables.Column(verbose_name='Circuits') - slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}}, - verbose_name='') + actions = tables.TemplateColumn( + template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='' + ) class Meta(BaseTable.Meta): model = CircuitType @@ -52,16 +58,28 @@ class CircuitTypeTable(BaseTable): class CircuitTable(BaseTable): pk = ToggleColumn() - cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID') - type = tables.Column(verbose_name='Type') - provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - a_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_a.site'), orderable=False, - args=[Accessor('termination_a.site.slug')]) - z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False, - args=[Accessor('termination_z.site.slug')]) - description = tables.Column(verbose_name='Description') + cid = tables.LinkColumn(verbose_name='ID') + provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')]) + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + a_side = tables.LinkColumn( + 'dcim:site', accessor=Accessor('termination_a.site'), orderable=False, + args=[Accessor('termination_a.site.slug')] + ) + z_side = tables.LinkColumn( + 'dcim:site', accessor=Accessor('termination_z.site'), orderable=False, + args=[Accessor('termination_z.site.slug')] + ) class Meta(BaseTable.Meta): model = Circuit fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description') + + +class CircuitSearchTable(SearchTable): + cid = tables.LinkColumn(verbose_name='ID') + provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')]) + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + + class Meta(SearchTable.Meta): + model = Circuit + fields = ('cid', 'type', 'provider', 'tenant', 'description') diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 9d9425f9c..6eab5ae34 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -8,9 +8,9 @@ from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES, + DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceTemplate, Manufacturer, InventoryItem, + Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, + RackRole, Region, Site, VIRTUAL_IFACE_TYPES, ) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index c4c7c5b73..b6ebb1be2 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, SearchTable, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, @@ -136,11 +136,9 @@ class RegionTable(BaseTable): class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name') - facility = tables.Column(verbose_name='Facility') - region = tables.TemplateColumn(template_code=SITE_REGION_LINK, verbose_name='Region') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - asn = tables.Column(verbose_name='ASN') + name = tables.LinkColumn() + region = tables.TemplateColumn(template_code=SITE_REGION_LINK) + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks') device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices') prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes') @@ -155,6 +153,16 @@ class SiteTable(BaseTable): ) +class SiteSearchTable(SearchTable): + name = tables.LinkColumn() + region = tables.TemplateColumn(template_code=SITE_REGION_LINK) + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + + class Meta(SearchTable.Meta): + model = Site + fields = ('name', 'facility', 'region', 'tenant', 'asn') + + # # Rack groups # @@ -197,20 +205,33 @@ class RackRoleTable(BaseTable): class RackTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') + name = tables.LinkColumn() + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') - facility_id = tables.Column(verbose_name='Facility ID') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role') + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + role = tables.TemplateColumn(RACK_ROLE) u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') - devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices') + devices = tables.Column(accessor=Accessor('device_count')) get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') class Meta(BaseTable.Meta): model = Rack - fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', - 'get_utilization') + fields = ( + 'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization' + ) + + +class RackSearchTable(SearchTable): + name = tables.LinkColumn() + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + role = tables.TemplateColumn(RACK_ROLE) + u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') + + class Meta(SearchTable.Meta): + model = Rack + fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height') class RackImportTable(BaseTable): @@ -249,9 +270,7 @@ class ManufacturerTable(BaseTable): class DeviceTypeTable(BaseTable): pk = ToggleColumn() - manufacturer = tables.Column(verbose_name='Manufacturer') model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type') - part_number = tables.Column(verbose_name='Part Number') is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') is_console_server = tables.BooleanColumn(verbose_name='CS') is_pdu = tables.BooleanColumn(verbose_name='PDU') @@ -267,6 +286,22 @@ class DeviceTypeTable(BaseTable): ) +class DeviceTypeSearchTable(SearchTable): + model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type') + is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') + is_console_server = tables.BooleanColumn(verbose_name='CS') + is_pdu = tables.BooleanColumn(verbose_name='PDU') + is_network_device = tables.BooleanColumn(verbose_name='Net') + subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role') + + class Meta(SearchTable.Meta): + model = DeviceType + fields = ( + 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', + 'is_network_device', 'subdevice_role', + ) + + # # Device type components # @@ -373,22 +408,42 @@ class PlatformTable(BaseTable): class DeviceTable(BaseTable): pk = ToggleColumn() + name = tables.TemplateColumn(template_code=DEVICE_LINK) status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='') - name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') - rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role') - device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', - text=lambda record: record.device_type.full_name) - primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address', - template_code=DEVICE_PRIMARY_IP) + device_type = tables.LinkColumn( + 'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', + text=lambda record: record.device_type.full_name + ) + primary_ip = tables.TemplateColumn( + orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP + ) class Meta(BaseTable.Meta): model = Device fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip') +class DeviceSearchTable(SearchTable): + name = tables.TemplateColumn(template_code=DEVICE_LINK) + status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='') + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) + device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role') + device_type = tables.LinkColumn( + 'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', + text=lambda record: record.device_type.full_name + ) + + class Meta(SearchTable.Meta): + model = Device + fields = ('name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type') + + class DeviceImportTable(BaseTable): name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 6c99f7d9e..49f87d716 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, SearchTable, ToggleColumn from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF @@ -133,16 +133,25 @@ TENANT_LINK = """ class VRFTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name') + name = tables.LinkColumn() rd = tables.Column(verbose_name='RD') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - description = tables.Column(verbose_name='Description') + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) class Meta(BaseTable.Meta): model = VRF fields = ('pk', 'name', 'rd', 'tenant', 'description') +class VRFSearchTable(SearchTable): + name = tables.LinkColumn() + rd = tables.Column(verbose_name='RD') + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + + class Meta(SearchTable.Meta): + model = VRF + fields = ('name', 'rd', 'tenant', 'description') + + # # RIRs # @@ -177,18 +186,25 @@ class RIRTable(BaseTable): class AggregateTable(BaseTable): pk = ToggleColumn() - prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate') - rir = tables.Column(verbose_name='RIR') + prefix = tables.LinkColumn(verbose_name='Aggregate') child_count = tables.Column(verbose_name='Prefixes') get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added') - description = tables.Column(verbose_name='Description') class Meta(BaseTable.Meta): model = Aggregate fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description') +class AggregateSearchTable(SearchTable): + prefix = tables.LinkColumn(verbose_name='Aggregate') + date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added') + + class Meta(SearchTable.Meta): + model = Aggregate + fields = ('prefix', 'rir', 'date_added', 'description') + + # # Roles # @@ -212,14 +228,13 @@ class RoleTable(BaseTable): class PrefixTable(BaseTable): pk = ToggleColumn() - status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') - prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix', attrs={'th': {'style': 'padding-left: 17px'}}) + prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}}) + status = tables.TemplateColumn(STATUS_LABEL) vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') - tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') + tenant = tables.TemplateColumn(TENANT_LINK) + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN') - role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role') - description = tables.Column(verbose_name='Description') + role = tables.TemplateColumn(PREFIX_ROLE_LINK) class Meta(BaseTable.Meta): model = Prefix @@ -230,12 +245,11 @@ class PrefixTable(BaseTable): class PrefixBriefTable(BaseTable): - prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF, verbose_name='Prefix') - vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') - status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') - vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN') - role = tables.Column(verbose_name='Role') + prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF) + vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + status = tables.TemplateColumn(STATUS_LABEL) + vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')]) class Meta(BaseTable.Meta): model = Prefix @@ -243,6 +257,20 @@ class PrefixBriefTable(BaseTable): orderable = False +class PrefixSearchTable(SearchTable): + prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}}) + status = tables.TemplateColumn(STATUS_LABEL) + vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') + tenant = tables.TemplateColumn(TENANT_LINK) + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN') + role = tables.TemplateColumn(PREFIX_ROLE_LINK) + + class Meta(SearchTable.Meta): + model = Prefix + fields = ('prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description') + + # # IPAddresses # @@ -250,13 +278,11 @@ class PrefixBriefTable(BaseTable): class IPAddressTable(BaseTable): pk = ToggleColumn() address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') - status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') + status = tables.TemplateColumn(STATUS_LABEL) vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') - tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant') - device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, - verbose_name='Device') - interface = tables.Column(orderable=False, verbose_name='Interface') - description = tables.Column(verbose_name='Description') + tenant = tables.TemplateColumn(TENANT_LINK) + device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False) + interface = tables.Column(orderable=False) class Meta(BaseTable.Meta): model = IPAddress @@ -268,17 +294,30 @@ class IPAddressTable(BaseTable): class IPAddressBriefTable(BaseTable): address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address') - device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, - verbose_name='Device') - interface = tables.Column(orderable=False, verbose_name='Interface') - nat_inside = tables.LinkColumn('ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, - verbose_name='NAT (Inside)') + device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False) + interface = tables.Column(orderable=False) + nat_inside = tables.LinkColumn( + 'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)' + ) class Meta(BaseTable.Meta): model = IPAddress fields = ('address', 'device', 'interface', 'nat_inside') +class IPAddressSearchTable(SearchTable): + address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') + status = tables.TemplateColumn(STATUS_LABEL) + vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') + tenant = tables.TemplateColumn(TENANT_LINK) + device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False) + interface = tables.Column(orderable=False) + + class Meta(SearchTable.Meta): + model = IPAddress + fields = ('address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description') + + # # VLAN groups # @@ -304,15 +343,26 @@ class VLANGroupTable(BaseTable): class VLANTable(BaseTable): pk = ToggleColumn() vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') - name = tables.Column(verbose_name='Name') prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') - role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role') - description = tables.Column(verbose_name='Description') + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + status = tables.TemplateColumn(STATUS_LABEL) + role = tables.TemplateColumn(VLAN_ROLE_LINK) class Meta(BaseTable.Meta): model = VLAN fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') + + +class VLANSearchTable(SearchTable): + vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') + tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + status = tables.TemplateColumn(STATUS_LABEL) + role = tables.TemplateColumn(VLAN_ROLE_LINK) + + class Meta(SearchTable.Meta): + model = VLAN + fields = ('vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description') diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 12df44e6f..8b4c91d2f 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -4,26 +4,25 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.reverse import reverse -from django.db.models import Count from django.shortcuts import render from django.views.generic import View from circuits.filters import CircuitFilter, ProviderFilter from circuits.models import Circuit, Provider -from circuits.tables import CircuitTable, ProviderTable +from circuits.tables import CircuitSearchTable, ProviderSearchTable from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site -from dcim.tables import DeviceTable, DeviceTypeTable, RackTable, SiteTable +from dcim.tables import DeviceSearchTable, DeviceTypeSearchTable, RackSearchTable, SiteSearchTable from extras.models import UserAction from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF -from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable +from ipam.tables import AggregateSearchTable, IPAddressSearchTable, PrefixSearchTable, VLANSearchTable, VRFSearchTable from secrets.filters import SecretFilter from secrets.models import Secret -from secrets.tables import SecretTable +from secrets.tables import SecretSearchTable from tenancy.filters import TenantFilter from tenancy.models import Tenant -from tenancy.tables import TenantTable +from tenancy.tables import TenantSearchTable from .forms import SearchForm @@ -31,89 +30,85 @@ SEARCH_MAX_RESULTS = 15 SEARCH_TYPES = { # Circuits 'provider': { - 'queryset': Provider.objects.annotate(count_circuits=Count('circuits')), + 'queryset': Provider.objects.all(), 'filter': ProviderFilter, - 'table': ProviderTable, + 'table': ProviderSearchTable, 'url': 'circuits:provider_list', }, 'circuit': { - 'queryset': Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related( - 'terminations__site' - ), + 'queryset': Circuit.objects.select_related('type', 'provider', 'tenant'), 'filter': CircuitFilter, - 'table': CircuitTable, + 'table': CircuitSearchTable, 'url': 'circuits:circuit_list', }, # DCIM 'site': { 'queryset': Site.objects.select_related('region', 'tenant'), 'filter': SiteFilter, - 'table': SiteTable, + 'table': SiteSearchTable, 'url': 'dcim:site_list', }, 'rack': { - 'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type').annotate(device_count=Count('devices', distinct=True)), + 'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'), 'filter': RackFilter, - 'table': RackTable, + 'table': RackSearchTable, 'url': 'dcim:rack_list', }, 'devicetype': { - 'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')), + 'queryset': DeviceType.objects.select_related('manufacturer'), 'filter': DeviceTypeFilter, - 'table': DeviceTypeTable, + 'table': DeviceTypeSearchTable, 'url': 'dcim:devicetype_list', }, 'device': { - 'queryset': Device.objects.select_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6' - ), + 'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'), 'filter': DeviceFilter, - 'table': DeviceTable, + 'table': DeviceSearchTable, 'url': 'dcim:device_list', }, # IPAM 'vrf': { 'queryset': VRF.objects.select_related('tenant'), 'filter': VRFFilter, - 'table': VRFTable, + 'table': VRFSearchTable, 'url': 'ipam:vrf_list', }, 'aggregate': { 'queryset': Aggregate.objects.select_related('rir'), 'filter': AggregateFilter, - 'table': AggregateTable, + 'table': AggregateSearchTable, 'url': 'ipam:aggregate_list', }, 'prefix': { 'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'filter': PrefixFilter, - 'table': PrefixTable, + 'table': PrefixSearchTable, 'url': 'ipam:prefix_list', }, 'ipaddress': { 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'), 'filter': IPAddressFilter, - 'table': IPAddressTable, + 'table': IPAddressSearchTable, 'url': 'ipam:ipaddress_list', }, 'vlan': { - 'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes'), + 'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'), 'filter': VLANFilter, - 'table': VLANTable, + 'table': VLANSearchTable, 'url': 'ipam:vlan_list', }, # Secrets 'secret': { 'queryset': Secret.objects.select_related('role', 'device'), 'filter': SecretFilter, - 'table': SecretTable, + 'table': SecretSearchTable, 'url': 'secrets:secret_list', }, # Tenancy 'tenant': { 'queryset': Tenant.objects.select_related('group'), 'filter': TenantFilter, - 'table': TenantTable, + 'table': TenantSearchTable, 'url': 'tenancy:tenant_list', }, } @@ -180,17 +175,21 @@ class SearchView(View): obj_types = SEARCH_TYPES.keys() for obj_type in obj_types: + queryset = SEARCH_TYPES[obj_type]['queryset'] - filter = SEARCH_TYPES[obj_type]['filter'] + filter_cls = SEARCH_TYPES[obj_type]['filter'] table = SEARCH_TYPES[obj_type]['table'] url = SEARCH_TYPES[obj_type]['url'] - filtered_queryset = filter({'q': form.cleaned_data['q']}, queryset=queryset).qs - total_count = filtered_queryset.count() - if total_count: + + # Construct the results table for this object type + filtered_queryset = filter_cls({'q': form.cleaned_data['q']}, queryset=queryset).qs + table = table(filtered_queryset) + table.paginate(per_page=SEARCH_MAX_RESULTS) + + if table.page: results.append({ 'name': queryset.model._meta.verbose_name_plural, - 'table': table(filtered_queryset[:SEARCH_MAX_RESULTS]), - 'total': total_count, + 'table': table, 'url': '{}?q={}'.format(reverse(url), form.cleaned_data['q']) }) diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 2fb6d9bbe..15e003d8f 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, SearchTable, ToggleColumn from .models import SecretRole, Secret @@ -36,11 +36,15 @@ class SecretRoleTable(BaseTable): class SecretTable(BaseTable): pk = ToggleColumn() - device = tables.LinkColumn('secrets:secret', args=[Accessor('pk')], verbose_name='Device') - role = tables.Column(verbose_name='Role') - name = tables.Column(verbose_name='Name') - last_updated = tables.DateTimeColumn(verbose_name='Last updated') + device = tables.LinkColumn() class Meta(BaseTable.Meta): model = Secret fields = ('pk', 'device', 'role', 'name', 'last_updated') + + +class SecretSearchTable(SearchTable): + + class Meta(SearchTable.Meta): + model = Secret + fields = ('device', 'role', 'name', 'last_updated') diff --git a/netbox/templates/search.html b/netbox/templates/search.html index 5e72e3396..e0c60003a 100644 --- a/netbox/templates/search.html +++ b/netbox/templates/search.html @@ -1,6 +1,8 @@ {% extends '_base.html' %} {% load form_helpers %} +{% block title %}Search{% endblock %} + {% block content %} {% if request.GET.q %} {% include 'search_form.html' with search_form=form %} @@ -8,33 +10,31 @@
{% for obj_type in results %} -

{{ obj_type.name|title }}

- {% include 'table.html' with table=obj_type.table %} - {% if obj_type.total > obj_type.table.rows|length %} +

{{ obj_type.name }}

+ {% include 'table.html' with table=obj_type.table hide_paginator=True %} + {% if obj_type.table.page.has_next %} - All {{ obj_type.total }} results + All {{ obj_type.table.page.paginator.count }} results {% endif %}
{% endfor %}
- {% if results %} -
-
- Search Results -
-
- {% for obj_type in results %} - - {{ obj_type.name|title }} - {{ obj_type.total }} - - {% endfor %} -
+
+
+ Search Results
- {% endif %} +
+ {% for obj_type in results %} + + {{ obj_type.name }} + {{ obj_type.table.page.paginator.count }} + + {% endfor %} +
+
{% else %} diff --git a/netbox/templates/table.html b/netbox/templates/table.html index 8782f0796..4792f8e68 100644 --- a/netbox/templates/table.html +++ b/netbox/templates/table.html @@ -4,5 +4,7 @@ {# Extends the stock django_tables2 template to provide custom formatting of the pagination controls #} {% block pagination %} - {% include 'paginator.html' %} + {% if not hide_paginator %} + {% include 'paginator.html' %} + {% endif %} {% endblock pagination %} diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 0ce0d577c..bacb4c12f 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, SearchTable, ToggleColumn from .models import Tenant, TenantGroup @@ -36,10 +36,15 @@ class TenantGroupTable(BaseTable): class TenantTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn('tenancy:tenant', args=[Accessor('slug')], verbose_name='Name') - group = tables.Column(verbose_name='Group') - description = tables.Column(verbose_name='Description') + name = tables.LinkColumn() class Meta(BaseTable.Meta): model = Tenant fields = ('pk', 'name', 'group', 'description') + + +class TenantSearchTable(SearchTable): + + class Meta(SearchTable.Meta): + model = Tenant + fields = ('name', 'group', 'description') diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index a53227937..279a7310b 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -4,7 +4,9 @@ from django.utils.safestring import mark_safe class BaseTable(tables.Table): - + """ + Default table for object lists + """ def __init__(self, *args, **kwargs): super(BaseTable, self).__init__(*args, **kwargs) @@ -18,6 +20,17 @@ class BaseTable(tables.Table): } +class SearchTable(tables.Table): + """ + Default table for search results + """ + class Meta: + attrs = { + 'class': 'table table-hover', + } + orderable = False + + class ToggleColumn(tables.CheckBoxColumn): def __init__(self, *args, **kwargs): From 66615f1a96826c2cf7500e8e9ef4ff0ee6993890 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Mar 2017 16:45:25 -0400 Subject: [PATCH 28/65] Prettied things up a bit --- netbox/netbox/views.py | 1 - netbox/project-static/css/base.css | 3 +++ netbox/templates/panel_table.html | 5 +++-- netbox/templates/search.html | 14 +++++++------- netbox/utilities/tables.py | 2 +- netbox/utilities/templatetags/helpers.py | 7 +++++++ 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 8b4c91d2f..0e0b9e50c 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -199,7 +199,6 @@ class SearchView(View): }) - class APIRootView(APIView): _ignore_model_permissions = True exclude_from_schema = True diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 11ea04b72..db37c3535 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -92,6 +92,9 @@ tfoot td { table.attr-table td:nth-child(1) { width: 25%; } +.table-headings th { + background-color: #f5f5f5; +} /* Paginator */ div.paginator { diff --git a/netbox/templates/panel_table.html b/netbox/templates/panel_table.html index 0d44d7ea9..cb807eeb6 100644 --- a/netbox/templates/panel_table.html +++ b/netbox/templates/panel_table.html @@ -20,6 +20,7 @@ {% endblock %} {% block pagination %} - {% include 'paginator.html' %} + {% if not hide_paginator %} + {% include 'paginator.html' %} + {% endif %} {% endblock pagination %} - diff --git a/netbox/templates/search.html b/netbox/templates/search.html index e0c60003a..afd4293ca 100644 --- a/netbox/templates/search.html +++ b/netbox/templates/search.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load helpers %} {% load form_helpers %} {% block title %}Search{% endblock %} @@ -10,12 +11,12 @@
{% for obj_type in results %} -

{{ obj_type.name }}

- {% include 'table.html' with table=obj_type.table hide_paginator=True %} +

{{ obj_type.name|bettertitle }}

+ {% include 'panel_table.html' with table=obj_type.table hide_paginator=True %} {% if obj_type.table.page.has_next %} - - All {{ obj_type.table.page.paginator.count }} results + + See all {{ obj_type.table.page.paginator.count }} results {% endif %}
@@ -28,8 +29,8 @@
{% for obj_type in results %} - - {{ obj_type.name }} + + {{ obj_type.name|bettertitle }} {{ obj_type.table.page.paginator.count }} {% endfor %} @@ -41,7 +42,6 @@

No results found

{% endif %} {% else %} - {# Larger search form #}
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 279a7310b..1c5eab2a6 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -26,7 +26,7 @@ class SearchTable(tables.Table): """ class Meta: attrs = { - 'class': 'table table-hover', + 'class': 'table table-hover table-headings', } orderable = False diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 164aa24b2..3c5770cb3 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -51,6 +51,13 @@ def startswith(value, arg): """ return str(value).startswith(arg) +@register.filter() +def bettertitle(value): + """ + Alternative to the builtin title(); uppercases words without replacing letters that are already uppercase. + """ + return ' '.join([w[0].upper() + w[1:] for w in value.split()]) + # # Tags From 998f89216e686e4d6d36a6352ce2bea458e40356 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Mar 2017 09:56:47 -0400 Subject: [PATCH 29/65] Updated the docstring for Device --- netbox/dcim/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f9703898f..d0971b556 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -904,11 +904,11 @@ class Device(CreatedUpdatedModel, CustomFieldModel): A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. - Each Device must be assigned to a Rack, although associating it with a particular rack face or unit is optional (for - example, vertically mounted PDUs do not consume rack units). + Each Device must be assigned to a site, and optionally to a rack within that site. Associating a device with a + particular rack face or unit is optional (for example, vertically mounted PDUs do not consume rack units). - When a new Device is created, console/power/interface components are created along with it as dictated by the - component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the + When a new Device is created, console/power/interface/device bay components are created along with it as dictated + by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the creation of a Device. """ device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT) From f6ea09e581e6bc7ab0ab6d748d2dcec1dbb9bf29 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Mar 2017 10:27:20 -0400 Subject: [PATCH 30/65] Removed duplicate 'Regions' section --- docs/data-model/dcim.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index 74dffb320..e4082ebc7 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -10,10 +10,6 @@ Sites can be assigned an optional facility ID to identify the actual facility ho Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. -### Regions - -Sites can optionally be arranged by geographic region. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. - --- # Racks From 3ed3e93b25f928f50ae5b23518678084bfb0f0bd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Mar 2017 15:40:00 -0400 Subject: [PATCH 31/65] Appended a version flag to all CSS/JS references to invalidate browser cache after an upgrade --- netbox/templates/_base.html | 4 ++-- netbox/templates/circuits/circuittermination_edit.html | 2 +- netbox/templates/circuits/provider.html | 2 +- netbox/templates/dcim/consoleport_connect.html | 2 +- netbox/templates/dcim/consoleserverport_connect.html | 2 +- netbox/templates/dcim/device.html | 4 ++-- netbox/templates/dcim/interfaceconnection_edit.html | 2 +- netbox/templates/dcim/poweroutlet_connect.html | 2 +- netbox/templates/dcim/powerport_connect.html | 2 +- netbox/templates/dcim/site.html | 2 +- netbox/templates/ipam/ipaddress_assign.html | 2 +- netbox/templates/ipam/ipaddress_edit.html | 2 +- netbox/templates/secrets/secret.html | 2 +- netbox/templates/secrets/secret_edit.html | 2 +- netbox/templates/secrets/secret_import.html | 2 +- netbox/templates/users/userkey_edit.html | 2 +- 16 files changed, 18 insertions(+), 18 deletions(-) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 0ebc4f5b4..7e2555f18 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -7,7 +7,7 @@ - + @@ -308,7 +308,7 @@ - + {% block javascript %}{% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 8ccc05de2..22e02c4ee 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -90,5 +90,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index d6843358c..3cdeea36f 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -199,5 +199,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/consoleport_connect.html b/netbox/templates/dcim/consoleport_connect.html index f896ebbd5..a04a4674b 100644 --- a/netbox/templates/dcim/consoleport_connect.html +++ b/netbox/templates/dcim/consoleport_connect.html @@ -58,5 +58,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_connect.html b/netbox/templates/dcim/consoleserverport_connect.html index 08850d1ed..6ba944b59 100644 --- a/netbox/templates/dcim/consoleserverport_connect.html +++ b/netbox/templates/dcim/consoleserverport_connect.html @@ -58,5 +58,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d38f60cb3..2df324a69 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -629,6 +629,6 @@ $(".interface-toggle").click(function() { return toggleConnection($(this), "dcim/interface-connections/"); }); - - + + {% endblock %} diff --git a/netbox/templates/dcim/interfaceconnection_edit.html b/netbox/templates/dcim/interfaceconnection_edit.html index ad0335398..488c11472 100644 --- a/netbox/templates/dcim/interfaceconnection_edit.html +++ b/netbox/templates/dcim/interfaceconnection_edit.html @@ -93,5 +93,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_connect.html b/netbox/templates/dcim/poweroutlet_connect.html index 927ed7b71..6fcc3e858 100644 --- a/netbox/templates/dcim/poweroutlet_connect.html +++ b/netbox/templates/dcim/poweroutlet_connect.html @@ -58,5 +58,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/powerport_connect.html b/netbox/templates/dcim/powerport_connect.html index 9e7f1fae9..f77a0e352 100644 --- a/netbox/templates/dcim/powerport_connect.html +++ b/netbox/templates/dcim/powerport_connect.html @@ -58,5 +58,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 772474155..b83139b53 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -260,5 +260,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/ipam/ipaddress_assign.html b/netbox/templates/ipam/ipaddress_assign.html index 4b27eae0b..03ec181ff 100644 --- a/netbox/templates/ipam/ipaddress_assign.html +++ b/netbox/templates/ipam/ipaddress_assign.html @@ -68,5 +68,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 7226a75fc..c0a210cc1 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -79,5 +79,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 391bc8868..016015cbe 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -97,5 +97,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index a83024fb4..0ed5cc875 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -70,5 +70,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html index 0a9a11c69..76022fac1 100644 --- a/netbox/templates/secrets/secret_import.html +++ b/netbox/templates/secrets/secret_import.html @@ -68,5 +68,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/users/userkey_edit.html b/netbox/templates/users/userkey_edit.html index 45bac1938..c590f4423 100644 --- a/netbox/templates/users/userkey_edit.html +++ b/netbox/templates/users/userkey_edit.html @@ -56,5 +56,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} From b643939cc4197fd288b9ef33152cc9ae9de6610f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Mar 2017 21:55:57 -0400 Subject: [PATCH 32/65] Initial work on #152: Image attachments --- netbox/dcim/models.py | 3 +- netbox/dcim/urls.py | 3 + netbox/extras/forms.py | 12 +++- .../migrations/0005_add_imageattachment.py | 34 ++++++++++++ netbox/extras/models.py | 55 +++++++++++++++++++ netbox/extras/urls.py | 12 ++++ netbox/extras/views.py | 30 ++++++++++ netbox/media/image-attachments/.gitignore | 2 + netbox/netbox/settings.py | 7 ++- netbox/netbox/urls.py | 3 + netbox/templates/dcim/rack.html | 49 +++++++++++++++++ netbox/templates/utilities/obj_edit.html | 2 +- netbox/utilities/forms.py | 11 ++-- netbox/utilities/views.py | 2 +- requirements.txt | 1 + 15 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 netbox/extras/migrations/0005_add_imageattachment.py create mode 100644 netbox/extras/urls.py create mode 100644 netbox/extras/views.py create mode 100644 netbox/media/image-attachments/.gitignore diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d0971b556..76d8c7fbc 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -15,7 +15,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist from django.utils.encoding import python_2_unicode_compatible from circuits.models import Circuit -from extras.models import CustomFieldModel, CustomField, CustomFieldValue +from extras.models import CustomFieldModel, CustomField, CustomFieldValue, ImageAttachment from extras.rpc import RPC_CLIENTS from tenancy.models import Tenant from utilities.fields import ColorField, NullableCharField @@ -375,6 +375,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): help_text='Units are numbered top-to-bottom') comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + images = GenericRelation(ImageAttachment) objects = RackManager() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index b4731df33..9e35a1d85 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -3,6 +3,8 @@ from django.conf.urls import url from ipam.views import ServiceEditView from secrets.views import secret_add +from extras.views import ImageAttachmentEditView +from .models import Rack from . import views @@ -49,6 +51,7 @@ urlpatterns = [ url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'), + url(r'^racks/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index b4549fcf1..d85697c8d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,9 +3,10 @@ from collections import OrderedDict from django import forms from django.contrib.contenttypes.models import ContentType -from utilities.forms import BulkEditForm, LaxURLField +from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField from .models import ( - CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue + CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue, + ImageAttachment, ) @@ -158,3 +159,10 @@ class CustomFieldFilterForm(forms.Form): for name, field in custom_fields: field.required = False self.fields[name] = field + + +class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = ImageAttachment + fields = ['name', 'image'] diff --git a/netbox/extras/migrations/0005_add_imageattachment.py b/netbox/extras/migrations/0005_add_imageattachment.py new file mode 100644 index 000000000..23ed8b786 --- /dev/null +++ b/netbox/extras/migrations/0005_add_imageattachment.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-03-30 21:09 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import extras.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0004_topologymap_change_comma_to_semicolon'), + ] + + operations = [ + migrations.CreateModel( + name='ImageAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('image', models.ImageField(height_field=b'image_height', upload_to=extras.models.image_upload, width_field=b'image_width')), + ('image_height', models.PositiveSmallIntegerField()), + ('image_width', models.PositiveSmallIntegerField()), + ('name', models.CharField(blank=True, max_length=50)), + ('created', models.DateTimeField(auto_now_add=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['name'], + }, + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 3101757d6..cdf2af31c 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -359,6 +359,61 @@ class TopologyMap(models.Model): return graph.pipe(format=img_format) +# +# Image attachments +# + +def image_upload(instance, filename): + + path = 'image-attachments/' + + # Rename the file to the provided name, if any. Attempt to preserve the file extension. + extension = filename.rsplit('.')[-1] + if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']: + filename = '.'.join([instance.name, extension]) + elif instance.name: + filename = instance.name + + return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) + + +@python_2_unicode_compatible +class ImageAttachment(models.Model): + """ + An uploaded image which is associated with an object. + """ + content_type = models.ForeignKey(ContentType) + object_id = models.PositiveIntegerField() + obj = GenericForeignKey('content_type', 'object_id') + image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width') + image_height = models.PositiveSmallIntegerField() + image_width = models.PositiveSmallIntegerField() + name = models.CharField(max_length=50, blank=True) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['name'] + + def __str__(self): + if self.name: + return self.name + filename = self.image.name.rsplit('/', 1)[-1] + return filename.split('_', 2)[2] + + def delete(self, *args, **kwargs): + + _name = self.image.name + + super(ImageAttachment, self).delete(*args, **kwargs) + + # Delete file from disk + self.image.delete(save=False) + + # Deleting the file erases its name. We restore the image's filename here in case we still need to reference it + # before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.) + self.image.name = _name + + # # User actions # diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py new file mode 100644 index 000000000..6e0e91a0d --- /dev/null +++ b/netbox/extras/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import url + +from extras import views + + +urlpatterns = [ + + # Image attachments + url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), + url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + +] diff --git a/netbox/extras/views.py b/netbox/extras/views.py new file mode 100644 index 000000000..af0a98745 --- /dev/null +++ b/netbox/extras/views.py @@ -0,0 +1,30 @@ +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.shortcuts import get_object_or_404 + +from utilities.views import ObjectDeleteView, ObjectEditView +from .forms import ImageAttachmentForm +from .models import ImageAttachment + + +class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'extras.change_imageattachment' + model = ImageAttachment + form_class = ImageAttachmentForm + + def alter_obj(self, imageattachment, request, args, kwargs): + if not imageattachment.pk: + # Assign the parent object based on URL kwargs + model = kwargs.get('model') + imageattachment.obj = get_object_or_404(model, pk=kwargs['object_id']) + return imageattachment + + def get_return_url(self, imageattachment): + return imageattachment.obj.get_absolute_url() + + +class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_imageattachment' + model = ImageAttachment + + def get_return_url(self, imageattachment): + return imageattachment.obj.get_absolute_url() diff --git a/netbox/media/image-attachments/.gitignore b/netbox/media/image-attachments/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/netbox/media/image-attachments/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index aeec93f06..4a486c434 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -153,6 +153,7 @@ TEMPLATES = [ 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', + 'django.template.context_processors.media', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'utilities.context_processors.settings', @@ -167,19 +168,21 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') USE_X_FORWARDED_HOST = True # Internationalization -# https://docs.djangoproject.com/en/1.8/topics/i18n/ LANGUAGE_CODE = 'en-us' USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.8/howto/static-files/ STATIC_ROOT = BASE_DIR + '/static/' STATIC_URL = '/{}static/'.format(BASE_PATH) STATICFILES_DIRS = ( os.path.join(BASE_DIR, "project-static"), ) +# Media +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + # Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.) DATA_UPLOAD_MAX_NUMBER_FIELDS = None diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 724ab3090..8a81e3ebb 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,6 +1,7 @@ from django.conf import settings from django.conf.urls import include, url from django.contrib import admin +from django.views.static import serve from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500 from users.views import login, logout @@ -21,6 +22,7 @@ _patterns = [ # Apps url(r'^circuits/', include('circuits.urls', namespace='circuits')), url(r'^dcim/', include('dcim.urls', namespace='dcim')), + url(r'^extras/', include('extras.urls', namespace='extras')), url(r'^ipam/', include('ipam.urls', namespace='ipam')), url(r'^secrets/', include('secrets.urls', namespace='secrets')), url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')), @@ -48,6 +50,7 @@ if settings.DEBUG: import debug_toolbar _patterns += [ url(r'^__debug__/', include(debug_toolbar.urls)), + url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), ] # Prepend BASE_PATH diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index d6529c2a4..4ef8277e2 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -197,6 +197,55 @@ {% endif %}
+
+
+ Images +
+ {% if rack.images.all %} + + + + + + + + {% for attachment in rack.images.all %} + + + + + + + {% endfor %} +
NameSizeCreated
+ + {{ attachment }} + {{ attachment.image.size|filesizeformat }}{{ attachment.created }} + {% if perms.extras.change_imageattachment %} + + + + {% endif %} + {% if perms.extras.delete_imageattachment %} + + + + {% endif %} +
+ {% else %} +
+ None +
+ {% endif %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
Reservations diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 21ec67cef..07a39634d 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -2,7 +2,7 @@ {% load form_helpers %} {% block content %} - + {% csrf_token %} {% for field in form.hidden_fields %} {{ field }} diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index dd6235f45..8285a7b96 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -425,12 +425,13 @@ class BootstrapMixin(forms.BaseForm): def __init__(self, *args, **kwargs): super(BootstrapMixin, self).__init__(*args, **kwargs) + + exempt_widgets = [forms.CheckboxInput, forms.ClearableFileInput, forms.FileInput, forms.RadioSelect] + for field_name, field in self.fields.items(): - if type(field.widget) not in [type(forms.CheckboxInput()), type(forms.RadioSelect())]: - try: - field.widget.attrs['class'] += ' form-control' - except KeyError: - field.widget.attrs['class'] = 'form-control' + if field.widget.__class__ not in exempt_widgets: + css = field.widget.attrs.get('class', '') + field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip() if field.required: field.widget.attrs['required'] = 'required' if 'placeholder' not in field.widget.attrs: diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index f38d9a0ab..ba29afbe1 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -174,7 +174,7 @@ class ObjectEditView(View): obj = self.get_object(kwargs) obj = self.alter_obj(obj, request, args, kwargs) - form = self.form_class(request.POST, instance=obj) + form = self.form_class(request.POST, request.FILES, instance=obj) if form.is_valid(): obj = form.save(commit=False) diff --git a/requirements.txt b/requirements.txt index b732ab1b1..aa361641b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ natsort>=5.0.0 ncclient==0.5.2 netaddr==0.7.18 paramiko>=2.0.0 +Pillow>=4.0.0 psycopg2>=2.6.1 py-gfm>=0.1.3 pycrypto>=2.6.1 From 1c38f705a746e8c3f1ba42569357fdf42b7062bf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Mar 2017 11:13:37 -0400 Subject: [PATCH 33/65] Fixes #1021: Corrected evaluation of API token expiration time --- netbox/users/models.py | 6 +++--- netbox/utilities/api.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/users/models.py b/netbox/users/models.py index 0dd303104..16c5005ef 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -39,6 +39,6 @@ class Token(models.Model): @property def is_expired(self): - if self.expires is not None and timezone.now() > self.expires: - return True - return False + if self.expires is None or timezone.now() < self.expires: + return False + return True diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index ca4384f08..791a504a6 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -30,7 +30,7 @@ class TokenAuthentication(authentication.TokenAuthentication): raise exceptions.AuthenticationFailed("Invalid token") # Enforce the Token's expiration time, if one has been set. - if token.expires and not token.is_expired: + if token.is_expired: raise exceptions.AuthenticationFailed("Token expired") if not token.user.is_active: From 50d7fd776fd49ad28be44935145762052136c55f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Mar 2017 15:19:44 -0400 Subject: [PATCH 34/65] Added image attachments to sites and devices --- netbox/dcim/models.py | 2 ++ netbox/dcim/urls.py | 4 ++- netbox/templates/dcim/device.html | 14 ++++++++ netbox/templates/dcim/rack.html | 37 +-------------------- netbox/templates/dcim/site.html | 14 ++++++++ netbox/templates/inc/image_attachments.html | 36 ++++++++++++++++++++ 6 files changed, 70 insertions(+), 37 deletions(-) create mode 100644 netbox/templates/inc/image_attachments.html diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 76d8c7fbc..fae85e2c1 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -254,6 +254,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail") comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + images = GenericRelation(ImageAttachment) objects = SiteManager() @@ -933,6 +934,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): blank=True, null=True, verbose_name='Primary IPv6') comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + images = GenericRelation(ImageAttachment) objects = DeviceManager() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 9e35a1d85..7e9f680de 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -4,7 +4,7 @@ from ipam.views import ServiceEditView from secrets.views import secret_add from extras.views import ImageAttachmentEditView -from .models import Rack +from .models import Device, Rack, Site from . import views @@ -24,6 +24,7 @@ urlpatterns = [ url(r'^sites/(?P[\w-]+)/$', views.site, name='site'), url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), + url(r'^sites/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'), @@ -120,6 +121,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), url(r'^devices/(?P\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'), + url(r'^devices/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 2df324a69..4e634d243 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -326,6 +326,20 @@ {% endif %}
+
+
+ Images +
+ {% include 'inc/image_attachments.html' with images=device.images.all %} + {% if perms.extras.add_imageattachment %} + + {% endif %} +
Related Devices diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 4ef8277e2..22ae617d3 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -201,42 +201,7 @@
Images
- {% if rack.images.all %} - - - - - - - - {% for attachment in rack.images.all %} - - - - - - - {% endfor %} -
NameSizeCreated
- - {{ attachment }} - {{ attachment.image.size|filesizeformat }}{{ attachment.created }} - {% if perms.extras.change_imageattachment %} - - - - {% endif %} - {% if perms.extras.delete_imageattachment %} - - - - {% endif %} -
- {% else %} -
- None -
- {% endif %} + {% include 'inc/image_attachments.html' with images=rack.images.all %} {% if perms.extras.add_imageattachment %} {% endif %}
+
Topology Maps diff --git a/netbox/templates/inc/image_attachments.html b/netbox/templates/inc/image_attachments.html new file mode 100644 index 000000000..0f922f3c6 --- /dev/null +++ b/netbox/templates/inc/image_attachments.html @@ -0,0 +1,36 @@ +{% if images %} + + + + + + + + {% for attachment in images %} + + + + + + + {% endfor %} +
NameSizeCreated
+ + {{ attachment }} + {{ attachment.image.size|filesizeformat }}{{ attachment.created }} + {% if perms.extras.change_imageattachment %} + + + + {% endif %} + {% if perms.extras.delete_imageattachment %} + + + + {% endif %} +
+{% else %} +
+ None +
+{% endif %} From 6bbdc2bae168846289bfdc18eb844f13f1c99d52 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Mar 2017 15:51:17 -0400 Subject: [PATCH 35/65] Enable serving static media through Django --- netbox/netbox/urls.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 8a81e3ebb..8e4b5918d 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -38,6 +38,9 @@ _patterns = [ url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), url(r'^api/docs/', include('rest_framework_swagger.urls')), + # Serving static media in Django to pipe it through LoginRequiredMiddleware + url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), + # Error testing url(r'^500/$', trigger_500), @@ -50,7 +53,6 @@ if settings.DEBUG: import debug_toolbar _patterns += [ url(r'^__debug__/', include(debug_toolbar.urls)), - url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), ] # Prepend BASE_PATH From a67fc64afb2814450d0f55e88dbc33bfb3fdcd07 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 11:04:17 -0400 Subject: [PATCH 36/65] Fixes #1025: Applied missing API view filters --- netbox/dcim/api/views.py | 7 ++++--- netbox/dcim/filters.py | 4 ++-- netbox/extras/api/views.py | 1 - netbox/ipam/api/views.py | 2 ++ netbox/secrets/api/views.py | 4 ++-- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 77344f4fa..1b6da12ba 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,4 +1,4 @@ -from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import detail_route from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ViewSet @@ -38,8 +38,8 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet): class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Site.objects.select_related('region', 'tenant') serializer_class = serializers.SiteSerializer - filter_class = filters.SiteFilter write_serializer_class = serializers.WritableSiteSerializer + filter_class = filters.SiteFilter @detail_route() def graphs(self, request, pk=None): @@ -59,8 +59,8 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet): class RackGroupViewSet(WritableSerializerMixin, ModelViewSet): queryset = RackGroup.objects.select_related('site') serializer_class = serializers.RackGroupSerializer - filter_class = filters.RackGroupFilter write_serializer_class = serializers.WritableRackGroupSerializer + filter_class = filters.RackGroupFilter # @@ -135,6 +135,7 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = DeviceType.objects.select_related('manufacturer') serializer_class = serializers.DeviceTypeSerializer write_serializer_class = serializers.WritableDeviceTypeSerializer + filter_class = filters.DeviceTypeFilter # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 6eab5ae34..720cd0161 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -240,7 +240,7 @@ class InterfaceTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = InterfaceTemplate - fields = ['name'] + fields = ['name', 'form_factor'] class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): @@ -448,7 +448,7 @@ class InterfaceFilter(DeviceComponentFilterSet): class Meta: model = Interface - fields = ['name'] + fields = ['name', 'form_factor'] def filter_type(self, queryset, name, value): value = value.strip().lower() diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index fab1ccdb5..09d72fe0b 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -51,7 +51,6 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet): class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet): queryset = ExportTemplate.objects.all() serializer_class = serializers.ExportTemplateSerializer - # write_serializer_class = serializers.WritableExportTemplateSerializer filter_class = filters.ExportTemplateFilter diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 3bd1f71c3..611931edf 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -34,6 +34,7 @@ class RoleViewSet(ModelViewSet): class RIRViewSet(ModelViewSet): queryset = RIR.objects.all() serializer_class = serializers.RIRSerializer + filter_class = filters.RIRFilter # @@ -99,3 +100,4 @@ class ServiceViewSet(WritableSerializerMixin, ModelViewSet): queryset = Service.objects.select_related('device') serializer_class = serializers.ServiceSerializer write_serializer_class = serializers.WritableServiceSerializer + filter_class = filters.ServiceFilter diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 601f7aece..0e6314e17 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -8,8 +8,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet, ViewSet +from secrets import filters from secrets.exceptions import InvalidKey -from secrets.filters import SecretFilter from secrets.models import Secret, SecretRole, SessionKey, UserKey from utilities.api import WritableSerializerMixin from . import serializers @@ -43,7 +43,7 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet): ) serializer_class = serializers.SecretSerializer write_serializer_class = serializers.WritableSecretSerializer - filter_class = SecretFilter + filter_class = filters.SecretFilter master_key = None From 2c1fa628a24c240cc7ef87bf22605a78b159dd47 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 14:00:15 -0400 Subject: [PATCH 37/65] Implemented API endpoints for ImageAttachments --- netbox/extras/api/serializers.py | 57 ++++++++++++++++++- netbox/extras/api/urls.py | 3 + netbox/extras/api/views.py | 8 ++- .../migrations/0005_add_imageattachment.py | 2 +- netbox/extras/models.py | 2 +- netbox/utilities/api.py | 18 +++++- 6 files changed, 83 insertions(+), 7 deletions(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a5c139c08..08da93aa0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,9 +1,14 @@ from rest_framework import serializers -from dcim.api.serializers import NestedSiteSerializer -from extras.models import ACTION_CHOICES, Graph, GRAPH_TYPE_CHOICES, ExportTemplate, TopologyMap, UserAction +from django.core.exceptions import ObjectDoesNotExist + +from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer +from dcim.models import Device, Rack, Site +from extras.models import ( + ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction, +) from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer +from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer # @@ -71,6 +76,52 @@ class WritableTopologyMapSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] +# +# Image attachments +# + +class ImageAttachmentSerializer(serializers.ModelSerializer): + parent = serializers.SerializerMethodField() + + class Meta: + model = ImageAttachment + fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created'] + + def get_parent(self, obj): + + # Static mapping of models to their nested serializers + if isinstance(obj.parent, Device): + serializer = NestedDeviceSerializer + elif isinstance(obj.parent, Rack): + serializer = NestedRackSerializer + elif isinstance(obj.parent, Site): + serializer = NestedSiteSerializer + else: + raise Exception("Unexpected type of parent object for ImageAttachment") + + return serializer(obj.parent, context={'request': self.context['request']}).data + + +class WritableImageAttachmentSerializer(serializers.ModelSerializer): + content_type = ContentTypeFieldSerializer() + + class Meta: + model = ImageAttachment + fields = ['id', 'content_type', 'object_id', 'name', 'image'] + + def validate(self, data): + + # Validate that the parent object exists + try: + data['content_type'].get_object_for_this_type(id=data['object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) + ) + + return data + + # # User actions # diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 1623dcdeb..85ed93a24 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -23,6 +23,9 @@ router.register(r'export-templates', views.ExportTemplateViewSet) # Topology maps router.register(r'topology-maps', views.TopologyMapViewSet) +# Image attachments +router.register(r'image-attachments', views.ImageAttachmentViewSet) + # Recent activity router.register(r'recent-activity', views.RecentActivityViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index fab1ccdb5..d5b05fab4 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -6,7 +6,7 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404 from extras import filters -from extras.models import ExportTemplate, Graph, TopologyMap, UserAction +from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction from utilities.api import WritableSerializerMixin from . import serializers @@ -81,6 +81,12 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): return response +class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ImageAttachment.objects.all() + serializer_class = serializers.ImageAttachmentSerializer + write_serializer_class = serializers.WritableImageAttachmentSerializer + + class RecentActivityViewSet(ReadOnlyModelViewSet): """ List all UserActions to provide a log of recent activity. diff --git a/netbox/extras/migrations/0005_add_imageattachment.py b/netbox/extras/migrations/0005_add_imageattachment.py index 23ed8b786..478762079 100644 --- a/netbox/extras/migrations/0005_add_imageattachment.py +++ b/netbox/extras/migrations/0005_add_imageattachment.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.10.6 on 2017-03-30 21:09 +# Generated by Django 1.10.6 on 2017-04-03 15:55 from __future__ import unicode_literals from django.db import migrations, models diff --git a/netbox/extras/models.py b/netbox/extras/models.py index cdf2af31c..9b31c3db4 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -384,7 +384,7 @@ class ImageAttachment(models.Model): """ content_type = models.ForeignKey(ContentType) object_id = models.PositiveIntegerField() - obj = GenericForeignKey('content_type', 'object_id') + parent = GenericForeignKey('content_type', 'object_id') image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width') image_height = models.PositiveSmallIntegerField() image_width = models.PositiveSmallIntegerField() diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index ca4384f08..b7dd61d0f 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -1,9 +1,10 @@ from django.conf import settings +from django.contrib.contenttypes.models import ContentType from rest_framework import authentication, exceptions from rest_framework.exceptions import APIException from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS -from rest_framework.serializers import Field +from rest_framework.serializers import Field, ValidationError from users.models import Token @@ -79,6 +80,21 @@ class ChoiceFieldSerializer(Field): return self._choices.get(data) +class ContentTypeFieldSerializer(Field): + """ + Represent a ContentType as '.' + """ + def to_representation(self, obj): + return "{}.{}".format(obj.app_label, obj.model) + + def to_internal_value(self, data): + app_label, model = data.split('.') + try: + return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) + except ContentType.DoesNotExist: + raise ValidationError("Invalid content type") + + class WritableSerializerMixin(object): """ Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT). From 8799a15e734b59628e5f9c8c4cdeccf158fd81db Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 14:26:20 -0400 Subject: [PATCH 38/65] What would we do without you, PEP8? --- netbox/utilities/templatetags/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 3c5770cb3..c74c2601f 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -51,6 +51,7 @@ def startswith(value, arg): """ return str(value).startswith(arg) + @register.filter() def bettertitle(value): """ From 05d3354570095ac070fd6173e347dc0e6d5b2eb7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 14:45:20 -0400 Subject: [PATCH 39/65] Fixes #1022: Record user actions when creating IP addresses in bulk --- netbox/extras/models.py | 9 +++++++-- netbox/utilities/views.py | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index f06d0aa29..b46c27f87 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -56,13 +56,15 @@ ACTION_EDIT = 3 ACTION_BULK_EDIT = 4 ACTION_DELETE = 5 ACTION_BULK_DELETE = 6 +ACTION_BULK_CREATE = 7 ACTION_CHOICES = ( (ACTION_CREATE, 'created'), + (ACTION_BULK_CREATE, 'bulk created'), (ACTION_IMPORT, 'imported'), (ACTION_EDIT, 'modified'), (ACTION_BULK_EDIT, 'bulk edited'), (ACTION_DELETE, 'deleted'), - (ACTION_BULK_DELETE, 'bulk deleted') + (ACTION_BULK_DELETE, 'bulk deleted'), ) @@ -328,6 +330,9 @@ class UserActionManager(models.Manager): def log_import(self, user, content_type, message=''): self.log_bulk_action(user, content_type, ACTION_IMPORT, message) + def log_bulk_create(self, user, content_type, message=''): + self.log_bulk_action(user, content_type, ACTION_BULK_CREATE, message) + def log_bulk_edit(self, user, content_type, message=''): self.log_bulk_action(user, content_type, ACTION_BULK_EDIT, message) @@ -358,7 +363,7 @@ class UserAction(models.Model): return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type) def icon(self): - if self.action in [ACTION_CREATE, ACTION_IMPORT]: + if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]: return mark_safe('') elif self.action in [ACTION_EDIT, ACTION_BULK_EDIT]: return mark_safe('') diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 08bf3f65a..010a60daa 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -334,7 +334,9 @@ class BulkAddView(View): form.add_error(None, e) if not form.errors: - messages.success(request, u"Added {} {}.".format(len(new_objs), self.model._meta.verbose_name_plural)) + msg = u"Added {} {}".format(len(new_objs), self.model._meta.verbose_name_plural) + messages.success(request, msg) + UserAction.objects.log_bulk_create(request.user, ContentType.objects.get_for_model(self.model), msg) if '_addanother' in request.POST: return redirect(request.path) return redirect(self.default_return_url) From 51725d3d9c848d606eb33e7f94fe52479a4044c4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Apr 2017 15:33:41 -0400 Subject: [PATCH 40/65] Added a search box to the navigation menu --- netbox/templates/_base.html | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 7e2555f18..a0c259dfb 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -240,13 +240,31 @@ {% endif %} {% endif %} + +
+ + + + +
+