Просмотр исходного кода

Merge pull request #5512 from netbox-community/develop

Release v2.10.2
Jeremy Stretch 5 лет назад
Родитель
Сommit
77e2b0e4ba
49 измененных файлов с 377 добавлено и 181 удалено
  1. 1 1
      .github/workflows/ci.yml
  2. 3 3
      README.md
  3. 1 4
      docs/additional-features/export-templates.md
  4. 2 2
      docs/configuration/optional-settings.md
  5. 1 1
      docs/installation/5-http-server.md
  6. 1 1
      docs/installation/6-ldap.md
  7. 4 0
      docs/plugins/development.md
  8. 1 1
      docs/release-notes/version-2.1.md
  9. 24 0
      docs/release-notes/version-2.10.md
  10. 1 1
      docs/release-notes/version-2.2.md
  11. 1 1
      docs/rest-api/overview.md
  12. 3 4
      netbox/circuits/api/views.py
  13. 6 6
      netbox/circuits/views.py
  14. 21 22
      netbox/dcim/api/views.py
  15. 9 3
      netbox/dcim/filters.py
  16. 21 2
      netbox/dcim/forms.py
  17. 2 0
      netbox/dcim/signals.py
  18. 2 1
      netbox/dcim/tables/devices.py
  19. 6 9
      netbox/dcim/tests/test_filters.py
  20. 18 18
      netbox/dcim/views.py
  21. 2 3
      netbox/extras/api/views.py
  22. 12 14
      netbox/extras/filters.py
  23. 35 5
      netbox/extras/tests/test_changelog.py
  24. 100 0
      netbox/extras/tests/test_customfields.py
  25. 4 4
      netbox/extras/views.py
  26. 8 9
      netbox/ipam/api/views.py
  27. 1 0
      netbox/ipam/forms.py
  28. 6 8
      netbox/ipam/tables.py
  29. 7 7
      netbox/ipam/views.py
  30. 2 2
      netbox/netbox/configuration.example.py
  31. 6 6
      netbox/netbox/constants.py
  32. 1 1
      netbox/netbox/settings.py
  33. 3 4
      netbox/project-static/js/interface_filtering.js
  34. 2 3
      netbox/secrets/api/views.py
  35. 1 0
      netbox/secrets/forms.py
  36. 3 3
      netbox/secrets/views.py
  37. 1 1
      netbox/templates/base.html
  38. 4 0
      netbox/templates/dcim/rack.html
  39. 3 1
      netbox/templates/dcim/rack_elevation_list.html
  40. 2 2
      netbox/templates/dcim/site.html
  41. 1 1
      netbox/templates/ipam/routetarget.html
  42. 3 0
      netbox/templates/secrets/inc/assigned_secrets.html
  43. 1 0
      netbox/templates/virtualization/virtualmachine.html
  44. 10 11
      netbox/tenancy/api/views.py
  45. 12 0
      netbox/utilities/templatetags/helpers.py
  46. 3 2
      netbox/utilities/utils.py
  47. 6 7
      netbox/virtualization/api/views.py
  48. 3 0
      netbox/virtualization/tables.py
  49. 7 7
      netbox/virtualization/views.py

+ 1 - 1
.github/workflows/ci.yml

@@ -1,5 +1,5 @@
 name: CI
-on: push
+on: [push, pull_request]
 jobs:
   build:
     runs-on: ubuntu-latest

+ 3 - 3
README.md

@@ -7,10 +7,10 @@ to address the needs of network and infrastructure engineers. It is intended to
 function as a domain-specific source of truth for network operations.
 
 NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
-Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
+Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
 complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
 
-The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
+The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/).
 
 Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions),
 or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
@@ -36,7 +36,7 @@ or join us in the **#netbox** Slack channel on [NetworkToCode](https://networkto
 
 ## Installation
 
-Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
+Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for
 instructions on installing NetBox. To upgrade NetBox, please download the
 [latest release](https://github.com/netbox-community/netbox/releases) and
 run `upgrade.sh`.

+ 1 - 4
docs/additional-features/export-templates.md

@@ -4,10 +4,7 @@ NetBox allows users to define custom templates that can be used when exporting o
 
 Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
 
-Export templates may be written in Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2.
-
-!!! warning
-    Support for Django's native templating logic will be removed in NetBox v2.10.
+Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
 
 The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
 

+ 2 - 2
docs/configuration/optional-settings.md

@@ -44,7 +44,7 @@ This defines custom content to be displayed on the login page above the login fo
 
 Default: None
 
-The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set:
+The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set:
 
 ```python
 BASE_PATH = 'netbox/'
@@ -318,7 +318,7 @@ NetBox will use these credentials when authenticating to remote devices via the
 
 ## NAPALM_ARGS
 
-A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](http://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
+A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
 
 ```python
 NAPALM_ARGS = {

+ 1 - 1
docs/installation/5-http-server.md

@@ -1,6 +1,6 @@
 # HTTP Server Setup
 
-This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
+This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
 
 !!! info
     For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.

+ 1 - 1
docs/installation/6-ldap.md

@@ -41,7 +41,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to
 REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
 ```
 
-Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
+Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/).
 
 ### General Server Configuration
 

+ 4 - 0
docs/plugins/development.md

@@ -63,11 +63,15 @@ setup(
     install_requires=[],
     packages=find_packages(),
     include_package_data=True,
+    zip_safe=False,
 )
 ```
 
 Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
 
+!!! note
+    `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)  
+
 ### Define a PluginConfig
 
 The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:

+ 1 - 1
docs/release-notes/version-2.1.md

@@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes/<pk>/available-ips/`. A
 
 #### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348))
 
-The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
+The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py.
 
 ### Enhancements
 

+ 24 - 0
docs/release-notes/version-2.10.md

@@ -1,5 +1,29 @@
 # NetBox v2.10
 
+## v2.10.2 (2020-12-21)
+
+### Enhancements
+
+* [#5489](https://github.com/netbox-community/netbox/issues/5489) - Add filters for type and width to racks list
+* [#5496](https://github.com/netbox-community/netbox/issues/5496) - Add form field to filter rack reservation by user
+
+### Bug Fixes
+
+* [#5254](https://github.com/netbox-community/netbox/issues/5254) - Require plugin authors to set zip_safe=False
+* [#5468](https://github.com/netbox-community/netbox/issues/5468) - Fix unlocking secrets from device/VM view
+* [#5473](https://github.com/netbox-community/netbox/issues/5473) - Fix alignment of rack names in elevations list
+* [#5478](https://github.com/netbox-community/netbox/issues/5478) - Fix display of route target description
+* [#5484](https://github.com/netbox-community/netbox/issues/5484) - Fix "tagged" indication in VLAN members list
+* [#5486](https://github.com/netbox-community/netbox/issues/5486) - Optimize retrieval of config context data for device/VM REST API views
+* [#5487](https://github.com/netbox-community/netbox/issues/5487) - Support filtering rack type/width with multiple values
+* [#5488](https://github.com/netbox-community/netbox/issues/5488) - Fix caching error when viewing cable trace after toggling cable status
+* [#5498](https://github.com/netbox-community/netbox/issues/5498) - Fix filtering rack reservations by username
+* [#5499](https://github.com/netbox-community/netbox/issues/5499) - Fix filtering of displayed device/VM interfaces by regex
+* [#5507](https://github.com/netbox-community/netbox/issues/5507) - Fix custom field data assignment via UI for IP addresses, secrets
+* [#5510](https://github.com/netbox-community/netbox/issues/5510) - Fix filtering by boolean custom fields
+
+---
+
 ## v2.10.1 (2020-12-15)
 
 ### Bug Fixes

+ 1 - 1
docs/release-notes/version-2.2.md

@@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre
 
 #### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511))
 
-Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
+Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info.
 
 ### Enhancements
 

+ 1 - 1
docs/rest-api/overview.md

@@ -2,7 +2,7 @@
 
 ## What is a REST API?
 
-REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](http://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
+REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
 
 * `GET`: Retrieve an object or list of objects
 * `POST`: Create an object

+ 3 - 4
netbox/circuits/api/views.py

@@ -1,5 +1,4 @@
 from django.db.models import Prefetch
-from django.db.models.functions import Coalesce
 from rest_framework.routers import APIRootView
 
 from circuits import filters
@@ -7,7 +6,7 @@ from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
 from dcim.api.views import PathEndpointMixin
 from extras.api.views import CustomFieldModelViewSet
 from netbox.api.views import ModelViewSet
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from . import serializers
 
 
@@ -25,7 +24,7 @@ class CircuitsRootView(APIRootView):
 
 class ProviderViewSet(CustomFieldModelViewSet):
     queryset = Provider.objects.prefetch_related('tags').annotate(
-        circuit_count=Coalesce(get_subquery(Circuit, 'provider'), 0)
+        circuit_count=count_related(Circuit, 'provider')
     )
     serializer_class = serializers.ProviderSerializer
     filterset_class = filters.ProviderFilterSet
@@ -37,7 +36,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
 
 class CircuitTypeViewSet(ModelViewSet):
     queryset = CircuitType.objects.annotate(
-        circuit_count=Coalesce(get_subquery(Circuit, 'type'), 0)
+        circuit_count=count_related(Circuit, 'type')
     )
     serializer_class = serializers.CircuitTypeSerializer
     filterset_class = filters.CircuitTypeFilterSet

+ 6 - 6
netbox/circuits/views.py

@@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from . import filters, forms, tables
 from .choices import CircuitTerminationSideChoices
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -18,7 +18,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 class ProviderListView(generic.ObjectListView):
     queryset = Provider.objects.annotate(
-        count_circuits=get_subquery(Circuit, 'provider')
+        count_circuits=count_related(Circuit, 'provider')
     )
     filterset = filters.ProviderFilterSet
     filterset_form = forms.ProviderFilterForm
@@ -67,7 +67,7 @@ class ProviderBulkImportView(generic.BulkImportView):
 
 class ProviderBulkEditView(generic.BulkEditView):
     queryset = Provider.objects.annotate(
-        count_circuits=get_subquery(Circuit, 'provider')
+        count_circuits=count_related(Circuit, 'provider')
     )
     filterset = filters.ProviderFilterSet
     table = tables.ProviderTable
@@ -76,7 +76,7 @@ class ProviderBulkEditView(generic.BulkEditView):
 
 class ProviderBulkDeleteView(generic.BulkDeleteView):
     queryset = Provider.objects.annotate(
-        count_circuits=get_subquery(Circuit, 'provider')
+        count_circuits=count_related(Circuit, 'provider')
     )
     filterset = filters.ProviderFilterSet
     table = tables.ProviderTable
@@ -88,7 +88,7 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
 
 class CircuitTypeListView(generic.ObjectListView):
     queryset = CircuitType.objects.annotate(
-        circuit_count=get_subquery(Circuit, 'type')
+        circuit_count=count_related(Circuit, 'type')
     )
     table = tables.CircuitTypeTable
 
@@ -110,7 +110,7 @@ class CircuitTypeBulkImportView(generic.BulkImportView):
 
 class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitType.objects.annotate(
-        circuit_count=get_subquery(Circuit, 'type')
+        circuit_count=count_related(Circuit, 'type')
     )
     table = tables.CircuitTypeTable
 

+ 21 - 22
netbox/dcim/api/views.py

@@ -3,7 +3,6 @@ from collections import OrderedDict
 
 from django.conf import settings
 from django.db.models import F
-from django.db.models.functions import Coalesce
 from django.http import HttpResponseForbidden, HttpResponse
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
@@ -31,7 +30,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.metadata import ContentTypeMetadata
 from utilities.api import get_serializer_for_model
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from virtualization.models import VirtualMachine
 from . import serializers
 from .exceptions import MissingFilterException
@@ -120,12 +119,12 @@ class SiteViewSet(CustomFieldModelViewSet):
     queryset = Site.objects.prefetch_related(
         'region', 'tenant', 'tags'
     ).annotate(
-        device_count=Coalesce(get_subquery(Device, 'site'), 0),
-        rack_count=Coalesce(get_subquery(Rack, 'site'), 0),
-        prefix_count=Coalesce(get_subquery(Prefix, 'site'), 0),
-        vlan_count=Coalesce(get_subquery(VLAN, 'site'), 0),
-        circuit_count=Coalesce(get_subquery(Circuit, 'terminations__site'), 0),
-        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster__site'), 0),
+        device_count=count_related(Device, 'site'),
+        rack_count=count_related(Rack, 'site'),
+        prefix_count=count_related(Prefix, 'site'),
+        vlan_count=count_related(VLAN, 'site'),
+        circuit_count=count_related(Circuit, 'terminations__site'),
+        virtualmachine_count=count_related(VirtualMachine, 'cluster__site')
     )
     serializer_class = serializers.SiteSerializer
     filterset_class = filters.SiteFilterSet
@@ -153,7 +152,7 @@ class RackGroupViewSet(ModelViewSet):
 
 class RackRoleViewSet(ModelViewSet):
     queryset = RackRole.objects.annotate(
-        rack_count=Coalesce(get_subquery(Rack, 'role'), 0)
+        rack_count=count_related(Rack, 'role')
     )
     serializer_class = serializers.RackRoleSerializer
     filterset_class = filters.RackRoleFilterSet
@@ -167,8 +166,8 @@ class RackViewSet(CustomFieldModelViewSet):
     queryset = Rack.objects.prefetch_related(
         'site', 'group__site', 'role', 'tenant', 'tags'
     ).annotate(
-        device_count=Coalesce(get_subquery(Device, 'rack'), 0),
-        powerfeed_count=Coalesce(get_subquery(PowerFeed, 'rack'), 0)
+        device_count=count_related(Device, 'rack'),
+        powerfeed_count=count_related(PowerFeed, 'rack')
     )
     serializer_class = serializers.RackSerializer
     filterset_class = filters.RackFilterSet
@@ -241,9 +240,9 @@ class RackReservationViewSet(ModelViewSet):
 
 class ManufacturerViewSet(ModelViewSet):
     queryset = Manufacturer.objects.annotate(
-        devicetype_count=Coalesce(get_subquery(DeviceType, 'manufacturer'), 0),
-        inventoryitem_count=Coalesce(get_subquery(InventoryItem, 'manufacturer'), 0),
-        platform_count=Coalesce(get_subquery(Platform, 'manufacturer'), 0)
+        devicetype_count=count_related(DeviceType, 'manufacturer'),
+        inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
+        platform_count=count_related(Platform, 'manufacturer')
     )
     serializer_class = serializers.ManufacturerSerializer
     filterset_class = filters.ManufacturerFilterSet
@@ -255,7 +254,7 @@ class ManufacturerViewSet(ModelViewSet):
 
 class DeviceTypeViewSet(CustomFieldModelViewSet):
     queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
-        device_count=Coalesce(get_subquery(Device, 'device_type'), 0)
+        device_count=count_related(Device, 'device_type')
     )
     serializer_class = serializers.DeviceTypeSerializer
     filterset_class = filters.DeviceTypeFilterSet
@@ -319,8 +318,8 @@ class DeviceBayTemplateViewSet(ModelViewSet):
 
 class DeviceRoleViewSet(ModelViewSet):
     queryset = DeviceRole.objects.annotate(
-        device_count=Coalesce(get_subquery(Device, 'device_role'), 0),
-        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'role'), 0)
+        device_count=count_related(Device, 'device_role'),
+        virtualmachine_count=count_related(VirtualMachine, 'role')
     )
     serializer_class = serializers.DeviceRoleSerializer
     filterset_class = filters.DeviceRoleFilterSet
@@ -332,8 +331,8 @@ class DeviceRoleViewSet(ModelViewSet):
 
 class PlatformViewSet(ModelViewSet):
     queryset = Platform.objects.annotate(
-        device_count=Coalesce(get_subquery(Device, 'platform'), 0),
-        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'platform'), 0)
+        device_count=count_related(Device, 'platform'),
+        virtualmachine_count=count_related(VirtualMachine, 'platform')
     )
     serializer_class = serializers.PlatformSerializer
     filterset_class = filters.PlatformFilterSet
@@ -343,7 +342,7 @@ class PlatformViewSet(ModelViewSet):
 # Devices
 #
 
-class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
+class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
     queryset = Device.objects.prefetch_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
         'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
@@ -597,7 +596,7 @@ class CableViewSet(ModelViewSet):
 
 class VirtualChassisViewSet(ModelViewSet):
     queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
-        member_count=Coalesce(get_subquery(Device, 'virtual_chassis'), 0)
+        member_count=count_related(Device, 'virtual_chassis')
     )
     serializer_class = serializers.VirtualChassisSerializer
     filterset_class = filters.VirtualChassisFilterSet
@@ -611,7 +610,7 @@ class PowerPanelViewSet(ModelViewSet):
     queryset = PowerPanel.objects.prefetch_related(
         'site', 'rack_group'
     ).annotate(
-        powerfeed_count=Coalesce(get_subquery(PowerFeed, 'power_panel'), 0)
+        powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     serializer_class = serializers.PowerPanelSerializer
     filterset_class = filters.PowerPanelFilterSet

+ 9 - 3
netbox/dcim/filters.py

@@ -224,6 +224,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
         choices=RackStatusChoices,
         null_value=None
     )
+    type = django_filters.MultipleChoiceFilter(
+        choices=RackTypeChoices
+    )
+    width = django_filters.MultipleChoiceFilter(
+        choices=RackWidthChoices
+    )
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RackRole.objects.all(),
         label='Role (ID)',
@@ -242,8 +248,8 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet,
     class Meta:
         model = Rack
         fields = [
-            'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
-            'outer_width', 'outer_depth', 'outer_unit',
+            'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
+            'outer_unit',
         ]
 
     def search(self, queryset, name, value):
@@ -296,7 +302,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
         label='User (ID)',
     )
     user = django_filters.ModelMultipleChoiceFilter(
-        field_name='user',
+        field_name='user__username',
         queryset=User.objects.all(),
         to_field_name='username',
         label='User (name)',

+ 21 - 2
netbox/dcim/forms.py

@@ -21,7 +21,7 @@ from ipam.models import IPAddress, VLAN
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
-    APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
+    APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm,
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
     NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
@@ -690,6 +690,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         required=False,
         widget=StaticSelect2Multiple()
     )
+    type = forms.MultipleChoiceField(
+        choices=RackTypeChoices,
+        required=False,
+        widget=StaticSelect2Multiple()
+    )
+    width = forms.MultipleChoiceField(
+        choices=RackWidthChoices,
+        required=False,
+        widget=StaticSelect2Multiple()
+    )
     role = DynamicModelMultipleChoiceField(
         queryset=RackRole.objects.all(),
         to_field_name='slug',
@@ -850,7 +860,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
 
 class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
     model = RackReservation
-    field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
+    field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
     q = forms.CharField(
         required=False,
         label='Search'
@@ -874,6 +884,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
         label='Rack group',
         null_option='None'
     )
+    user_id = DynamicModelMultipleChoiceField(
+        queryset=User.objects.all(),
+        required=False,
+        display_field='username',
+        label='User',
+        widget=APISelectMultiple(
+            api_url='/api/users/users/',
+        )
+    )
     tag = TagFilterField(model)
 
 

+ 2 - 0
netbox/dcim/signals.py

@@ -1,5 +1,6 @@
 import logging
 
+from cacheops import invalidate_obj
 from django.contrib.contenttypes.models import ContentType
 from django.db.models.signals import post_save, post_delete, pre_delete
 from django.db import transaction
@@ -30,6 +31,7 @@ def rebuild_paths(obj):
 
     with transaction.atomic():
         for cp in cable_paths:
+            invalidate_obj(cp.origin)
             cp.delete()
             create_cablepath(cp.origin)
 

+ 2 - 1
netbox/dcim/tables/devices.py

@@ -447,7 +447,8 @@ class DeviceInterfaceTable(InterfaceTable):
             'connection', 'actions',
         )
         row_attrs = {
-            'class': lambda record: record.cable.get_status_class() if record.cable else ''
+            'class': lambda record: record.cable.get_status_class() if record.cable else '',
+            'data-name': lambda record: record.name,
         }
 
 

+ 6 - 9
netbox/dcim/tests/test_filters.py

@@ -329,7 +329,7 @@ class RackTestCase(TestCase):
 
         racks = (
             Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
-            Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_19IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
+            Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER),
             Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH),
         )
         Rack.objects.bulk_create(racks)
@@ -351,13 +351,11 @@ class RackTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_type(self):
-        # TODO: Test for multiple values
-        params = {'type': RackTypeChoices.TYPE_2POST}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_width(self):
-        # TODO: Test for multiple values
-        params = {'width': RackWidthChoices.WIDTH_19IN}
+        params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_u_height(self):
@@ -516,9 +514,8 @@ class RackReservationTestCase(TestCase):
         users = User.objects.all()[:2]
         params = {'user_id': [users[0].pk, users[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
-        # TODO: Filtering by username is broken
-        # params = {'user': [users[0].username, users[1].username]}
-        # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'user': [users[0].username, users[1].username]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_tenant(self):
         tenants = Tenant.objects.all()[:2]

+ 18 - 18
netbox/dcim/views.py

@@ -20,7 +20,7 @@ from secrets.models import Secret
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
-from utilities.utils import csv_format, get_subquery
+from utilities.utils import csv_format, count_related
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
 from virtualization.models import VirtualMachine
 from . import filters, forms, tables
@@ -254,7 +254,7 @@ class RackGroupBulkDeleteView(generic.BulkDeleteView):
 
 class RackRoleListView(generic.ObjectListView):
     queryset = RackRole.objects.annotate(
-        rack_count=get_subquery(Rack, 'role')
+        rack_count=count_related(Rack, 'role')
     )
     table = tables.RackRoleTable
 
@@ -276,7 +276,7 @@ class RackRoleBulkImportView(generic.BulkImportView):
 
 class RackRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = RackRole.objects.annotate(
-        rack_count=get_subquery(Rack, 'role')
+        rack_count=count_related(Rack, 'role')
     )
     table = tables.RackRoleTable
 
@@ -289,7 +289,7 @@ class RackListView(generic.ObjectListView):
     queryset = Rack.objects.prefetch_related(
         'site', 'group', 'tenant', 'role', 'devices__device_type'
     ).annotate(
-        device_count=get_subquery(Device, 'rack')
+        device_count=count_related(Device, 'rack')
     )
     filterset = filters.RackFilterSet
     filterset_form = forms.RackFilterForm
@@ -470,9 +470,9 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
 
 class ManufacturerListView(generic.ObjectListView):
     queryset = Manufacturer.objects.annotate(
-        devicetype_count=get_subquery(DeviceType, 'manufacturer'),
-        inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
-        platform_count=get_subquery(Platform, 'manufacturer')
+        devicetype_count=count_related(DeviceType, 'manufacturer'),
+        inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
+        platform_count=count_related(Platform, 'manufacturer')
     )
     table = tables.ManufacturerTable
 
@@ -494,7 +494,7 @@ class ManufacturerBulkImportView(generic.BulkImportView):
 
 class ManufacturerBulkDeleteView(generic.BulkDeleteView):
     queryset = Manufacturer.objects.annotate(
-        devicetype_count=get_subquery(DeviceType, 'manufacturer')
+        devicetype_count=count_related(DeviceType, 'manufacturer')
     )
     table = tables.ManufacturerTable
 
@@ -505,7 +505,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
 
 class DeviceTypeListView(generic.ObjectListView):
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
-        instance_count=get_subquery(Device, 'device_type')
+        instance_count=count_related(Device, 'device_type')
     )
     filterset = filters.DeviceTypeFilterSet
     filterset_form = forms.DeviceTypeFilterForm
@@ -612,7 +612,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
 
 class DeviceTypeBulkEditView(generic.BulkEditView):
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
-        instance_count=get_subquery(Device, 'device_type')
+        instance_count=count_related(Device, 'device_type')
     )
     filterset = filters.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
@@ -621,7 +621,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
 
 class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
-        instance_count=get_subquery(Device, 'device_type')
+        instance_count=count_related(Device, 'device_type')
     )
     filterset = filters.DeviceTypeFilterSet
     table = tables.DeviceTypeTable
@@ -913,8 +913,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class DeviceRoleListView(generic.ObjectListView):
     queryset = DeviceRole.objects.annotate(
-        device_count=get_subquery(Device, 'device_role'),
-        vm_count=get_subquery(VirtualMachine, 'role')
+        device_count=count_related(Device, 'device_role'),
+        vm_count=count_related(VirtualMachine, 'role')
     )
     table = tables.DeviceRoleTable
 
@@ -945,8 +945,8 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
 
 class PlatformListView(generic.ObjectListView):
     queryset = Platform.objects.annotate(
-        device_count=get_subquery(Device, 'platform'),
-        vm_count=get_subquery(VirtualMachine, 'platform')
+        device_count=count_related(Device, 'platform'),
+        vm_count=count_related(VirtualMachine, 'platform')
     )
     table = tables.PlatformTable
 
@@ -2335,7 +2335,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
 
 class VirtualChassisListView(generic.ObjectListView):
     queryset = VirtualChassis.objects.prefetch_related('master').annotate(
-        member_count=get_subquery(Device, 'virtual_chassis')
+        member_count=count_related(Device, 'virtual_chassis')
     )
     table = tables.VirtualChassisTable
     filterset = filters.VirtualChassisFilterSet
@@ -2565,7 +2565,7 @@ class PowerPanelListView(generic.ObjectListView):
     queryset = PowerPanel.objects.prefetch_related(
         'site', 'rack_group'
     ).annotate(
-        powerfeed_count=get_subquery(PowerFeed, 'power_panel')
+        powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     filterset = filters.PowerPanelFilterSet
     filterset_form = forms.PowerPanelFilterForm
@@ -2615,7 +2615,7 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
     queryset = PowerPanel.objects.prefetch_related(
         'site', 'rack_group'
     ).annotate(
-        powerfeed_count=get_subquery(PowerFeed, 'power_panel')
+        powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     filterset = filters.PowerPanelFilterSet
     table = tables.PowerPanelTable

+ 2 - 3
netbox/extras/api/views.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.models import ContentType
-from django.db.models.functions import Coalesce
 from django.http import Http404
 from django_rq.queues import get_connection
 from rest_framework import status
@@ -22,7 +21,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.views import ModelViewSet
 from utilities.exceptions import RQWorkerNotRunningException
-from utilities.utils import copy_safe_request, get_subquery
+from utilities.utils import copy_safe_request, count_related
 from . import serializers
 
 
@@ -103,7 +102,7 @@ class ExportTemplateViewSet(ModelViewSet):
 
 class TagViewSet(ModelViewSet):
     queryset = Tag.objects.annotate(
-        tagged_items=Coalesce(get_subquery(TaggedItem, 'tag'), 0)
+        tagged_items=count_related(TaggedItem, 'tag')
     )
     serializer_class = serializers.TagSerializer
     filterset_class = filters.TagFilterSet

+ 12 - 14
netbox/extras/filters.py

@@ -2,6 +2,7 @@ import django_filters
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
+from django.forms import DateField, IntegerField, NullBooleanField
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
@@ -38,24 +39,21 @@ class CustomFieldFilter(django_filters.Filter):
     """
     def __init__(self, custom_field, *args, **kwargs):
         self.custom_field = custom_field
-        super().__init__(*args, **kwargs)
 
-    def filter(self, queryset, value):
+        if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER:
+            self.field_class = IntegerField
+        elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+            self.field_class = NullBooleanField
+        elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE:
+            self.field_class = DateField
 
-        # Skip filter on empty value
-        if value is None or not value.strip():
-            return queryset
+        super().__init__(*args, **kwargs)
 
-        # Apply the assigned filter logic (exact or loose)
-        if (
-            self.custom_field.type in EXACT_FILTER_TYPES or
-            self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT
-        ):
-            kwargs = {f'custom_field_data__{self.field_name}': value}
-        else:
-            kwargs = {f'custom_field_data__{self.field_name}__icontains': value}
+        self.field_name = f'custom_field_data__{self.field_name}'
 
-        return queryset.filter(**kwargs)
+        if custom_field.type not in EXACT_FILTER_TYPES:
+            if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
+                self.lookup_expr = 'icontains'
 
 
 class CustomFieldModelFilterSet(django_filters.FilterSet):

+ 35 - 5
netbox/extras/tests/test_changelog.py

@@ -27,6 +27,16 @@ class ChangeLogViewTest(ModelViewTestCase):
         cf.save()
         cf.content_types.set([ct])
 
+        # Create a select custom field on the Site model
+        cf_select = CustomField(
+            type=CustomFieldTypeChoices.TYPE_SELECT,
+            name='my_field_select',
+            required=False,
+            choices=['Bar', 'Foo']
+        )
+        cf_select.save()
+        cf_select.content_types.set([ct])
+
     def test_create_object(self):
         tags = self.create_tags('Tag 1', 'Tag 2')
         form_data = {
@@ -34,6 +44,7 @@ class ChangeLogViewTest(ModelViewTestCase):
             'slug': 'test-site-1',
             'status': SiteStatusChoices.STATUS_ACTIVE,
             'cf_my_field': 'ABC',
+            'cf_my_field_select': 'Bar',
             'tags': [tag.pk for tag in tags],
         }
 
@@ -54,6 +65,7 @@ class ChangeLogViewTest(ModelViewTestCase):
         self.assertEqual(oc_list[0].changed_object, site)
         self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE)
         self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field'])
+        self.assertEqual(oc_list[0].object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
         self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2'])
 
@@ -68,6 +80,7 @@ class ChangeLogViewTest(ModelViewTestCase):
             'slug': 'test-site-x',
             'status': SiteStatusChoices.STATUS_PLANNED,
             'cf_my_field': 'DEF',
+            'cf_my_field_select': 'Foo',
             'tags': [tags[2].pk],
         }
 
@@ -88,6 +101,7 @@ class ChangeLogViewTest(ModelViewTestCase):
         self.assertEqual(oc.changed_object, site)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
         self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field'])
+        self.assertEqual(oc.object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
         self.assertEqual(oc.object_data['tags'], ['Tag 3'])
 
     def test_delete_object(self):
@@ -95,7 +109,8 @@ class ChangeLogViewTest(ModelViewTestCase):
             name='Test Site 1',
             slug='test-site-1',
             custom_field_data={
-                'my_field': 'ABC'
+                'my_field': 'ABC',
+                'my_field_select': 'Bar'
             }
         )
         site.save()
@@ -115,6 +130,7 @@ class ChangeLogViewTest(ModelViewTestCase):
         self.assertEqual(oc.object_repr, site.name)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
         self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
+        self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
         self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])
 
 
@@ -133,6 +149,16 @@ class ChangeLogAPITest(APITestCase):
         cf.save()
         cf.content_types.set([ct])
 
+        # Create a select custom field on the Site model
+        cf_select = CustomField(
+            type=CustomFieldTypeChoices.TYPE_SELECT,
+            name='my_field_select',
+            required=False,
+            choices=['Bar', 'Foo']
+        )
+        cf_select.save()
+        cf_select.content_types.set([ct])
+
         # Create some tags
         tags = (
             Tag(name='Tag 1', slug='tag-1'),
@@ -146,7 +172,8 @@ class ChangeLogAPITest(APITestCase):
             'name': 'Test Site 1',
             'slug': 'test-site-1',
             'custom_fields': {
-                'my_field': 'ABC'
+                'my_field': 'ABC',
+                'my_field_select': 'Bar',
             },
             'tags': [
                 {'name': 'Tag 1'},
@@ -180,7 +207,8 @@ class ChangeLogAPITest(APITestCase):
             'name': 'Test Site X',
             'slug': 'test-site-x',
             'custom_fields': {
-                'my_field': 'DEF'
+                'my_field': 'DEF',
+                'my_field_select': 'Foo',
             },
             'tags': [
                 {'name': 'Tag 3'}
@@ -209,7 +237,8 @@ class ChangeLogAPITest(APITestCase):
             name='Test Site 1',
             slug='test-site-1',
             custom_field_data={
-                'my_field': 'ABC'
+                'my_field': 'ABC',
+                'my_field_select': 'Bar'
             }
         )
         site.save()
@@ -226,5 +255,6 @@ class ChangeLogAPITest(APITestCase):
         self.assertEqual(oc.changed_object, None)
         self.assertEqual(oc.object_repr, site.name)
         self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
-        self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
+        self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC')
+        self.assertEqual(oc.object_data['custom_fields']['my_field_select'], 'Bar')
         self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2'])

+ 100 - 0
netbox/extras/tests/test_customfields.py

@@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
 from django.urls import reverse
 from rest_framework import status
 
+from dcim.filters import SiteFilterSet
 from dcim.forms import SiteCSVForm
 from dcim.models import Site, Rack
 from extras.choices import *
@@ -597,3 +598,102 @@ class CustomFieldModelTest(TestCase):
 
         site.cf['baz'] = 'def'
         site.clean()
+
+
+class CustomFieldFilterTest(TestCase):
+    queryset = Site.objects.all()
+    filterset = SiteFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        obj_type = ContentType.objects.get_for_model(Site)
+
+        # Integer filtering
+        cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Boolean filtering
+        cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Exact text filtering
+        cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT,
+                         filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Loose text filtering
+        cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT,
+                         filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Date filtering
+        cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE)
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Exact URL filtering
+        cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL,
+                         filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT)
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Loose URL filtering
+        cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL,
+                         filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE)
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Selection filtering
+        cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        Site.objects.bulk_create([
+            Site(name='Site 1', slug='site-1', custom_field_data={
+                'cf1': 100,
+                'cf2': True,
+                'cf3': 'foo',
+                'cf4': 'foo',
+                'cf5': '2016-06-26',
+                'cf6': 'http://foo.example.com/',
+                'cf7': 'http://foo.example.com/',
+                'cf8': 'Foo',
+            }),
+            Site(name='Site 2', slug='site-2', custom_field_data={
+                'cf1': 200,
+                'cf2': False,
+                'cf3': 'foobar',
+                'cf4': 'foobar',
+                'cf5': '2016-06-27',
+                'cf6': 'http://bar.example.com/',
+                'cf7': 'http://bar.example.com/',
+                'cf8': 'Bar',
+            }),
+            Site(name='Site 3', slug='site-3', custom_field_data={
+            }),
+        ])
+
+    def test_filter_integer(self):
+        self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1)
+
+    def test_filter_boolean(self):
+        self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
+
+    def test_filter_text(self):
+        self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2)
+
+    def test_filter_date(self):
+        self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1)
+
+    def test_filter_url(self):
+        self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2)
+
+    def test_filter_select(self):
+        self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)

+ 4 - 4
netbox/extras/views.py

@@ -12,7 +12,7 @@ from rq import Worker
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
-from utilities.utils import copy_safe_request, get_subquery, shallow_compare_dict
+from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin
 from . import filters, forms, tables
 from .choices import JobResultStatusChoices
@@ -27,7 +27,7 @@ from .scripts import get_scripts, run_script
 
 class TagListView(generic.ObjectListView):
     queryset = Tag.objects.annotate(
-        items=get_subquery(TaggedItem, 'tag')
+        items=count_related(TaggedItem, 'tag')
     )
     filterset = filters.TagFilterSet
     filterset_form = forms.TagFilterForm
@@ -52,7 +52,7 @@ class TagBulkImportView(generic.BulkImportView):
 
 class TagBulkEditView(generic.BulkEditView):
     queryset = Tag.objects.annotate(
-        items=get_subquery(TaggedItem, 'tag')
+        items=count_related(TaggedItem, 'tag')
     )
     table = tables.TagTable
     form = forms.TagBulkEditForm
@@ -60,7 +60,7 @@ class TagBulkEditView(generic.BulkEditView):
 
 class TagBulkDeleteView(generic.BulkDeleteView):
     queryset = Tag.objects.annotate(
-        items=get_subquery(TaggedItem, 'tag')
+        items=count_related(TaggedItem, 'tag')
     )
     table = tables.TagTable
 

+ 8 - 9
netbox/ipam/api/views.py

@@ -1,5 +1,4 @@
 from django.conf import settings
-from django.db.models.functions import Coalesce
 from django.shortcuts import get_object_or_404
 from django_pglocks import advisory_lock
 from drf_yasg.utils import swagger_auto_schema
@@ -13,7 +12,7 @@ from ipam import filters
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
 from netbox.api.views import ModelViewSet
 from utilities.constants import ADVISORY_LOCK_KEYS
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from . import serializers
 
 
@@ -33,8 +32,8 @@ class VRFViewSet(CustomFieldModelViewSet):
     queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
         'import_targets', 'export_targets', 'tags'
     ).annotate(
-        ipaddress_count=Coalesce(get_subquery(IPAddress, 'vrf'), 0),
-        prefix_count=Coalesce(get_subquery(Prefix, 'vrf'), 0)
+        ipaddress_count=count_related(IPAddress, 'vrf'),
+        prefix_count=count_related(Prefix, 'vrf')
     )
     serializer_class = serializers.VRFSerializer
     filterset_class = filters.VRFFilterSet
@@ -56,7 +55,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet):
 
 class RIRViewSet(ModelViewSet):
     queryset = RIR.objects.annotate(
-        aggregate_count=Coalesce(get_subquery(Aggregate, 'rir'), 0)
+        aggregate_count=count_related(Aggregate, 'rir')
     )
     serializer_class = serializers.RIRSerializer
     filterset_class = filters.RIRFilterSet
@@ -78,8 +77,8 @@ class AggregateViewSet(CustomFieldModelViewSet):
 
 class RoleViewSet(ModelViewSet):
     queryset = Role.objects.annotate(
-        prefix_count=Coalesce(get_subquery(Prefix, 'role'), 0),
-        vlan_count=Coalesce(get_subquery(VLAN, 'role'), 0)
+        prefix_count=count_related(Prefix, 'role'),
+        vlan_count=count_related(VLAN, 'role')
     )
     serializer_class = serializers.RoleSerializer
     filterset_class = filters.RoleFilterSet
@@ -273,7 +272,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
 
 class VLANGroupViewSet(ModelViewSet):
     queryset = VLANGroup.objects.prefetch_related('site').annotate(
-        vlan_count=Coalesce(get_subquery(VLAN, 'group'), 0)
+        vlan_count=count_related(VLAN, 'group')
     )
     serializer_class = serializers.VLANGroupSerializer
     filterset_class = filters.VLANGroupFilterSet
@@ -287,7 +286,7 @@ class VLANViewSet(CustomFieldModelViewSet):
     queryset = VLAN.objects.prefetch_related(
         'site', 'group', 'tenant', 'role', 'tags'
     ).annotate(
-        prefix_count=Coalesce(get_subquery(Prefix, 'vlan'), 0)
+        prefix_count=count_related(Prefix, 'vlan')
     )
     serializer_class = serializers.VLANSerializer
     filterset_class = filters.VLANFilterSet

+ 1 - 0
netbox/ipam/forms.py

@@ -774,6 +774,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
                 self.initial['primary_for_parent'] = True
 
     def clean(self):
+        super().clean()
 
         # Cannot select both a device interface and a VM interface
         if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):

+ 6 - 8
netbox/ipam/tables.py

@@ -18,13 +18,11 @@ UTILIZATION_GRAPH = """
 """
 
 PREFIX_LINK = """
-{% if record.children %}
-    <span class="text-nowrap" style="padding-left: {{ record.parents }}0px "><i class="mdi mdi-chevron-right"></i></a>
-{% else %}
-    <span class="text-nowrap" style="padding-left: {{ record.parents }}9px">
-{% endif %}
-    <a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
-</span>
+{% load helpers %}
+{% for i in record.parents|as_range %}
+    <i class="mdi mdi-circle-small"></i>
+{% endfor %}
+<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if parent.vrf %}&vrf={{ parent.vrf.pk }}{% endif %}{% if parent.site %}&site={{ parent.site.pk }}{% endif %}{% if parent.tenant %}&tenant_group={{ parent.tenant.group.pk }}&tenant={{ parent.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
 """
 
 PREFIX_ROLE_LINK = """
@@ -104,7 +102,7 @@ VLANGROUP_ADD_VLAN = """
 """
 
 VLAN_MEMBER_TAGGED = """
-{% if record.untagged_vlan_id == vlan.pk %}
+{% if record.untagged_vlan_id == object.pk %}
     <span class="text-danger"><i class="mdi mdi-close-thick"></i></span>
 {% else %}
     <span class="text-success"><i class="mdi mdi-check-bold"></i></span>

+ 7 - 7
netbox/ipam/views.py

@@ -6,7 +6,7 @@ from django_tables2 import RequestConfig
 from dcim.models import Device, Interface
 from netbox.views import generic
 from utilities.paginator import EnhancedPaginator, get_paginate_count
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from virtualization.models import VirtualMachine, VMInterface
 from . import filters, forms, tables
 from .constants import *
@@ -140,7 +140,7 @@ class RouteTargetBulkDeleteView(generic.BulkDeleteView):
 
 class RIRListView(generic.ObjectListView):
     queryset = RIR.objects.annotate(
-        aggregate_count=get_subquery(Aggregate, 'rir')
+        aggregate_count=count_related(Aggregate, 'rir')
     )
     filterset = filters.RIRFilterSet
     filterset_form = forms.RIRFilterForm
@@ -165,7 +165,7 @@ class RIRBulkImportView(generic.BulkImportView):
 
 class RIRBulkDeleteView(generic.BulkDeleteView):
     queryset = RIR.objects.annotate(
-        aggregate_count=get_subquery(Aggregate, 'rir')
+        aggregate_count=count_related(Aggregate, 'rir')
     )
     filterset = filters.RIRFilterSet
     table = tables.RIRTable
@@ -277,8 +277,8 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
 
 class RoleListView(generic.ObjectListView):
     queryset = Role.objects.annotate(
-        prefix_count=get_subquery(Prefix, 'role'),
-        vlan_count=get_subquery(VLAN, 'role')
+        prefix_count=count_related(Prefix, 'role'),
+        vlan_count=count_related(VLAN, 'role')
     )
     table = tables.RoleTable
 
@@ -633,7 +633,7 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
 
 class VLANGroupListView(generic.ObjectListView):
     queryset = VLANGroup.objects.prefetch_related('site').annotate(
-        vlan_count=get_subquery(VLAN, 'group')
+        vlan_count=count_related(VLAN, 'group')
     )
     filterset = filters.VLANGroupFilterSet
     filterset_form = forms.VLANGroupFilterForm
@@ -657,7 +657,7 @@ class VLANGroupBulkImportView(generic.BulkImportView):
 
 class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = VLANGroup.objects.prefetch_related('site').annotate(
-        vlan_count=get_subquery(VLAN, 'group')
+        vlan_count=count_related(VLAN, 'group')
     )
     filterset = filters.VLANGroupFilterSet
     table = tables.VLANGroupTable

+ 2 - 2
netbox/netbox/configuration.example.py

@@ -79,7 +79,7 @@ BANNER_BOTTOM = ''
 # Text to include on the login page above the login form. HTML is allowed.
 BANNER_LOGIN = ''
 
-# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
+# Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set:
 # BASE_PATH = 'netbox/'
 BASE_PATH = ''
 
@@ -183,7 +183,7 @@ NAPALM_PASSWORD = ''
 # NAPALM timeout (in seconds). (Default: 30)
 NAPALM_TIMEOUT = 30
 
-# NAPALM optional arguments (see http://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
+# NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
 # be provided as a dictionary.
 NAPALM_ARGS = {}
 

+ 6 - 6
netbox/netbox/constants.py

@@ -23,7 +23,7 @@ from secrets.tables import SecretTable
 from tenancy.filters import TenantFilterSet
 from tenancy.models import Tenant
 from tenancy.tables import TenantTable
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet
 from virtualization.models import Cluster, VirtualMachine
 from virtualization.tables import ClusterTable, VirtualMachineDetailTable
@@ -33,7 +33,7 @@ SEARCH_TYPES = OrderedDict((
     # Circuits
     ('provider', {
         'queryset': Provider.objects.annotate(
-            count_circuits=get_subquery(Circuit, 'provider')
+            count_circuits=count_related(Circuit, 'provider')
         ),
         'filterset': ProviderFilterSet,
         'table': ProviderTable,
@@ -74,7 +74,7 @@ SEARCH_TYPES = OrderedDict((
     }),
     ('devicetype', {
         'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
-            instance_count=get_subquery(Device, 'device_type')
+            instance_count=count_related(Device, 'device_type')
         ),
         'filterset': DeviceTypeFilterSet,
         'table': DeviceTypeTable,
@@ -90,7 +90,7 @@ SEARCH_TYPES = OrderedDict((
     }),
     ('virtualchassis', {
         'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
-            member_count=get_subquery(Device, 'virtual_chassis')
+            member_count=count_related(Device, 'virtual_chassis')
         ),
         'filterset': VirtualChassisFilterSet,
         'table': VirtualChassisTable,
@@ -111,8 +111,8 @@ SEARCH_TYPES = OrderedDict((
     # Virtualization
     ('cluster', {
         'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
-            device_count=get_subquery(Device, 'cluster'),
-            vm_count=get_subquery(VirtualMachine, 'cluster')
+            device_count=count_related(Device, 'cluster'),
+            vm_count=count_related(VirtualMachine, 'cluster')
         ),
         'filterset': ClusterFilterSet,
         'table': ClusterTable,

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.10.1'
+VERSION = '2.10.2'
 
 # Hostname
 HOSTNAME = platform.node()

+ 3 - 4
netbox/project-static/js/interface_filtering.js

@@ -1,11 +1,10 @@
 // Inteface filtering
 $('input.interface-filter').on('input', function() {
-    var filter = new RegExp(this.value);
-    var interface;
+    let filter = new RegExp(this.value);
+    let interface;
 
     for (interface of $('table > tbody > tr')) {
-        // Slice off 'interface_' at the start of the ID
-        if (filter.test(interface.id.slice(10))) {
+        if (filter.test(interface.getAttribute('data-name'))) {
             // Match the toggle in case the filter now matches the interface
             $(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
             $(interface).show();

+ 2 - 3
netbox/secrets/api/views.py

@@ -1,7 +1,6 @@
 import base64
 
 from Crypto.PublicKey import RSA
-from django.db.models.functions import Coalesce
 from django.http import HttpResponseBadRequest
 from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
@@ -13,7 +12,7 @@ from netbox.api.views import ModelViewSet
 from secrets import filters
 from secrets.exceptions import InvalidKey
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from . import serializers
 
 ERR_USERKEY_MISSING = "No UserKey found for the current user."
@@ -36,7 +35,7 @@ class SecretsRootView(APIRootView):
 
 class SecretRoleViewSet(ModelViewSet):
     queryset = SecretRole.objects.annotate(
-        secret_count=Coalesce(get_subquery(Secret, 'role'), 0)
+        secret_count=count_related(Secret, 'role')
     )
     serializer_class = serializers.SecretRoleSerializer
     filterset_class = filters.SecretRoleFilterSet

+ 1 - 0
netbox/secrets/forms.py

@@ -122,6 +122,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
             self.fields['plaintext'].required = True
 
     def clean(self):
+        super().clean()
 
         if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']:
             raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.")

+ 3 - 3
netbox/secrets/views.py

@@ -7,7 +7,7 @@ from django.utils.html import escape
 from django.utils.safestring import mark_safe
 
 from netbox.views import generic
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from . import filters, forms, tables
 from .models import SecretRole, Secret, SessionKey, UserKey
 
@@ -28,7 +28,7 @@ def get_session_key(request):
 
 class SecretRoleListView(generic.ObjectListView):
     queryset = SecretRole.objects.annotate(
-        secret_count=get_subquery(Secret, 'role')
+        secret_count=count_related(Secret, 'role')
     )
     table = tables.SecretRoleTable
 
@@ -50,7 +50,7 @@ class SecretRoleBulkImportView(generic.BulkImportView):
 
 class SecretRoleBulkDeleteView(generic.BulkDeleteView):
     queryset = SecretRole.objects.annotate(
-        secret_count=get_subquery(Secret, 'role')
+        secret_count=count_related(Secret, 'role')
     )
     table = tables.SecretRoleTable
 

+ 1 - 1
netbox/templates/base.html

@@ -71,7 +71,7 @@
                 </div>
                 <div class="col-xs-4 text-right noprint">
                     <p class="text-muted">
-                        <i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> &middot;
+                        <i class="mdi mdi-book-open-page-variant text-primary"></i> <a href="https://netbox.readthedocs.io/">Docs</a> &middot;
                         <i class="mdi mdi-cloud-braces text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot;
                         <i class="mdi mdi-xml text-primary"></i> <a href="https://github.com/netbox-community/netbox">Code</a> &middot;
                         <i class="mdi mdi-lifebuoy text-primary"></i> <a href="https://github.com/netbox-community/netbox/wiki">Help</a>

+ 4 - 0
netbox/templates/dcim/rack.html

@@ -330,12 +330,16 @@
     <div class="col-md-6">
         <div class="row" style="margin-bottom: 20px">
             <div class="col-md-6 col-sm-6 col-xs-12 text-center">
+              <div style="margin-left: 30px">
                 <h4>Front</h4>
                 {% include 'dcim/inc/rack_elevation.html' with face='front' %}
+              </div>
             </div>
             <div class="col-md-6 col-sm-6 col-xs-12 text-center">
+              <div style="margin-left: 30px">
                 <h4>Rear</h4>
                 {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
+              </div>
             </div>
         </div>
         <div class="panel panel-default">

+ 3 - 1
netbox/templates/dcim/rack_elevation_list.html

@@ -25,7 +25,8 @@
         {% if page %}
             <div style="white-space: nowrap; overflow-x: scroll;">
                 {% for rack in page %}
-                    <div style="display: inline-block; width: 266px">
+                    <div style="display: inline-block; margin-right: 12px; width: 254px">
+                      <div style="margin-left: 30px">
                         <div class="text-center">
                             <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
                             {% if rack.role %}
@@ -43,6 +44,7 @@
                                 <small class="text-muted">({{ rack.facility_id }})</small>
                             {% endif %}
                         </div>
+                      </div>
                     </div>
                 {% endfor %}
             </div>

+ 2 - 2
netbox/templates/dcim/site.html

@@ -137,7 +137,7 @@
                     <td>
                         {% if object.physical_address %}
                             <div class="pull-right noprint">
-                                <a href="http://maps.google.com/?q={{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-xs">
+                                <a href="https://maps.google.com/?q={{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-xs">
                                     <i class="mdi mdi-map-marker"></i> Map it
                                 </a>
                             </div>
@@ -156,7 +156,7 @@
                     <td>
                         {% if object.latitude and object.longitude %}
                             <div class="pull-right noprint">
-                                <a href="http://maps.google.com/?q={{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-xs">
+                                <a href="https://maps.google.com/?q={{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-xs">
                                     <i class="mdi mdi-map-marker"></i> Map it
                                 </a>
                             </div>

+ 1 - 1
netbox/templates/ipam/routetarget.html

@@ -78,7 +78,7 @@
                 </tr>
                 <tr>
                     <td>Description</td>
-                    <td>{{ vrf.description|placeholder }}</td>
+                    <td>{{ object.description|placeholder }}</td>
                 </tr>
 		    </table>
         </div>

+ 3 - 0
netbox/templates/secrets/inc/assigned_secrets.html

@@ -1,4 +1,7 @@
 {% if secrets %}
+    <form id="secret_form">
+        {% csrf_token %}
+    </form>
     <table class="table table-hover panel-body">
         {% for secret in secrets %}
             <tr>

+ 1 - 0
netbox/templates/virtualization/virtualmachine.html

@@ -317,5 +317,6 @@
 
 {% block javascript %}
   <script src="{% static 'js/interface_filtering.js' %}?v{{ settings.VERSION }}"></script>
+  <script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
   <script src="{% static 'js/tableconfig.js' %}?v{{ settings.VERSION }}"></script>
 {% endblock %}

+ 10 - 11
netbox/tenancy/api/views.py

@@ -1,4 +1,3 @@
-from django.db.models.functions import Coalesce
 from rest_framework.routers import APIRootView
 
 from circuits.models import Circuit
@@ -8,7 +7,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
 from netbox.api.views import ModelViewSet
 from tenancy import filters
 from tenancy.models import Tenant, TenantGroup
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from virtualization.models import VirtualMachine
 from . import serializers
 
@@ -45,15 +44,15 @@ class TenantViewSet(CustomFieldModelViewSet):
     queryset = Tenant.objects.prefetch_related(
         'group', 'tags'
     ).annotate(
-        circuit_count=get_subquery(Circuit, 'tenant'),
-        device_count=get_subquery(Device, 'tenant'),
-        ipaddress_count=Coalesce(get_subquery(IPAddress, 'tenant'), 0),
-        prefix_count=Coalesce(get_subquery(Prefix, 'tenant'), 0),
-        rack_count=Coalesce(get_subquery(Rack, 'tenant'), 0),
-        site_count=Coalesce(get_subquery(Site, 'tenant'), 0),
-        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'tenant'), 0),
-        vlan_count=Coalesce(get_subquery(VLAN, 'tenant'), 0),
-        vrf_count=Coalesce(get_subquery(VRF, 'tenant'), 0)
+        circuit_count=count_related(Circuit, 'tenant'),
+        device_count=count_related(Device, 'tenant'),
+        ipaddress_count=count_related(IPAddress, 'tenant'),
+        prefix_count=count_related(Prefix, 'tenant'),
+        rack_count=count_related(Rack, 'tenant'),
+        site_count=count_related(Site, 'tenant'),
+        virtualmachine_count=count_related(VirtualMachine, 'tenant'),
+        vlan_count=count_related(VLAN, 'tenant'),
+        vrf_count=count_related(VRF, 'tenant')
     )
     serializer_class = serializers.TenantSerializer
     filterset_class = filters.TenantFilterSet

+ 12 - 0
netbox/utilities/templatetags/helpers.py

@@ -208,6 +208,18 @@ def split(string, sep=','):
     return string.split(sep)
 
 
+@register.filter()
+def as_range(n):
+    """
+    Return a range of n items.
+    """
+    try:
+        int(n)
+    except TypeError:
+        return list()
+    return range(n)
+
+
 #
 # Tags
 #

+ 3 - 2
netbox/utilities/utils.py

@@ -5,6 +5,7 @@ from itertools import count, groupby
 
 from django.core.serializers import serialize
 from django.db.models import Count, OuterRef, Subquery
+from django.db.models.functions import Coalesce
 from jinja2 import Environment
 
 from dcim.choices import CableLengthUnitChoices
@@ -65,7 +66,7 @@ def dynamic_import(name):
     return mod
 
 
-def get_subquery(model, field):
+def count_related(model, field):
     """
     Return a Subquery suitable for annotating a child object count.
     """
@@ -79,7 +80,7 @@ def get_subquery(model, field):
         ).values('c')
     )
 
-    return subquery
+    return Coalesce(subquery, 0)
 
 
 def serialize_object(obj, extra=None, exclude=None):

+ 6 - 7
netbox/virtualization/api/views.py

@@ -1,9 +1,8 @@
-from django.db.models.functions import Coalesce
 from rest_framework.routers import APIRootView
 
 from dcim.models import Device
 from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from virtualization import filters
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from . import serializers
@@ -23,7 +22,7 @@ class VirtualizationRootView(APIRootView):
 
 class ClusterTypeViewSet(ModelViewSet):
     queryset = ClusterType.objects.annotate(
-        cluster_count=Coalesce(get_subquery(Cluster, 'type'), 0)
+        cluster_count=count_related(Cluster, 'type')
     )
     serializer_class = serializers.ClusterTypeSerializer
     filterset_class = filters.ClusterTypeFilterSet
@@ -31,7 +30,7 @@ class ClusterTypeViewSet(ModelViewSet):
 
 class ClusterGroupViewSet(ModelViewSet):
     queryset = ClusterGroup.objects.annotate(
-        cluster_count=Coalesce(get_subquery(Cluster, 'group'), 0)
+        cluster_count=count_related(Cluster, 'group')
     )
     serializer_class = serializers.ClusterGroupSerializer
     filterset_class = filters.ClusterGroupFilterSet
@@ -41,8 +40,8 @@ class ClusterViewSet(CustomFieldModelViewSet):
     queryset = Cluster.objects.prefetch_related(
         'type', 'group', 'tenant', 'site', 'tags'
     ).annotate(
-        device_count=Coalesce(get_subquery(Device, 'cluster'), 0),
-        virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster'), 0)
+        device_count=count_related(Device, 'cluster'),
+        virtualmachine_count=count_related(VirtualMachine, 'cluster')
     )
     serializer_class = serializers.ClusterSerializer
     filterset_class = filters.ClusterFilterSet
@@ -52,7 +51,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
 # Virtual machines
 #
 
-class VirtualMachineViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
+class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
     queryset = VirtualMachine.objects.prefetch_related(
         'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
     )

+ 3 - 0
netbox/virtualization/tables.py

@@ -183,3 +183,6 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
         default_columns = (
             'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions',
         )
+        row_attrs = {
+            'data-name': lambda record: record.name,
+        }

+ 7 - 7
netbox/virtualization/views.py

@@ -11,7 +11,7 @@ from ipam.models import IPAddress, Service
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from netbox.views import generic
 from secrets.models import Secret
-from utilities.utils import get_subquery
+from utilities.utils import count_related
 from . import filters, forms, tables
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
@@ -22,7 +22,7 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterf
 
 class ClusterTypeListView(generic.ObjectListView):
     queryset = ClusterType.objects.annotate(
-        cluster_count=get_subquery(Cluster, 'type')
+        cluster_count=count_related(Cluster, 'type')
     )
     table = tables.ClusterTypeTable
 
@@ -44,7 +44,7 @@ class ClusterTypeBulkImportView(generic.BulkImportView):
 
 class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterType.objects.annotate(
-        cluster_count=get_subquery(Cluster, 'type')
+        cluster_count=count_related(Cluster, 'type')
     )
     table = tables.ClusterTypeTable
 
@@ -55,7 +55,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
 
 class ClusterGroupListView(generic.ObjectListView):
     queryset = ClusterGroup.objects.annotate(
-        cluster_count=get_subquery(Cluster, 'group')
+        cluster_count=count_related(Cluster, 'group')
     )
     table = tables.ClusterGroupTable
 
@@ -77,7 +77,7 @@ class ClusterGroupBulkImportView(generic.BulkImportView):
 
 class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = ClusterGroup.objects.annotate(
-        cluster_count=get_subquery(Cluster, 'group')
+        cluster_count=count_related(Cluster, 'group')
     )
     table = tables.ClusterGroupTable
 
@@ -89,8 +89,8 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
 class ClusterListView(generic.ObjectListView):
     permission_required = 'virtualization.view_cluster'
     queryset = Cluster.objects.annotate(
-        device_count=get_subquery(Device, 'cluster'),
-        vm_count=get_subquery(VirtualMachine, 'cluster')
+        device_count=count_related(Device, 'cluster'),
+        vm_count=count_related(VirtualMachine, 'cluster')
     )
     table = tables.ClusterTable
     filterset = filters.ClusterFilterSet