Jelajahi Sumber

Merge pull request #7317 from netbox-community/develop

Release v3.0.3
Jeremy Stretch 4 tahun lalu
induk
melakukan
55e9685d30
99 mengubah file dengan 1282 tambahan dan 734 penghapusan
  1. 4 4
      .gitattributes
  2. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  3. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  4. 1 1
      docs/administration/permissions.md
  5. 0 11
      docs/administration/replicating-netbox.md
  6. 56 0
      docs/configuration/optional-settings.md
  7. 5 5
      docs/core-functionality/circuits.md
  8. 10 10
      docs/core-functionality/device-types.md
  9. 14 14
      docs/core-functionality/devices.md
  10. 8 8
      docs/core-functionality/ipam.md
  11. 3 3
      docs/core-functionality/power.md
  12. 1 1
      docs/core-functionality/services.md
  13. 7 7
      docs/core-functionality/sites-and-racks.md
  14. 2 2
      docs/core-functionality/tenancy.md
  15. 5 5
      docs/core-functionality/virtualization.md
  16. 2 2
      docs/core-functionality/vlans.md
  17. 1 1
      docs/customization/custom-scripts.md
  18. 1 1
      docs/customization/reports.md
  19. 2 2
      docs/graphql-api/overview.md
  20. 0 1
      docs/index.md
  21. 1 1
      docs/installation/index.md
  22. 1 1
      docs/models/dcim/cable.md
  23. 21 6
      docs/plugins/development.md
  24. 29 0
      docs/release-notes/version-3.0.md
  25. 1 1
      docs/rest-api/authentication.md
  26. 2 4
      mkdocs.yml
  27. 0 1
      netbox/circuits/__init__.py
  28. 12 0
      netbox/circuits/forms.py
  29. 16 4
      netbox/circuits/tables.py
  30. 5 12
      netbox/circuits/views.py
  31. 0 1
      netbox/dcim/__init__.py
  32. 15 0
      netbox/dcim/choices.py
  33. 37 3
      netbox/dcim/forms.py
  34. 4 2
      netbox/dcim/tables/devices.py
  35. 3 2
      netbox/dcim/tables/devicetypes.py
  36. 3 2
      netbox/dcim/tables/power.py
  37. 6 16
      netbox/dcim/tables/racks.py
  38. 3 2
      netbox/dcim/tables/sites.py
  39. 20 16
      netbox/dcim/views.py
  40. 0 1
      netbox/extras/__init__.py
  41. 15 1
      netbox/extras/tables.py
  42. 0 1
      netbox/ipam/__init__.py
  43. 25 2
      netbox/ipam/forms.py
  44. 4 0
      netbox/ipam/tables/__init__.py
  45. 27 322
      netbox/ipam/tables/ip.py
  46. 35 0
      netbox/ipam/tables/services.py
  47. 203 0
      netbox/ipam/tables/vlans.py
  48. 74 0
      netbox/ipam/tables/vrfs.py
  49. 27 22
      netbox/ipam/views.py
  50. 133 23
      netbox/netbox/authentication.py
  51. 2 2
      netbox/netbox/constants.py
  52. 63 3
      netbox/netbox/middleware.py
  53. 9 1
      netbox/netbox/settings.py
  54. 123 19
      netbox/netbox/tests/test_authentication.py
  55. 2 1
      netbox/netbox/urls.py
  56. 5 14
      netbox/netbox/views/__init__.py
  57. 3 3
      netbox/netbox/views/generic.py
  58. 0 0
      netbox/project-static/dist/netbox-dark.css
  59. 0 0
      netbox/project-static/dist/netbox-light.css
  60. 0 0
      netbox/project-static/dist/netbox-print.css
  61. 0 0
      netbox/project-static/dist/netbox.js
  62. 0 0
      netbox/project-static/dist/netbox.js.map
  63. 0 0
      netbox/project-static/dist/status.js
  64. 0 0
      netbox/project-static/dist/status.js.map
  65. 1 23
      netbox/project-static/src/select/api/apiSelect.ts
  66. 1 1
      netbox/project-static/src/util.ts
  67. 2 2
      netbox/project-static/styles/select.scss
  68. 1 1
      netbox/project-static/styles/theme-dark.scss
  69. 1 0
      netbox/project-static/styles/theme-light.scss
  70. 11 2
      netbox/templates/dcim/cable_trace.html
  71. 2 2
      netbox/templates/dcim/device.html
  72. 6 4
      netbox/templates/dcim/inc/devicetype_component_table.html
  73. 2 2
      netbox/templates/dcim/rack.html
  74. 35 27
      netbox/templates/extras/report.html
  75. 4 6
      netbox/templates/extras/report_result.html
  76. 3 1
      netbox/templates/extras/script.html
  77. 2 8
      netbox/templates/extras/webhook.html
  78. 2 2
      netbox/templates/home.html
  79. 4 1
      netbox/templates/import_success.html
  80. 0 5
      netbox/templates/inc/responsive_table.html
  81. 1 1
      netbox/templates/ipam/aggregate.html
  82. 2 2
      netbox/templates/ipam/prefix/prefixes.html
  83. 1 1
      netbox/templates/ipam/routetarget.html
  84. 3 2
      netbox/templates/ipam/vlan.html
  85. 2 2
      netbox/templates/search.html
  86. 9 2
      netbox/templates/utilities/obj_table.html
  87. 3 2
      netbox/templates/virtualization/cluster/devices.html
  88. 3 2
      netbox/templates/virtualization/cluster/virtual_machines.html
  89. 15 0
      netbox/tenancy/migrations/0002_tenant_ordering.py
  90. 1 1
      netbox/tenancy/models.py
  91. 11 2
      netbox/tenancy/tables.py
  92. 1 3
      netbox/tenancy/views.py
  93. 24 20
      netbox/utilities/forms/utils.py
  94. 22 1
      netbox/utilities/tables.py
  95. 1 1
      netbox/utilities/templatetags/helpers.py
  96. 0 1
      netbox/virtualization/__init__.py
  97. 27 3
      netbox/virtualization/forms.py
  98. 13 23
      netbox/virtualization/tables.py
  99. 13 9
      netbox/virtualization/views.py

+ 4 - 4
.gitattributes

@@ -1,5 +1,5 @@
 *.sh text eol=lf
-# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable
-*.min.* binary
-*.map binary
-*.pack.js binary
+# Treat compiled JS/CSS files as binary, as they're not meant to be human-readable
+netbox/project-static/dist/*.css binary
+netbox/project-static/dist/*.js binary
+netbox/project-static/dist/*.js.map binary

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -17,7 +17,7 @@ body:
         What version of NetBox are you currently running? (If you don't have access to the most
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         before opening a bug report to see if your issue has already been addressed.)
-      placeholder: v3.0.2
+      placeholder: v3.0.3
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.0.2
+      placeholder: v3.0.3
     validations:
       required: true
   - type: dropdown

+ 1 - 1
docs/administration/permissions.md

@@ -2,7 +2,7 @@
 
 NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
 
-{!docs/models/users/objectpermission.md!}
+{!models/users/objectpermission.md!}
 
 ### Example Constraint Definitions
 

+ 0 - 11
docs/administration/replicating-netbox.md

@@ -71,14 +71,3 @@ To extract the saved archive into a new installation, run the following from the
 ```no-highlight
 tar -xf netbox_media.tar.gz
 ```
-
----
-
-## Cache Invalidation
-
-If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment):
-
-```no-highlight
-# source /opt/netbox/venv/bin/activate
-(venv) # python3 manage.py invalidate all
-```

+ 56 - 0
docs/configuration/optional-settings.md

@@ -490,6 +490,14 @@ NetBox can be configured to support remote user authentication by inferring user
 
 ---
 
+## REMOTE_AUTH_GROUP_SYNC_ENABLED
+
+Default: `False`
+
+NetBox can be configured to sync remote user groups by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
 ## REMOTE_AUTH_HEADER
 
 Default: `'HTTP_REMOTE_USER'`
@@ -498,6 +506,54 @@ When remote user authentication is in use, this is the name of the HTTP header w
 
 ---
 
+## REMOTE_AUTH_GROUP_HEADER
+
+Default: `'HTTP_REMOTE_USER_GROUP'`
+
+When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_SUPERUSER_GROUPS
+
+Default: `[]` (Empty list)
+
+The list of groups that promote an remote User to Superuser on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_SUPERUSERS
+
+Default: `[]` (Empty list)
+
+The list of users that get promoted to Superuser on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_STAFF_GROUPS
+
+Default: `[]` (Empty list)
+
+The list of groups that promote an remote User to Staff on Login. If group isn't present on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_STAFF_USERS
+
+Default: `[]` (Empty list)
+
+The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
+## REMOTE_AUTH_GROUP_SEPARATOR
+
+Default: `|` (Pipe)
+
+The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+
+---
+
 ## RELEASE_CHECK_URL
 
 Default: None (disabled)

+ 5 - 5
docs/core-functionality/circuits.md

@@ -1,10 +1,10 @@
 # Circuits
 
-{!docs/models/circuits/provider.md!}
-{!docs/models/circuits/providernetwork.md!}
+{!models/circuits/provider.md!}
+{!models/circuits/providernetwork.md!}
 
 ---
 
-{!docs/models/circuits/circuit.md!}
-{!docs/models/circuits/circuittype.md!}
-{!docs/models/circuits/circuittermination.md!}
+{!models/circuits/circuit.md!}
+{!models/circuits/circuittype.md!}
+{!models/circuits/circuittermination.md!}

+ 10 - 10
docs/core-functionality/device-types.md

@@ -1,7 +1,7 @@
 # Device Types
 
-{!docs/models/dcim/devicetype.md!}
-{!docs/models/dcim/manufacturer.md!}
+{!models/dcim/devicetype.md!}
+{!models/dcim/manufacturer.md!}
 
 ---
 
@@ -30,11 +30,11 @@ Once component templates have been created, every new device that you create as
 !!! note
     Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices.
 
-{!docs/models/dcim/consoleporttemplate.md!}
-{!docs/models/dcim/consoleserverporttemplate.md!}
-{!docs/models/dcim/powerporttemplate.md!}
-{!docs/models/dcim/poweroutlettemplate.md!}
-{!docs/models/dcim/interfacetemplate.md!}
-{!docs/models/dcim/frontporttemplate.md!}
-{!docs/models/dcim/rearporttemplate.md!}
-{!docs/models/dcim/devicebaytemplate.md!}
+{!models/dcim/consoleporttemplate.md!}
+{!models/dcim/consoleserverporttemplate.md!}
+{!models/dcim/powerporttemplate.md!}
+{!models/dcim/poweroutlettemplate.md!}
+{!models/dcim/interfacetemplate.md!}
+{!models/dcim/frontporttemplate.md!}
+{!models/dcim/rearporttemplate.md!}
+{!models/dcim/devicebaytemplate.md!}

+ 14 - 14
docs/core-functionality/devices.md

@@ -1,8 +1,8 @@
 # Devices and Cabling
 
-{!docs/models/dcim/device.md!}
-{!docs/models/dcim/devicerole.md!}
-{!docs/models/dcim/platform.md!}
+{!models/dcim/device.md!}
+{!models/dcim/devicerole.md!}
+{!models/dcim/platform.md!}
 
 ---
 
@@ -10,20 +10,20 @@
 
 Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources.
 
-{!docs/models/dcim/consoleport.md!}
-{!docs/models/dcim/consoleserverport.md!}
-{!docs/models/dcim/powerport.md!}
-{!docs/models/dcim/poweroutlet.md!}
-{!docs/models/dcim/interface.md!}
-{!docs/models/dcim/frontport.md!}
-{!docs/models/dcim/rearport.md!}
-{!docs/models/dcim/devicebay.md!}
-{!docs/models/dcim/inventoryitem.md!}
+{!models/dcim/consoleport.md!}
+{!models/dcim/consoleserverport.md!}
+{!models/dcim/powerport.md!}
+{!models/dcim/poweroutlet.md!}
+{!models/dcim/interface.md!}
+{!models/dcim/frontport.md!}
+{!models/dcim/rearport.md!}
+{!models/dcim/devicebay.md!}
+{!models/dcim/inventoryitem.md!}
 
 ---
 
-{!docs/models/dcim/virtualchassis.md!}
+{!models/dcim/virtualchassis.md!}
 
 ---
 
-{!docs/models/dcim/cable.md!}
+{!models/dcim/cable.md!}

+ 8 - 8
docs/core-functionality/ipam.md

@@ -1,19 +1,19 @@
 # IP Address Management
 
-{!docs/models/ipam/aggregate.md!}
-{!docs/models/ipam/rir.md!}
+{!models/ipam/aggregate.md!}
+{!models/ipam/rir.md!}
 
 ---
 
-{!docs/models/ipam/prefix.md!}
-{!docs/models/ipam/role.md!}
+{!models/ipam/prefix.md!}
+{!models/ipam/role.md!}
 
 ---
 
-{!docs/models/ipam/iprange.md!}
-{!docs/models/ipam/ipaddress.md!}
+{!models/ipam/iprange.md!}
+{!models/ipam/ipaddress.md!}
 
 ---
 
-{!docs/models/ipam/vrf.md!}
-{!docs/models/ipam/routetarget.md!}
+{!models/ipam/vrf.md!}
+{!models/ipam/routetarget.md!}

+ 3 - 3
docs/core-functionality/power.md

@@ -1,8 +1,8 @@
 # Power Tracking
 
-{!docs/models/dcim/powerpanel.md!}
-{!docs/models/dcim/powerfeed.md!}
+{!models/dcim/powerpanel.md!}
+{!models/dcim/powerfeed.md!}
 
 # Example Power Topology
 
-![Power distribution model](../../media/power_distribution.png)
+![Power distribution model](/media/power_distribution.png)

+ 1 - 1
docs/core-functionality/services.md

@@ -1,3 +1,3 @@
 # Service Mapping
 
-{!docs/models/ipam/service.md!}
+{!models/ipam/service.md!}

+ 7 - 7
docs/core-functionality/sites-and-racks.md

@@ -1,12 +1,12 @@
 # Sites and Racks
 
-{!docs/models/dcim/region.md!}
-{!docs/models/dcim/sitegroup.md!}
-{!docs/models/dcim/site.md!}
-{!docs/models/dcim/location.md!}
+{!models/dcim/region.md!}
+{!models/dcim/sitegroup.md!}
+{!models/dcim/site.md!}
+{!models/dcim/location.md!}
 
 ---
 
-{!docs/models/dcim/rack.md!}
-{!docs/models/dcim/rackrole.md!}
-{!docs/models/dcim/rackreservation.md!}
+{!models/dcim/rack.md!}
+{!models/dcim/rackrole.md!}
+{!models/dcim/rackreservation.md!}

+ 2 - 2
docs/core-functionality/tenancy.md

@@ -1,4 +1,4 @@
 # Tenancy Assignment
 
-{!docs/models/tenancy/tenant.md!}
-{!docs/models/tenancy/tenantgroup.md!}
+{!models/tenancy/tenant.md!}
+{!models/tenancy/tenantgroup.md!}

+ 5 - 5
docs/core-functionality/virtualization.md

@@ -1,10 +1,10 @@
 # Virtualization
 
-{!docs/models/virtualization/cluster.md!}
-{!docs/models/virtualization/clustertype.md!}
-{!docs/models/virtualization/clustergroup.md!}
+{!models/virtualization/cluster.md!}
+{!models/virtualization/clustertype.md!}
+{!models/virtualization/clustergroup.md!}
 
 ---
 
-{!docs/models/virtualization/virtualmachine.md!}
-{!docs/models/virtualization/vminterface.md!}
+{!models/virtualization/virtualmachine.md!}
+{!models/virtualization/vminterface.md!}

+ 2 - 2
docs/core-functionality/vlans.md

@@ -1,4 +1,4 @@
 # VLAN Management
 
-{!docs/models/ipam/vlan.md!}
-{!docs/models/ipam/vlangroup.md!}
+{!models/ipam/vlan.md!}
+{!models/ipam/vlangroup.md!}

+ 1 - 1
docs/customization/custom-scripts.md

@@ -226,7 +226,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
 !!! note
     To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
 
-    ![Adding the run action to a permission](../../media/admin_ui_run_permission.png)
+    ![Adding the run action to a permission](/media/admin_ui_run_permission.png)
 
 ### Via the Web UI
 

+ 1 - 1
docs/customization/reports.md

@@ -104,7 +104,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
 !!! note
     To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
 
-    ![Adding the run action to a permission](../../media/admin_ui_run_permission.png)
+    ![Adding the run action to a permission](/media/admin_ui_run_permission.png)
 
 ### Via the Web UI
 

+ 2 - 2
docs/graphql-api/overview.md

@@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \
 -H "Content-Type: application/json" \
 -H "Accept: application/json" \
 http://netbox/graphql/ \
---data '{"query": "query {circuits(status:\"active\" {cid provider {name}}}"}'
+--data '{"query": "query {circuit_list(status:\"active\") {cid provider {name}}}"}'
 ```
 
 The response will include the requested data formatted as JSON:
@@ -54,7 +54,7 @@ For more detail on constructing GraphQL queries, see the [Graphene documentation
 The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active:
 
 ```
-{"query": "query {sites(region:\"north-carolina\", status:\"active\") {name}}"}
+{"query": "query {site_list(region:\"north-carolina\", status:\"active\") {name}}"}
 ```
 
 ## Authentication

+ 0 - 1
docs/index.md

@@ -10,7 +10,6 @@ NetBox is an infrastructure resource modeling (IRM) application designed to empo
 * **Connections** - Network, console, and power connections among devices
 * **Virtualization** - Virtual machines and clusters
 * **Data circuits** - Long-haul communications circuits and providers
-* **Secrets** - Encrypted storage of sensitive credentials
 
 ## What NetBox Is Not
 

+ 1 - 1
docs/installation/index.md

@@ -13,7 +13,7 @@ The following sections detail how to set up a new instance of NetBox:
 
 The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
 
-<iframe width="560" height="315" src="https://www.youtube.com/embed/dFANGlxXEng" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
+<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
 
 ## Requirements
 

+ 1 - 1
docs/models/dcim/cable.md

@@ -25,7 +25,7 @@ A cable may be traced from either of its endpoints by clicking the "trace" butto
 
 In the example below, three individual cables comprise a path between devices A and D:
 
-![Cable path](../../media/models/dcim_cable_trace.png)
+![Cable path](/media/models/dcim_cable_trace.png)
 
 Traced from Interface 1 on Device A, NetBox will show the following path:
 

+ 21 - 6
docs/plugins/development.md

@@ -17,12 +17,12 @@ However, keep in mind that each piece of functionality is entirely optional. For
 
 ## Initial Setup
 
-## Plugin Structure
+### Plugin Structure
 
 Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
 
 ```no-highlight
-plugin_name/
+project-name/
   - plugin_name/
     - templates/
       - plugin_name/
@@ -38,13 +38,13 @@ plugin_name/
   - setup.py
 ```
 
-The top level is the project root. Immediately within the root should exist several items:
+The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
 
 * `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
 * `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown.
-* The plugin source directory, with the same name as your plugin.
+* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens).
 
-The plugin source directory contains all of the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
+The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
 
 ### Create setup.py
 
@@ -118,6 +118,21 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
 
 All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
 
+### Create a Virtual Environment
+
+It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
+
+```shell
+python3 -m venv /path/to/my/venv
+```
+
+You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
+
+```shell
+cd $VENV/lib/python3.7/site-packages/
+echo /opt/netbox/netbox > netbox.pth
+```
+
 ### Install the Plugin for Development
 
 To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
@@ -218,7 +233,7 @@ NetBox provides a base template to ensure a consistent user experience, which pl
 For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
 
 ```jinja2
-{% extends 'base.html' %}
+{% extends 'base/layout.html' %}
 
 {% block content %}
     {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}

+ 29 - 0
docs/release-notes/version-3.0.md

@@ -1,5 +1,34 @@
 # NetBox v3.0
 
+## v3.0.3 (2021-09-20)
+
+### Enhancements
+
+* [#5775](https://github.com/netbox-community/netbox/issues/5775) - Enable synchronization of groups for remote authentication backend
+* [#6387](https://github.com/netbox-community/netbox/issues/6387) - Add xDSL interface type
+* [#6988](https://github.com/netbox-community/netbox/issues/6988) - Order tenants alphabetically without regard to group assignment
+* [#7032](https://github.com/netbox-community/netbox/issues/7032) - Add URM port types
+* [#7087](https://github.com/netbox-community/netbox/issues/7087) - Add `local_context_data` filter for virtual machines list
+* [#7208](https://github.com/netbox-community/netbox/issues/7208) - Add navigation breadcrumbs for custom scripts & reports
+* [#7210](https://github.com/netbox-community/netbox/issues/7210) - Add search/filter forms for all organizational models
+* [#7239](https://github.com/netbox-community/netbox/issues/7239) - Redirect global search to filtered object list when an object type is selected
+* [#7284](https://github.com/netbox-community/netbox/issues/7284) - Include comments field in table/export for all appropriate models
+
+### Bug Fixes
+
+* [#7167](https://github.com/netbox-community/netbox/issues/7167) - Ensure consistent font size when using monospace formatting
+* [#7226](https://github.com/netbox-community/netbox/issues/7226) - Exempt GraphQL API requests from CSRF inspection
+* [#7228](https://github.com/netbox-community/netbox/issues/7228) - Improve temperature conversions under device status
+* [#7248](https://github.com/netbox-community/netbox/issues/7248) - Fix global search results section links
+* [#7266](https://github.com/netbox-community/netbox/issues/7266) - Tweak font color for form field placeholder text
+* [#7273](https://github.com/netbox-community/netbox/issues/7273) - Fix natural ordering of device components in UI form fields
+* [#7279](https://github.com/netbox-community/netbox/issues/7279) - Fix exception when tracing cable with no associated path
+* [#7282](https://github.com/netbox-community/netbox/issues/7282) - Fix KeyError exception when `INSECURE_SKIP_TLS_VERIFY` is true
+* [#7298](https://github.com/netbox-community/netbox/issues/7298) - Restore missing object names from applied object list filters
+* [#7301](https://github.com/netbox-community/netbox/issues/7301) - Fix exception when deleting a large number of child prefixes
+
+---
+
 ## v3.0.2 (2021-09-08)
 
 ### Bug Fixes

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

@@ -2,7 +2,7 @@
 
 The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
 
-{!docs/models/users/token.md!}
+{!models/users/token.md!}
 
 ## Authenticating to the API
 

+ 2 - 4
mkdocs.yml

@@ -3,9 +3,6 @@ site_dir: netbox/project-static/docs
 site_url: https://netbox.readthedocs.io/
 repo_name: netbox-community/netbox
 repo_url: https://github.com/netbox-community/netbox
-python:
-    install:
-        - requirements: docs/requirements.txt
 theme:
   name: material
   icon:
@@ -24,13 +21,14 @@ extra:
     - icon: fontawesome/brands/github
       link: https://github.com/netbox-community/netbox
     - icon: fontawesome/brands/slack
-      link: https://slack.netbox.dev
+      link: https://netdev.chat/
 extra_css:
     - extra.css
 markdown_extensions:
     - admonition
     - attr_list
     - markdown_include.include:
+        base_path: 'docs/'
         headingOffset: 1
     - pymdownx.emoji:
         emoji_index: !!python/name:materialx.emoji.twemoji

+ 0 - 1
netbox/circuits/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'circuits.apps.CircuitsConfig'

+ 12 - 0
netbox/circuits/forms.py

@@ -266,6 +266,18 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm):
         }
 
 
+class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = CircuitType
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
 #
 # Circuits
 #

+ 16 - 4
netbox/circuits/tables.py

@@ -2,10 +2,18 @@ import django_tables2 as tables
 from django_tables2.utils import Accessor
 
 from tenancy.tables import TenantColumn
-from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
+from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn
 from .models import *
 
 
+__all__ = (
+    'CircuitTable',
+    'CircuitTypeTable',
+    'ProviderTable',
+    'ProviderNetworkTable',
+)
+
+
 CIRCUITTERMINATION_LINK = """
 {% if value.site %}
   <a href="{{ value.site.get_absolute_url }}">{{ value.site }}</a>
@@ -28,6 +36,7 @@ class ProviderTable(BaseTable):
         accessor=Accessor('count_circuits'),
         verbose_name='Circuits'
     )
+    comments = MarkdownColumn()
     tags = TagColumn(
         url_name='circuits:provider_list'
     )
@@ -35,7 +44,8 @@ class ProviderTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = Provider
         fields = (
-            'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'tags',
+            'pk', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', 'comments',
+            'tags',
         )
         default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
 
@@ -52,13 +62,14 @@ class ProviderNetworkTable(BaseTable):
     provider = tables.Column(
         linkify=True
     )
+    comments = MarkdownColumn()
     tags = TagColumn(
         url_name='circuits:providernetwork_list'
     )
 
     class Meta(BaseTable.Meta):
         model = ProviderNetwork
-        fields = ('pk', 'name', 'provider', 'description', 'tags')
+        fields = ('pk', 'name', 'provider', 'description', 'comments', 'tags')
         default_columns = ('pk', 'name', 'provider', 'description')
 
 
@@ -105,6 +116,7 @@ class CircuitTable(BaseTable):
         template_code=CIRCUITTERMINATION_LINK,
         verbose_name='Side Z'
     )
+    comments = MarkdownColumn()
     tags = TagColumn(
         url_name='circuits:circuit_list'
     )
@@ -113,7 +125,7 @@ class CircuitTable(BaseTable):
         model = Circuit
         fields = (
             'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
-            'commit_rate', 'description', 'tags',
+            'commit_rate', 'description', 'comments', 'tags',
         )
         default_columns = (
             'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

+ 5 - 12
netbox/circuits/views.py

@@ -34,9 +34,7 @@ class ProviderView(generic.ObjectView):
         ).prefetch_related(
             'type', 'tenant', 'terminations__site'
         )
-
-        circuits_table = tables.CircuitTable(circuits)
-        circuits_table.columns.hide('provider')
+        circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
         paginate_table(circuits_table, request)
 
         return {
@@ -97,10 +95,7 @@ class ProviderNetworkView(generic.ObjectView):
         ).prefetch_related(
             'type', 'tenant', 'terminations__site'
         )
-
         circuits_table = tables.CircuitTable(circuits)
-        circuits_table.columns.hide('termination_a')
-        circuits_table.columns.hide('termination_z')
         paginate_table(circuits_table, request)
 
         return {
@@ -144,6 +139,8 @@ class CircuitTypeListView(generic.ObjectListView):
     queryset = CircuitType.objects.annotate(
         circuit_count=count_related(Circuit, 'type')
     )
+    filterset = filtersets.CircuitTypeFilterSet
+    filterset_form = forms.CircuitTypeFilterForm
     table = tables.CircuitTypeTable
 
 
@@ -151,12 +148,8 @@ class CircuitTypeView(generic.ObjectView):
     queryset = CircuitType.objects.all()
 
     def get_extra_context(self, request, instance):
-        circuits = Circuit.objects.restrict(request.user, 'view').filter(
-            type=instance
-        )
-
-        circuits_table = tables.CircuitTable(circuits)
-        circuits_table.columns.hide('type')
+        circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
+        circuits_table = tables.CircuitTable(circuits, exclude=('type',))
         paginate_table(circuits_table, request)
 
         return {

+ 0 - 1
netbox/dcim/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'dcim.apps.DCIMConfig'

+ 15 - 0
netbox/dcim/choices.py

@@ -761,6 +761,9 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_T3 = 't3'
     TYPE_E3 = 'e3'
 
+    # ATM/DSL
+    TYPE_XDSL = 'xdsl'
+
     # Stacking
     TYPE_STACKWISE = 'cisco-stackwise'
     TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
@@ -885,6 +888,12 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_E3, 'E3 (34 Mbps)'),
             )
         ),
+        (
+            'ATM',
+            (
+                (TYPE_XDSL, 'xDSL'),
+            )
+        ),
         (
             'Stacking',
             (
@@ -958,6 +967,9 @@ class PortTypeChoices(ChoiceSet):
     TYPE_SPLICE = 'splice'
     TYPE_CS = 'cs'
     TYPE_SN = 'sn'
+    TYPE_URM_P2 = 'urm-p2'
+    TYPE_URM_P4 = 'urm-p4'
+    TYPE_URM_P8 = 'urm-p8'
 
     CHOICES = (
         (
@@ -998,6 +1010,9 @@ class PortTypeChoices(ChoiceSet):
                 (TYPE_ST, 'ST'),
                 (TYPE_CS, 'CS'),
                 (TYPE_SN, 'SN'),
+                (TYPE_URM_P2, 'URM-P2'),
+                (TYPE_URM_P4, 'URM-P4'),
+                (TYPE_URM_P8, 'URM-P8'),
                 (TYPE_SPLICE, 'Splice'),
             )
         )

+ 37 - 3
netbox/dcim/forms.py

@@ -696,6 +696,18 @@ class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['color', 'description']
 
 
+class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = RackRole
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
 #
 # Racks
 #
@@ -1240,6 +1252,18 @@ class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['description']
 
 
+class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Manufacturer
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
 #
 # Device types
 #
@@ -2076,6 +2100,18 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['color', 'description']
 
 
+class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = DeviceRole
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
 #
 # Platforms
 #
@@ -2202,9 +2238,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             api_url='/api/dcim/racks/{{rack}}/elevation/',
             attrs={
                 'disabled-indicator': 'device',
-                'data-query-param-face': "[\"$face\"]",
-                # The UI will not sort this element's options.
-                'pre-sorted': ''
+                'data-query-param-face': "[\"$face\"]"
             }
         )
     )

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

@@ -9,7 +9,7 @@ from dcim.models import (
 from tenancy.tables import TenantColumn
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
-    TagColumn, ToggleColumn,
+    MarkdownColumn, TagColumn, ToggleColumn,
 )
 from .template_code import (
     CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
@@ -18,6 +18,7 @@ from .template_code import (
 )
 
 __all__ = (
+    'BaseInterfaceTable',
     'ConsolePortTable',
     'ConsoleServerPortTable',
     'DeviceBayTable',
@@ -187,6 +188,7 @@ class DeviceTable(BaseTable):
     vc_priority = tables.Column(
         verbose_name='VC Priority'
     )
+    comments = MarkdownColumn()
     tags = TagColumn(
         url_name='dcim:device_list'
     )
@@ -196,7 +198,7 @@ class DeviceTable(BaseTable):
         fields = (
             'pk', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
             'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6',
-            'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'tags',
+            'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
         )
         default_columns = (
             'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

+ 3 - 2
netbox/dcim/tables/devicetypes.py

@@ -5,7 +5,7 @@ from dcim.models import (
     Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
 )
 from utilities.tables import (
-    BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, TagColumn, ToggleColumn,
+    BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
 )
 
 __all__ = (
@@ -68,6 +68,7 @@ class DeviceTypeTable(BaseTable):
         url_params={'device_type_id': 'pk'},
         verbose_name='Instances'
     )
+    comments = MarkdownColumn()
     tags = TagColumn(
         url_name='dcim:devicetype_list'
     )
@@ -76,7 +77,7 @@ class DeviceTypeTable(BaseTable):
         model = DeviceType
         fields = (
             'pk', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
-            'instance_count', 'tags',
+            'comments', 'instance_count', 'tags',
         )
         default_columns = (
             'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

+ 3 - 2
netbox/dcim/tables/power.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 
 from dcim.models import PowerFeed, PowerPanel
-from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
+from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn
 from .devices import CableTerminationTable
 
 __all__ = (
@@ -62,6 +62,7 @@ class PowerFeedTable(CableTerminationTable):
     available_power = tables.Column(
         verbose_name='Available power (VA)'
     )
+    comments = MarkdownColumn()
     tags = TagColumn(
         url_name='dcim:powerfeed_list'
     )
@@ -71,7 +72,7 @@ class PowerFeedTable(CableTerminationTable):
         fields = (
             'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
             'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power',
-            'tags',
+            'comments', 'tags',
         )
         default_columns = (
             'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

+ 6 - 16
netbox/dcim/tables/racks.py

@@ -4,13 +4,12 @@ from django_tables2.utils import Accessor
 from dcim.models import Rack, RackReservation, RackRole
 from tenancy.tables import TenantColumn
 from utilities.tables import (
-    BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn,
-    ToggleColumn, UtilizationColumn,
+    BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn,
+    TagColumn, ToggleColumn, UtilizationColumn,
 )
 
 __all__ = (
     'RackTable',
-    'RackDetailTable',
     'RackReservationTable',
     'RackRoleTable',
 )
@@ -56,17 +55,7 @@ class RackTable(BaseTable):
         template_code="{{ record.u_height }}U",
         verbose_name='Height'
     )
-
-    class Meta(BaseTable.Meta):
-        model = Rack
-        fields = (
-            'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
-            'width', 'u_height',
-        )
-        default_columns = ('pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height')
-
-
-class RackDetailTable(RackTable):
+    comments = MarkdownColumn()
     device_count = LinkedCountColumn(
         viewname='dcim:device_list',
         url_params={'rack_id': 'pk'},
@@ -84,10 +73,11 @@ class RackDetailTable(RackTable):
         url_name='dcim:rack_list'
     )
 
-    class Meta(RackTable.Meta):
+    class Meta(BaseTable.Meta):
+        model = Rack
         fields = (
             'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
-            'width', 'u_height', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
+            'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
         )
         default_columns = (
             'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

+ 3 - 2
netbox/dcim/tables/sites.py

@@ -3,7 +3,7 @@ import django_tables2 as tables
 from dcim.models import Location, Region, Site, SiteGroup
 from tenancy.tables import TenantColumn
 from utilities.tables import (
-    BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn,
+    BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
 )
 from .template_code import LOCATION_ELEVATIONS
 
@@ -76,6 +76,7 @@ class SiteTable(BaseTable):
         linkify=True
     )
     tenant = TenantColumn()
+    comments = MarkdownColumn()
     tags = TagColumn(
         url_name='dcim:site_list'
     )
@@ -85,7 +86,7 @@ class SiteTable(BaseTable):
         fields = (
             'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description',
             'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
-            'contact_email', 'tags',
+            'contact_email', 'comments', 'tags',
         )
         default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description')
 

+ 20 - 16
netbox/dcim/views.py

@@ -131,8 +131,7 @@ class RegionView(generic.ObjectView):
         sites = Site.objects.restrict(request.user, 'view').filter(
             region=instance
         )
-        sites_table = tables.SiteTable(sites)
-        sites_table.columns.hide('region')
+        sites_table = tables.SiteTable(sites, exclude=('region',))
         paginate_table(sites_table, request)
 
         return {
@@ -216,8 +215,7 @@ class SiteGroupView(generic.ObjectView):
         sites = Site.objects.restrict(request.user, 'view').filter(
             group=instance
         )
-        sites_table = tables.SiteTable(sites)
-        sites_table.columns.hide('group')
+        sites_table = tables.SiteTable(sites, exclude=('group',))
         paginate_table(sites_table, request)
 
         return {
@@ -440,6 +438,8 @@ class RackRoleListView(generic.ObjectListView):
     queryset = RackRole.objects.annotate(
         rack_count=count_related(Rack, 'role')
     )
+    filterset = filtersets.RackRoleFilterSet
+    filterset_form = forms.RackRoleFilterForm
     table = tables.RackRoleTable
 
 
@@ -451,8 +451,7 @@ class RackRoleView(generic.ObjectView):
             role=instance
         )
 
-        racks_table = tables.RackTable(racks)
-        racks_table.columns.hide('role')
+        racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
         paginate_table(racks_table, request)
 
         return {
@@ -503,7 +502,7 @@ class RackListView(generic.ObjectListView):
     )
     filterset = filtersets.RackFilterSet
     filterset_form = forms.RackFilterForm
-    table = tables.RackDetailTable
+    table = tables.RackTable
 
 
 class RackElevationListView(generic.ObjectListView):
@@ -684,6 +683,8 @@ class ManufacturerListView(generic.ObjectListView):
         inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
         platform_count=count_related(Platform, 'manufacturer')
     )
+    filterset = filtersets.ManufacturerFilterSet
+    filterset_form = forms.ManufacturerFilterForm
     table = tables.ManufacturerTable
 
 
@@ -700,8 +701,7 @@ class ManufacturerView(generic.ObjectView):
             manufacturer=instance
         )
 
-        devicetypes_table = tables.DeviceTypeTable(devicetypes)
-        devicetypes_table.columns.hide('manufacturer')
+        devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
         paginate_table(devicetypes_table, request)
 
         return {
@@ -1149,6 +1149,8 @@ class DeviceRoleListView(generic.ObjectListView):
         device_count=count_related(Device, 'device_role'),
         vm_count=count_related(VirtualMachine, 'role')
     )
+    filterset = filtersets.DeviceRoleFilterSet
+    filterset_form = forms.DeviceRoleFilterForm
     table = tables.DeviceRoleTable
 
 
@@ -1159,9 +1161,7 @@ class DeviceRoleView(generic.ObjectView):
         devices = Device.objects.restrict(request.user, 'view').filter(
             device_role=instance
         )
-
-        devices_table = tables.DeviceTable(devices)
-        devices_table.columns.hide('device_role')
+        devices_table = tables.DeviceTable(devices, exclude=('device_role',))
         paginate_table(devices_table, request)
 
         return {
@@ -1225,9 +1225,7 @@ class PlatformView(generic.ObjectView):
         devices = Device.objects.restrict(request.user, 'view').filter(
             platform=instance
         )
-
-        devices_table = tables.DeviceTable(devices)
-        devices_table.columns.hide('platform')
+        devices_table = tables.DeviceTable(devices, exclude=('platform',))
         paginate_table(devices_table, request)
 
         return {
@@ -1872,9 +1870,9 @@ class InterfaceView(generic.ObjectView):
         child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
         child_interfaces_tables = tables.InterfaceTable(
             child_interfaces,
+            exclude=('device', 'parent'),
             orderable=False
         )
-        child_interfaces_tables.columns.hide('device')
 
         # Get assigned VLANs and annotate whether each is tagged or untagged
         vlans = []
@@ -2411,6 +2409,12 @@ class PathTraceView(generic.ObjectView):
             else:
                 path = related_paths.first()
 
+        # No paths found
+        if path is None:
+            return {
+                'path': None
+            }
+
         # Get the total length of the cable and whether the length is definitive (fully defined)
         total_length, is_definitive = path.get_total_length() if path else (None, False)
 

+ 0 - 1
netbox/extras/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'extras.apps.ExtrasConfig'

+ 15 - 1
netbox/extras/tables.py

@@ -3,10 +3,23 @@ from django.conf import settings
 
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn,
-    ToggleColumn,
+    MarkdownColumn, ToggleColumn,
 )
 from .models import *
 
+__all__ = (
+    'ConfigContextTable',
+    'CustomFieldTable',
+    'CustomLinkTable',
+    'ExportTemplateTable',
+    'JournalEntryTable',
+    'ObjectChangeTable',
+    'ObjectJournalTable',
+    'TaggedItemTable',
+    'TagTable',
+    'WebhookTable',
+)
+
 CONFIGCONTEXT_ACTIONS = """
 {% if perms.extras.change_configcontext %}
     <a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-sm btn-warning"><i class="mdi mdi-pencil" aria-hidden="true"></i></a>
@@ -232,6 +245,7 @@ class JournalEntryTable(ObjectJournalTable):
         orderable=False,
         verbose_name='Object'
     )
+    comments = MarkdownColumn()
 
     class Meta(BaseTable.Meta):
         model = JournalEntry

+ 0 - 1
netbox/ipam/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'ipam.apps.IPAMConfig'

+ 25 - 2
netbox/ipam/forms.py

@@ -256,7 +256,17 @@ class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['is_private', 'description']
 
 
-class RIRFilterForm(BootstrapMixin, forms.Form):
+class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = RIR
+    field_groups = [
+        ['q'],
+        ['is_private'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
     is_private = forms.NullBooleanField(
         required=False,
         label=_('Private'),
@@ -413,6 +423,18 @@ class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['description']
 
 
+class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = Role
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
 #
 # Prefixes
 #
@@ -1460,11 +1482,12 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['site', 'description']
 
 
-class VLANGroupFilterForm(BootstrapMixin, forms.Form):
+class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     field_groups = [
         ['q'],
         ['region', 'sitegroup', 'site', 'location', 'rack']
     ]
+    model = VLANGroup
     q = forms.CharField(
         required=False,
         widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),

+ 4 - 0
netbox/ipam/tables/__init__.py

@@ -0,0 +1,4 @@
+from .ip import *
+from .services import *
+from .vlans import *
+from .vrfs import *

+ 27 - 322
netbox/ipam/tables.py → netbox/ipam/tables/ip.py

@@ -2,14 +2,23 @@ import django_tables2 as tables
 from django.utils.safestring import mark_safe
 from django_tables2.utils import Accessor
 
-from dcim.models import Interface
 from tenancy.tables import TenantColumn
 from utilities.tables import (
-    BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
+    BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn,
     ToggleColumn, UtilizationColumn,
 )
-from virtualization.models import VMInterface
-from .models import *
+from ipam.models import *
+
+__all__ = (
+    'AggregateTable',
+    'InterfaceIPAddressTable',
+    'IPAddressAssignTable',
+    'IPAddressTable',
+    'IPRangeTable',
+    'PrefixTable',
+    'RIRTable',
+    'RoleTable',
+)
 
 AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
 
@@ -66,114 +75,6 @@ VRF_LINK = """
 {% endif %}
 """
 
-VRF_TARGETS = """
-{% for rt in value.all %}
-    <a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
-{% empty %}
-    &mdash;
-{% endfor %}
-"""
-
-VLAN_LINK = """
-{% if record.pk %}
-    <a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
-{% elif perms.ipam.add_vlan %}
-    <a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-sm btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
-{% else %}
-    {{ record.available }} VLAN{{ record.available|pluralize }} available
-{% endif %}
-"""
-
-VLAN_PREFIXES = """
-{% for prefix in record.prefixes.all %}
-    <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
-{% empty %}
-    &mdash;
-{% endfor %}
-"""
-
-VLAN_ROLE_LINK = """
-{% if record.role %}
-    <a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
-{% else %}
-    &mdash;
-{% endif %}
-"""
-
-VLANGROUP_ADD_VLAN = """
-{% with next_vid=record.get_next_available_vid %}
-    {% if next_vid and perms.ipam.add_vlan %}
-        <a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
-            <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
-        </a>
-    {% endif %}
-{% endwith %}
-"""
-
-VLAN_MEMBER_TAGGED = """
-{% 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>
-{% endif %}
-"""
-
-
-#
-# VRFs
-#
-
-class VRFTable(BaseTable):
-    pk = ToggleColumn()
-    name = tables.Column(
-        linkify=True
-    )
-    rd = tables.Column(
-        verbose_name='RD'
-    )
-    tenant = TenantColumn()
-    enforce_unique = BooleanColumn(
-        verbose_name='Unique'
-    )
-    import_targets = tables.TemplateColumn(
-        template_code=VRF_TARGETS,
-        orderable=False
-    )
-    export_targets = tables.TemplateColumn(
-        template_code=VRF_TARGETS,
-        orderable=False
-    )
-    tags = TagColumn(
-        url_name='ipam:vrf_list'
-    )
-
-    class Meta(BaseTable.Meta):
-        model = VRF
-        fields = (
-            'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
-        )
-        default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
-
-
-#
-# Route targets
-#
-
-class RouteTargetTable(BaseTable):
-    pk = ToggleColumn()
-    name = tables.Column(
-        linkify=True
-    )
-    tenant = TenantColumn()
-    tags = TagColumn(
-        url_name='ipam:vrf_list'
-    )
-
-    class Meta(BaseTable.Meta):
-        model = RouteTarget
-        fields = ('pk', 'name', 'tenant', 'description', 'tags')
-        default_columns = ('pk', 'name', 'tenant', 'description')
-
 
 #
 # RIRs
@@ -215,13 +116,6 @@ class AggregateTable(BaseTable):
         format="Y-m-d",
         verbose_name='Added'
     )
-
-    class Meta(BaseTable.Meta):
-        model = Aggregate
-        fields = ('pk', 'prefix', 'rir', 'tenant', 'date_added', 'description')
-
-
-class AggregateDetailTable(AggregateTable):
     child_count = tables.Column(
         verbose_name='Prefixes'
     )
@@ -233,7 +127,8 @@ class AggregateDetailTable(AggregateTable):
         url_name='ipam:aggregate_list'
     )
 
-    class Meta(AggregateTable.Meta):
+    class Meta(BaseTable.Meta):
+        model = Aggregate
         fields = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
         default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
 
@@ -332,20 +227,6 @@ class PrefixTable(BaseTable):
     mark_utilized = BooleanColumn(
         verbose_name='Marked Utilized'
     )
-
-    class Meta(BaseTable.Meta):
-        model = Prefix
-        fields = (
-            'pk', 'prefix', 'prefix_flat', 'status', 'depth', 'children', 'vrf', 'tenant', 'site', 'vlan', 'role',
-            'is_pool', 'mark_utilized', 'description',
-        )
-        default_columns = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
-        row_attrs = {
-            'class': lambda record: 'success' if not record.pk else '',
-        }
-
-
-class PrefixDetailTable(PrefixTable):
     utilization = PrefixUtilizationColumn(
         accessor='get_utilization',
         orderable=False
@@ -354,7 +235,8 @@ class PrefixDetailTable(PrefixTable):
         url_name='ipam:prefix_list'
     )
 
-    class Meta(PrefixTable.Meta):
+    class Meta(BaseTable.Meta):
+        model = Prefix
         fields = (
             'pk', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
             'is_pool', 'mark_utilized', 'description', 'tags',
@@ -362,6 +244,9 @@ class PrefixDetailTable(PrefixTable):
         default_columns = (
             'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
         )
+        row_attrs = {
+            'class': lambda record: 'success' if not record.pk else '',
+        }
 
 
 #
@@ -427,25 +312,11 @@ class IPAddressTable(BaseTable):
         orderable=False,
         verbose_name='Device/VM'
     )
-
-    class Meta(BaseTable.Meta):
-        model = IPAddress
-        fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'assigned_object_parent', 'dns_name',
-            'description',
-        )
-        row_attrs = {
-            'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
-        }
-
-
-class IPAddressDetailTable(IPAddressTable):
     nat_inside = tables.Column(
         linkify=True,
         orderable=False,
         verbose_name='NAT (Inside)'
     )
-    tenant = TenantColumn()
     assigned = BooleanColumn(
         accessor='assigned_object_id',
         verbose_name='Assigned'
@@ -454,14 +325,18 @@ class IPAddressDetailTable(IPAddressTable):
         url_name='ipam:ipaddress_list'
     )
 
-    class Meta(IPAddressTable.Meta):
+    class Meta(BaseTable.Meta):
+        model = IPAddress
         fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name',
-            'description', 'tags',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
+            'tags',
         )
         default_columns = (
             'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
         )
+        row_attrs = {
+            'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
+        }
 
 
 class IPAddressAssignTable(BaseTable):
@@ -501,173 +376,3 @@ class InterfaceIPAddressTable(BaseTable):
     class Meta(BaseTable.Meta):
         model = IPAddress
         fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description')
-
-
-#
-# VLAN groups
-#
-
-class VLANGroupTable(BaseTable):
-    pk = ToggleColumn()
-    name = tables.Column(linkify=True)
-    scope_type = ContentTypeColumn()
-    scope = tables.Column(
-        linkify=True,
-        orderable=False
-    )
-    vlan_count = LinkedCountColumn(
-        viewname='ipam:vlan_list',
-        url_params={'group_id': 'pk'},
-        verbose_name='VLANs'
-    )
-    actions = ButtonsColumn(
-        model=VLANGroup,
-        prepend_template=VLANGROUP_ADD_VLAN
-    )
-
-    class Meta(BaseTable.Meta):
-        model = VLANGroup
-        fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
-        default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
-
-
-#
-# VLANs
-#
-
-class VLANTable(BaseTable):
-    pk = ToggleColumn()
-    vid = tables.TemplateColumn(
-        template_code=VLAN_LINK,
-        verbose_name='ID'
-    )
-    site = tables.Column(
-        linkify=True
-    )
-    group = tables.Column(
-        linkify=True
-    )
-    tenant = TenantColumn()
-    status = ChoiceFieldColumn(
-        default=AVAILABLE_LABEL
-    )
-    role = tables.TemplateColumn(
-        template_code=VLAN_ROLE_LINK
-    )
-
-    class Meta(BaseTable.Meta):
-        model = VLAN
-        fields = ('pk', 'vid', 'name', 'site', 'group', 'tenant', 'status', 'role', 'description')
-        row_attrs = {
-            'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
-        }
-
-
-class VLANDetailTable(VLANTable):
-    prefixes = tables.TemplateColumn(
-        template_code=VLAN_PREFIXES,
-        orderable=False,
-        verbose_name='Prefixes'
-    )
-    tenant = TenantColumn()
-    tags = TagColumn(
-        url_name='ipam:vlan_list'
-    )
-
-    class Meta(VLANTable.Meta):
-        fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
-        default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
-
-
-class VLANMembersTable(BaseTable):
-    """
-    Base table for Interface and VMInterface assignments
-    """
-    name = tables.Column(
-        linkify=True,
-        verbose_name='Interface'
-    )
-    tagged = tables.TemplateColumn(
-        template_code=VLAN_MEMBER_TAGGED,
-        orderable=False
-    )
-
-
-class VLANDevicesTable(VLANMembersTable):
-    device = tables.Column(
-        linkify=True
-    )
-    actions = ButtonsColumn(Interface, buttons=['edit'])
-
-    class Meta(BaseTable.Meta):
-        model = Interface
-        fields = ('device', 'name', 'tagged', 'actions')
-
-
-class VLANVirtualMachinesTable(VLANMembersTable):
-    virtual_machine = tables.Column(
-        linkify=True
-    )
-    actions = ButtonsColumn(VMInterface, buttons=['edit'])
-
-    class Meta(BaseTable.Meta):
-        model = VMInterface
-        fields = ('virtual_machine', 'name', 'tagged', 'actions')
-
-
-class InterfaceVLANTable(BaseTable):
-    """
-    List VLANs assigned to a specific Interface.
-    """
-    vid = tables.Column(
-        linkify=True,
-        verbose_name='ID'
-    )
-    tagged = BooleanColumn()
-    site = tables.Column(
-        linkify=True
-    )
-    group = tables.Column(
-        accessor=Accessor('group__name'),
-        verbose_name='Group'
-    )
-    tenant = TenantColumn()
-    status = ChoiceFieldColumn()
-    role = tables.TemplateColumn(
-        template_code=VLAN_ROLE_LINK
-    )
-
-    class Meta(BaseTable.Meta):
-        model = VLAN
-        fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
-
-    def __init__(self, interface, *args, **kwargs):
-        self.interface = interface
-        super().__init__(*args, **kwargs)
-
-
-#
-# Services
-#
-
-class ServiceTable(BaseTable):
-    pk = ToggleColumn()
-    name = tables.Column(
-        linkify=True
-    )
-    parent = tables.Column(
-        linkify=True,
-        order_by=('device', 'virtual_machine')
-    )
-    ports = tables.TemplateColumn(
-        template_code='{{ record.port_list }}',
-        verbose_name='Ports'
-    )
-    tags = TagColumn(
-        url_name='ipam:service_list'
-    )
-
-    class Meta(BaseTable.Meta):
-        model = Service
-        fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
-        default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

+ 35 - 0
netbox/ipam/tables/services.py

@@ -0,0 +1,35 @@
+import django_tables2 as tables
+
+from utilities.tables import BaseTable, TagColumn, ToggleColumn
+from ipam.models import *
+
+__all__ = (
+    'ServiceTable',
+)
+
+
+#
+# Services
+#
+
+class ServiceTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
+    parent = tables.Column(
+        linkify=True,
+        order_by=('device', 'virtual_machine')
+    )
+    ports = tables.TemplateColumn(
+        template_code='{{ record.port_list }}',
+        verbose_name='Ports'
+    )
+    tags = TagColumn(
+        url_name='ipam:service_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Service
+        fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
+        default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

+ 203 - 0
netbox/ipam/tables/vlans.py

@@ -0,0 +1,203 @@
+import django_tables2 as tables
+from django.utils.safestring import mark_safe
+from django_tables2.utils import Accessor
+
+from dcim.models import Interface
+from tenancy.tables import TenantColumn
+from utilities.tables import (
+    BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
+    ToggleColumn,
+)
+from virtualization.models import VMInterface
+from ipam.models import *
+
+__all__ = (
+    'InterfaceVLANTable',
+    'VLANDevicesTable',
+    'VLANGroupTable',
+    'VLANMembersTable',
+    'VLANTable',
+    'VLANVirtualMachinesTable',
+)
+
+AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
+
+VLAN_LINK = """
+{% if record.pk %}
+    <a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
+{% elif perms.ipam.add_vlan %}
+    <a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-sm btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
+{% else %}
+    {{ record.available }} VLAN{{ record.available|pluralize }} available
+{% endif %}
+"""
+
+VLAN_PREFIXES = """
+{% for prefix in record.prefixes.all %}
+    <a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
+{% empty %}
+    &mdash;
+{% endfor %}
+"""
+
+VLAN_ROLE_LINK = """
+{% if record.role %}
+    <a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
+{% else %}
+    &mdash;
+{% endif %}
+"""
+
+VLANGROUP_ADD_VLAN = """
+{% with next_vid=record.get_next_available_vid %}
+    {% if next_vid and perms.ipam.add_vlan %}
+        <a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
+            <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
+        </a>
+    {% endif %}
+{% endwith %}
+"""
+
+VLAN_MEMBER_TAGGED = """
+{% 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>
+{% endif %}
+"""
+
+
+#
+# VLAN groups
+#
+
+class VLANGroupTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(linkify=True)
+    scope_type = ContentTypeColumn()
+    scope = tables.Column(
+        linkify=True,
+        orderable=False
+    )
+    vlan_count = LinkedCountColumn(
+        viewname='ipam:vlan_list',
+        url_params={'group_id': 'pk'},
+        verbose_name='VLANs'
+    )
+    actions = ButtonsColumn(
+        model=VLANGroup,
+        prepend_template=VLANGROUP_ADD_VLAN
+    )
+
+    class Meta(BaseTable.Meta):
+        model = VLANGroup
+        fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions')
+        default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
+
+
+#
+# VLANs
+#
+
+class VLANTable(BaseTable):
+    pk = ToggleColumn()
+    vid = tables.TemplateColumn(
+        template_code=VLAN_LINK,
+        verbose_name='ID'
+    )
+    site = tables.Column(
+        linkify=True
+    )
+    group = tables.Column(
+        linkify=True
+    )
+    tenant = TenantColumn()
+    status = ChoiceFieldColumn(
+        default=AVAILABLE_LABEL
+    )
+    role = tables.TemplateColumn(
+        template_code=VLAN_ROLE_LINK
+    )
+    prefixes = tables.TemplateColumn(
+        template_code=VLAN_PREFIXES,
+        orderable=False,
+        verbose_name='Prefixes'
+    )
+    tags = TagColumn(
+        url_name='ipam:vlan_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = VLAN
+        fields = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
+        default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
+        row_attrs = {
+            'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
+        }
+
+
+class VLANMembersTable(BaseTable):
+    """
+    Base table for Interface and VMInterface assignments
+    """
+    name = tables.Column(
+        linkify=True,
+        verbose_name='Interface'
+    )
+    tagged = tables.TemplateColumn(
+        template_code=VLAN_MEMBER_TAGGED,
+        orderable=False
+    )
+
+
+class VLANDevicesTable(VLANMembersTable):
+    device = tables.Column(
+        linkify=True
+    )
+    actions = ButtonsColumn(Interface, buttons=['edit'])
+
+    class Meta(BaseTable.Meta):
+        model = Interface
+        fields = ('device', 'name', 'tagged', 'actions')
+
+
+class VLANVirtualMachinesTable(VLANMembersTable):
+    virtual_machine = tables.Column(
+        linkify=True
+    )
+    actions = ButtonsColumn(VMInterface, buttons=['edit'])
+
+    class Meta(BaseTable.Meta):
+        model = VMInterface
+        fields = ('virtual_machine', 'name', 'tagged', 'actions')
+
+
+class InterfaceVLANTable(BaseTable):
+    """
+    List VLANs assigned to a specific Interface.
+    """
+    vid = tables.Column(
+        linkify=True,
+        verbose_name='ID'
+    )
+    tagged = BooleanColumn()
+    site = tables.Column(
+        linkify=True
+    )
+    group = tables.Column(
+        accessor=Accessor('group__name'),
+        verbose_name='Group'
+    )
+    tenant = TenantColumn()
+    status = ChoiceFieldColumn()
+    role = tables.TemplateColumn(
+        template_code=VLAN_ROLE_LINK
+    )
+
+    class Meta(BaseTable.Meta):
+        model = VLAN
+        fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
+
+    def __init__(self, interface, *args, **kwargs):
+        self.interface = interface
+        super().__init__(*args, **kwargs)

+ 74 - 0
netbox/ipam/tables/vrfs.py

@@ -0,0 +1,74 @@
+import django_tables2 as tables
+
+from tenancy.tables import TenantColumn
+from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn
+from ipam.models import *
+
+__all__ = (
+    'RouteTargetTable',
+    'VRFTable',
+)
+
+VRF_TARGETS = """
+{% for rt in value.all %}
+    <a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
+{% empty %}
+    &mdash;
+{% endfor %}
+"""
+
+
+#
+# VRFs
+#
+
+class VRFTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
+    rd = tables.Column(
+        verbose_name='RD'
+    )
+    tenant = TenantColumn()
+    enforce_unique = BooleanColumn(
+        verbose_name='Unique'
+    )
+    import_targets = tables.TemplateColumn(
+        template_code=VRF_TARGETS,
+        orderable=False
+    )
+    export_targets = tables.TemplateColumn(
+        template_code=VRF_TARGETS,
+        orderable=False
+    )
+    tags = TagColumn(
+        url_name='ipam:vrf_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = VRF
+        fields = (
+            'pk', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
+        )
+        default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
+
+
+#
+# Route targets
+#
+
+class RouteTargetTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
+    tenant = TenantColumn()
+    tags = TagColumn(
+        url_name='ipam:vrf_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = RouteTarget
+        fields = ('pk', 'name', 'tenant', 'description', 'tags')
+        default_columns = ('pk', 'name', 'tenant', 'description')

+ 27 - 22
netbox/ipam/views.py

@@ -155,9 +155,7 @@ class RIRView(generic.ObjectView):
         aggregates = Aggregate.objects.restrict(request.user, 'view').filter(
             rir=instance
         )
-
-        aggregates_table = tables.AggregateTable(aggregates)
-        aggregates_table.columns.hide('rir')
+        aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
         paginate_table(aggregates_table, request)
 
         return {
@@ -207,7 +205,7 @@ class AggregateListView(generic.ObjectListView):
     )
     filterset = filtersets.AggregateFilterSet
     filterset_form = forms.AggregateFilterForm
-    table = tables.AggregateDetailTable
+    table = tables.AggregateTable
 
 
 class AggregateView(generic.ObjectView):
@@ -227,7 +225,7 @@ class AggregateView(generic.ObjectView):
         if request.GET.get('show_available', 'true') == 'true':
             child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
 
-        prefix_table = tables.PrefixDetailTable(child_prefixes)
+        prefix_table = tables.PrefixTable(child_prefixes, exclude=('utilization',))
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
             prefix_table.columns.show('pk')
         paginate_table(prefix_table, request)
@@ -283,6 +281,8 @@ class RoleListView(generic.ObjectListView):
         prefix_count=count_related(Prefix, 'role'),
         vlan_count=count_related(VLAN, 'role')
     )
+    filterset = filtersets.RoleFilterSet
+    filterset_form = forms.RoleFilterForm
     table = tables.RoleTable
 
 
@@ -294,8 +294,7 @@ class RoleView(generic.ObjectView):
             role=instance
         )
 
-        prefixes_table = tables.PrefixTable(prefixes)
-        prefixes_table.columns.hide('role')
+        prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization'))
         paginate_table(prefixes_table, request)
 
         return {
@@ -338,7 +337,7 @@ class PrefixListView(generic.ObjectListView):
     queryset = Prefix.objects.all()
     filterset = filtersets.PrefixFilterSet
     filterset_form = forms.PrefixFilterForm
-    table = tables.PrefixDetailTable
+    table = tables.PrefixTable
     template_name = 'ipam/prefix_list.html'
 
 
@@ -361,8 +360,11 @@ class PrefixView(generic.ObjectView):
         ).prefetch_related(
             'site', 'role'
         )
-        parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
-        parent_prefix_table.exclude = ('vrf',)
+        parent_prefix_table = tables.PrefixTable(
+            list(parent_prefixes),
+            exclude=('vrf', 'utilization'),
+            orderable=False
+        )
 
         # Duplicate prefixes table
         duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
@@ -372,8 +374,11 @@ class PrefixView(generic.ObjectView):
         ).prefetch_related(
             'site', 'role'
         )
-        duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False)
-        duplicate_prefix_table.exclude = ('vrf',)
+        duplicate_prefix_table = tables.PrefixTable(
+            list(duplicate_prefixes),
+            exclude=('vrf', 'utilization'),
+            orderable=False
+        )
 
         return {
             'aggregate': aggregate,
@@ -396,7 +401,7 @@ class PrefixPrefixesView(generic.ObjectView):
         if child_prefixes and request.GET.get('show_available', 'true') == 'true':
             child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
 
-        table = tables.PrefixDetailTable(child_prefixes, user=request.user)
+        table = tables.PrefixTable(child_prefixes, user=request.user, exclude=('utilization',))
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
             table.columns.show('pk')
         paginate_table(table, request)
@@ -599,7 +604,7 @@ class IPAddressListView(generic.ObjectListView):
     queryset = IPAddress.objects.all()
     filterset = filtersets.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
-    table = tables.IPAddressDetailTable
+    table = tables.IPAddressTable
 
 
 class IPAddressView(generic.ObjectView):
@@ -613,8 +618,11 @@ class IPAddressView(generic.ObjectView):
         ).prefetch_related(
             'site', 'role'
         )
-        parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False)
-        parent_prefixes_table.exclude = ('vrf',)
+        parent_prefixes_table = tables.PrefixTable(
+            list(parent_prefixes),
+            exclude=('vrf', 'utilization'),
+            orderable=False
+        )
 
         # Duplicate IPs table
         duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter(
@@ -765,11 +773,9 @@ class VLANGroupView(generic.ObjectView):
         vlans_count = vlans.count()
         vlans = add_available_vlans(vlans, vlan_group=instance)
 
-        vlans_table = tables.VLANDetailTable(vlans)
+        vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes'))
         if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
             vlans_table.columns.show('pk')
-        vlans_table.columns.hide('site')
-        vlans_table.columns.hide('group')
         paginate_table(vlans_table, request)
 
         # Compile permissions list for rendering the object table
@@ -826,7 +832,7 @@ class VLANListView(generic.ObjectListView):
     queryset = VLAN.objects.all()
     filterset = filtersets.VLANFilterSet
     filterset_form = forms.VLANFilterForm
-    table = tables.VLANDetailTable
+    table = tables.VLANTable
 
 
 class VLANView(generic.ObjectView):
@@ -836,8 +842,7 @@ class VLANView(generic.ObjectView):
         prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
             'vrf', 'site', 'role'
         )
-        prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
-        prefix_table.exclude = ('vlan',)
+        prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
 
         return {
             'prefix_table': prefix_table,

+ 133 - 23
netbox/netbox/authentication.py

@@ -2,14 +2,17 @@ import logging
 from collections import defaultdict
 
 from django.conf import settings
+from django.contrib.auth import get_user_model
 from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
-from django.contrib.auth.models import Group
+from django.contrib.auth.models import Group, AnonymousUser
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 
 from users.models import ObjectPermission
 from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct
 
+UserModel = get_user_model()
+
 
 class ObjectPermissionMixin():
 
@@ -101,38 +104,145 @@ class RemoteUserBackend(_RemoteUserBackend):
     def create_unknown_user(self):
         return settings.REMOTE_AUTH_AUTO_CREATE_USER
 
-    def configure_user(self, request, user):
+    def configure_groups(self, user, remote_groups):
         logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
 
         # Assign default groups to the user
         group_list = []
-        for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
+        for name in remote_groups:
             try:
                 group_list.append(Group.objects.get(name=name))
             except Group.DoesNotExist:
-                logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
+                logging.error(
+                    f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
         if group_list:
-            user.groups.add(*group_list)
-            logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")
+            user.groups.set(group_list)
+            logger.debug(
+                f"Assigned groups to remotely-authenticated user {user}: {group_list}")
+        else:
+            user.groups.clear()
+            logger.debug(f"Stripping user {user} from Groups")
+        user.is_superuser = self._is_superuser(user)
+        logger.debug(f"User {user} is Superuser: {user.is_superuser}")
+        logger.debug(
+            f"User {user} should be Superuser: {self._is_superuser(user)}")
+
+        user.is_staff = self._is_staff(user)
+        logger.debug(f"User {user} is Staff: {user.is_staff}")
+        logger.debug(f"User {user} should be Staff: {self._is_staff(user)}")
+        user.save()
+        return user
 
-        # Assign default object permissions to the user
-        permissions_list = []
-        for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
+    def authenticate(self, request, remote_user, remote_groups=None):
+        """
+        The username passed as ``remote_user`` is considered trusted. Return
+        the ``User`` object with the given username. Create a new ``User``
+        object if ``create_unknown_user`` is ``True``.
+        Return None if ``create_unknown_user`` is ``False`` and a ``User``
+        object with the given username is not found in the database.
+        """
+        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        logger.debug(
+            f"trying to authenticate {remote_user} with groups {remote_groups}")
+        if not remote_user:
+            return
+        user = None
+        username = self.clean_username(remote_user)
+
+        # Note that this could be accomplished in one try-except clause, but
+        # instead we use get_or_create when creating unknown users since it has
+        # built-in safeguards for multiple threads.
+        if self.create_unknown_user:
+            user, created = UserModel._default_manager.get_or_create(**{
+                UserModel.USERNAME_FIELD: username
+            })
+            if created:
+                user = self.configure_user(request, user)
+        else:
             try:
-                object_type, action = resolve_permission_ct(permission_name)
-                # TODO: Merge multiple actions into a single ObjectPermission per content type
-                obj_perm = ObjectPermission(actions=[action], constraints=constraints)
-                obj_perm.save()
-                obj_perm.users.add(user)
-                obj_perm.object_types.add(object_type)
-                permissions_list.append(permission_name)
-            except ValueError:
-                logging.error(
-                    f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
-                    "<app>.<action>_<model>. (Example: dcim.add_site)"
-                )
-        if permissions_list:
-            logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
+                user = UserModel._default_manager.get_by_natural_key(username)
+            except UserModel.DoesNotExist:
+                pass
+        if self.user_can_authenticate(user):
+            if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
+                if user is not None and not isinstance(user, AnonymousUser):
+                    return self.configure_groups(user, remote_groups)
+            else:
+                return user
+        else:
+            return None
+
+    def _is_superuser(self, user):
+        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
+        logger.debug(f"Superuser Groups: {superuser_groups}")
+        superusers = settings.REMOTE_AUTH_SUPERUSERS
+        logger.debug(f"Superuser Users: {superusers}")
+        user_groups = set()
+        for g in user.groups.all():
+            user_groups.add(g.name)
+        logger.debug(f"User {user.username} is in Groups:{user_groups}")
+
+        result = user.username in superusers or (
+            set(user_groups) & set(superuser_groups))
+        logger.debug(f"User {user.username} in Superuser Users :{result}")
+        return bool(result)
+
+    def _is_staff(self, user):
+        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
+        logger.debug(f"Superuser Groups: {staff_groups}")
+        staff_users = settings.REMOTE_AUTH_STAFF_USERS
+        logger.debug(f"Staff Users :{staff_users}")
+        user_groups = set()
+        for g in user.groups.all():
+            user_groups.add(g.name)
+        logger.debug(f"User {user.username} is in Groups:{user_groups}")
+        result = user.username in staff_users or (
+            set(user_groups) & set(staff_groups))
+        logger.debug(f"User {user.username} in Staff Users :{result}")
+        return bool(result)
+
+    def configure_user(self, request, user):
+        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
+            # Assign default groups to the user
+            group_list = []
+            for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
+                try:
+                    group_list.append(Group.objects.get(name=name))
+                except Group.DoesNotExist:
+                    logging.error(
+                        f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
+            if group_list:
+                user.groups.add(*group_list)
+                logger.debug(
+                    f"Assigned groups to remotely-authenticated user {user}: {group_list}")
+
+            # Assign default object permissions to the user
+            permissions_list = []
+            for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
+                try:
+                    object_type, action = resolve_permission_ct(
+                        permission_name)
+                    # TODO: Merge multiple actions into a single ObjectPermission per content type
+                    obj_perm = ObjectPermission(
+                        actions=[action], constraints=constraints)
+                    obj_perm.save()
+                    obj_perm.users.add(user)
+                    obj_perm.object_types.add(object_type)
+                    permissions_list.append(permission_name)
+                except ValueError:
+                    logging.error(
+                        f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
+                        "<app>.<action>_<model>. (Example: dcim.add_site)"
+                    )
+            if permissions_list:
+                logger.debug(
+                    f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
+        else:
+            logger.debug(
+                f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as Group sync is enabled")
 
         return user
 

+ 2 - 2
netbox/netbox/constants.py

@@ -21,7 +21,7 @@ from tenancy.tables import TenantTable
 from utilities.utils import count_related
 from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
 from virtualization.models import Cluster, VirtualMachine
-from virtualization.tables import ClusterTable, VirtualMachineDetailTable
+from virtualization.tables import ClusterTable, VirtualMachineTable
 
 SEARCH_MAX_RESULTS = 15
 SEARCH_TYPES = OrderedDict((
@@ -130,7 +130,7 @@ SEARCH_TYPES = OrderedDict((
             'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
         ),
         'filterset': VirtualMachineFilterSet,
-        'table': VirtualMachineDetailTable,
+        'table': VirtualMachineTable,
         'url': 'virtualization:virtualmachine_list',
     }),
     # IPAM

+ 63 - 3
netbox/netbox/middleware.py

@@ -1,8 +1,11 @@
 import uuid
 from urllib import parse
+import logging
 
 from django.conf import settings
 from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
+from django.contrib import auth
+from django.core.exceptions import ImproperlyConfigured
 from django.db import ProgrammingError
 from django.http import Http404, HttpResponseRedirect
 from django.urls import reverse
@@ -16,6 +19,7 @@ class LoginRequiredMiddleware(object):
     """
     If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page.
     """
+
     def __init__(self, get_response):
         self.get_response = get_response
 
@@ -49,12 +53,65 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
         return settings.REMOTE_AUTH_HEADER
 
     def process_request(self, request):
-
+        logger = logging.getLogger(
+            'netbox.authentication.RemoteUserMiddleware')
         # Bypass middleware if remote authentication is not enabled
         if not settings.REMOTE_AUTH_ENABLED:
             return
-
-        return super().process_request(request)
+        # AuthenticationMiddleware is required so that request.user exists.
+        if not hasattr(request, 'user'):
+            raise ImproperlyConfigured(
+                "The Django remote user auth middleware requires the"
+                " authentication middleware to be installed.  Edit your"
+                " MIDDLEWARE setting to insert"
+                " 'django.contrib.auth.middleware.AuthenticationMiddleware'"
+                " before the RemoteUserMiddleware class.")
+        try:
+            username = request.META[self.header]
+        except KeyError:
+            # If specified header doesn't exist then remove any existing
+            # authenticated remote-user, or return (leaving request.user set to
+            # AnonymousUser by the AuthenticationMiddleware).
+            if self.force_logout_if_no_header and request.user.is_authenticated:
+                self._remove_invalid_user(request)
+            return
+        # If the user is already authenticated and that user is the user we are
+        # getting passed in the headers, then the correct user is already
+        # persisted in the session and we don't need to continue.
+        if request.user.is_authenticated:
+            if request.user.get_username() == self.clean_username(username, request):
+                return
+            else:
+                # An authenticated user is associated with the request, but
+                # it does not match the authorized user in the header.
+                self._remove_invalid_user(request)
+
+        # We are seeing this user for the first time in this session, attempt
+        # to authenticate the user.
+        if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
+            logger.debug("Trying to sync Groups")
+            user = auth.authenticate(
+                request, remote_user=username, remote_groups=self._get_groups(request))
+        else:
+            user = auth.authenticate(request, remote_user=username)
+        if user:
+            # User is valid.  Set request.user and persist user in the session
+            # by logging the user in.
+            request.user = user
+            auth.login(request, user)
+
+    def _get_groups(self, request):
+        logger = logging.getLogger(
+            'netbox.authentication.RemoteUserMiddleware')
+
+        groups_string = request.META.get(
+            settings.REMOTE_AUTH_GROUP_HEADER, None)
+        if groups_string:
+            groups = groups_string.split(settings.REMOTE_AUTH_GROUP_SEPARATOR)
+        else:
+            groups = []
+        logger.debug(f"Groups are {groups}")
+        return groups
 
 
 class ObjectChangeMiddleware(object):
@@ -71,6 +128,7 @@ class ObjectChangeMiddleware(object):
     have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
     object is recorded before it (and any related objects) are actually deleted from the database.
     """
+
     def __init__(self, get_response):
         self.get_response = get_response
 
@@ -90,6 +148,7 @@ class APIVersionMiddleware(object):
     """
     If the request is for an API endpoint, include the API version as a response header.
     """
+
     def __init__(self, get_response):
         self.get_response = get_response
 
@@ -105,6 +164,7 @@ class ExceptionHandlingMiddleware(object):
     Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions
     to the user.
     """
+
     def __init__(self, get_response):
         self.get_response = get_response
 

+ 9 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '3.0.2'
+VERSION = '3.0.3'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -120,6 +120,13 @@ REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS'
 REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
 REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
 REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER')
+REMOTE_AUTH_GROUP_HEADER = getattr(configuration, 'REMOTE_AUTH_GROUP_HEADER', 'HTTP_REMOTE_USER_GROUP')
+REMOTE_AUTH_GROUP_SYNC_ENABLED = getattr(configuration, 'REMOTE_AUTH_GROUP_SYNC_ENABLED', False)
+REMOTE_AUTH_SUPERUSER_GROUPS = getattr(configuration, 'REMOTE_AUTH_SUPERUSER_GROUPS', [])
+REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
+REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
+REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
+REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
 RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
@@ -255,6 +262,7 @@ if CACHING_REDIS_SENTINELS:
     CACHES['default']['OPTIONS']['CLIENT_CLASS'] = 'django_redis.client.SentinelClient'
     CACHES['default']['OPTIONS']['SENTINELS'] = CACHING_REDIS_SENTINELS
 if CACHING_REDIS_SKIP_TLS_VERIFY:
+    CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {})
     CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_cert_reqs'] = False
 
 

+ 123 - 19
netbox/netbox/tests/test_authentication.py

@@ -58,7 +58,8 @@ class ExternalAuthenticationTestCase(TestCase):
 
         response = self.client.get(reverse('home'), follow=True, **headers)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed')
+        self.assertEqual(int(self.client.session.get(
+            '_auth_user_id')), self.user.pk, msg='Authentication failed')
 
     @override_settings(
         REMOTE_AUTH_ENABLED=True,
@@ -78,7 +79,8 @@ class ExternalAuthenticationTestCase(TestCase):
 
         response = self.client.get(reverse('home'), follow=True, **headers)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(int(self.client.session.get('_auth_user_id')), self.user.pk, msg='Authentication failed')
+        self.assertEqual(int(self.client.session.get(
+            '_auth_user_id')), self.user.pk, msg='Authentication failed')
 
     @override_settings(
         REMOTE_AUTH_ENABLED=True,
@@ -102,7 +104,8 @@ class ExternalAuthenticationTestCase(TestCase):
 
         # Local user should have been automatically created
         new_user = User.objects.get(username='remoteuser2')
-        self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
+        self.assertEqual(int(self.client.session.get(
+            '_auth_user_id')), new_user.pk, msg='Authentication failed')
 
     @override_settings(
         REMOTE_AUTH_ENABLED=True,
@@ -121,7 +124,8 @@ class ExternalAuthenticationTestCase(TestCase):
         self.assertTrue(settings.REMOTE_AUTH_ENABLED)
         self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
         self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
-        self.assertEqual(settings.REMOTE_AUTH_DEFAULT_GROUPS, ['Group 1', 'Group 2'])
+        self.assertEqual(settings.REMOTE_AUTH_DEFAULT_GROUPS,
+                         ['Group 1', 'Group 2'])
 
         # Create required groups
         groups = (
@@ -135,7 +139,8 @@ class ExternalAuthenticationTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
 
         new_user = User.objects.get(username='remoteuser2')
-        self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
+        self.assertEqual(int(self.client.session.get(
+            '_auth_user_id')), new_user.pk, msg='Authentication failed')
         self.assertListEqual(
             [groups[0], groups[1]],
             list(new_user.groups.all())
@@ -144,7 +149,8 @@ class ExternalAuthenticationTestCase(TestCase):
     @override_settings(
         REMOTE_AUTH_ENABLED=True,
         REMOTE_AUTH_AUTO_CREATE_USER=True,
-        REMOTE_AUTH_DEFAULT_PERMISSIONS={'dcim.add_site': None, 'dcim.change_site': None},
+        REMOTE_AUTH_DEFAULT_PERMISSIONS={
+            'dcim.add_site': None, 'dcim.change_site': None},
         LOGIN_REQUIRED=True
     )
     def test_remote_auth_default_permissions(self):
@@ -158,14 +164,102 @@ class ExternalAuthenticationTestCase(TestCase):
         self.assertTrue(settings.REMOTE_AUTH_ENABLED)
         self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
         self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
-        self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {'dcim.add_site': None, 'dcim.change_site': None})
+        self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {
+                         'dcim.add_site': None, 'dcim.change_site': None})
 
         response = self.client.get(reverse('home'), follow=True, **headers)
         self.assertEqual(response.status_code, 200)
 
         new_user = User.objects.get(username='remoteuser2')
-        self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
-        self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site']))
+        self.assertEqual(int(self.client.session.get(
+            '_auth_user_id')), new_user.pk, msg='Authentication failed')
+        self.assertTrue(new_user.has_perms(
+            ['dcim.add_site', 'dcim.change_site']))
+
+    @override_settings(
+        REMOTE_AUTH_ENABLED=True,
+        REMOTE_AUTH_AUTO_CREATE_USER=True,
+        REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
+        LOGIN_REQUIRED=True
+    )
+    def test_remote_auth_remote_groups_default(self):
+        """
+        Test enabling remote authentication with group sync enabled with the default configuration.
+        """
+        headers = {
+            'HTTP_REMOTE_USER': 'remoteuser2',
+            'HTTP_REMOTE_USER_GROUP': 'Group 1|Group 2',
+        }
+
+        self.assertTrue(settings.REMOTE_AUTH_ENABLED)
+        self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
+        self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
+        self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
+        self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER,
+                         'HTTP_REMOTE_USER_GROUP')
+        self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, '|')
+
+        # Create required groups
+        groups = (
+            Group(name='Group 1'),
+            Group(name='Group 2'),
+            Group(name='Group 3'),
+        )
+        Group.objects.bulk_create(groups)
+
+        response = self.client.get(reverse('home'), follow=True, **headers)
+        self.assertEqual(response.status_code, 200)
+
+        new_user = User.objects.get(username='remoteuser2')
+        self.assertEqual(int(self.client.session.get(
+            '_auth_user_id')), new_user.pk, msg='Authentication failed')
+        self.assertListEqual(
+            [groups[0], groups[1]],
+            list(new_user.groups.all())
+        )
+
+    @override_settings(
+        REMOTE_AUTH_ENABLED=True,
+        REMOTE_AUTH_AUTO_CREATE_USER=True,
+        REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
+        REMOTE_AUTH_HEADER='HTTP_FOO',
+        REMOTE_AUTH_GROUP_HEADER='HTTP_BAR',
+        LOGIN_REQUIRED=True
+    )
+    def test_remote_auth_remote_groups_custom_header(self):
+        """
+        Test enabling remote authentication with group sync enabled with the default configuration.
+        """
+        headers = {
+            'HTTP_FOO': 'remoteuser2',
+            'HTTP_BAR': 'Group 1|Group 2',
+        }
+
+        self.assertTrue(settings.REMOTE_AUTH_ENABLED)
+        self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
+        self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
+        self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_FOO')
+        self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, 'HTTP_BAR')
+        self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, '|')
+
+        # Create required groups
+        groups = (
+            Group(name='Group 1'),
+            Group(name='Group 2'),
+            Group(name='Group 3'),
+        )
+        Group.objects.bulk_create(groups)
+
+        response = self.client.get(reverse('home'), follow=True, **headers)
+        self.assertEqual(response.status_code, 200)
+
+        new_user = User.objects.get(username='remoteuser2')
+        self.assertEqual(int(self.client.session.get(
+            '_auth_user_id')), new_user.pk, msg='Authentication failed')
+        self.assertListEqual(
+            [groups[0], groups[1]],
+            list(new_user.groups.all())
+        )
 
 
 class ObjectPermissionAPIViewTestCase(TestCase):
@@ -206,7 +300,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
     def test_get_object(self):
 
         # Attempt to retrieve object without permission
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[0].pk})
         response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 403)
 
@@ -221,12 +316,14 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
 
         # Retrieve permitted object
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[0].pk})
         response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 200)
 
         # Attempt to retrieve non-permitted object
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[3].pk})
         response = self.client.get(url, **self.header)
         self.assertEqual(response.status_code, 404)
 
@@ -292,7 +389,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
 
         # Attempt to edit an object without permission
         data = {'site': self.sites[0].pk}
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[0].pk})
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
 
@@ -308,19 +406,22 @@ class ObjectPermissionAPIViewTestCase(TestCase):
 
         # Attempt to edit a non-permitted object
         data = {'site': self.sites[0].pk}
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[3].pk})
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 404)
 
         # Edit a permitted object
         data['status'] = 'reserved'
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[0].pk})
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 200)
 
         # Attempt to modify a permitted object to a non-permitted object
         data['site'] = self.sites[1].pk
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[0].pk})
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
 
@@ -328,7 +429,8 @@ class ObjectPermissionAPIViewTestCase(TestCase):
     def test_delete_object(self):
 
         # Attempt to delete an object without permission
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[0].pk})
         response = self.client.delete(url, format='json', **self.header)
         self.assertEqual(response.status_code, 403)
 
@@ -343,11 +445,13 @@ class ObjectPermissionAPIViewTestCase(TestCase):
         obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
 
         # Attempt to delete a non-permitted object
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[3].pk})
         response = self.client.delete(url, format='json', **self.header)
         self.assertEqual(response.status_code, 404)
 
         # Delete a permitted object
-        url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+        url = reverse('ipam-api:prefix-detail',
+                      kwargs={'pk': self.prefixes[0].pk})
         response = self.client.delete(url, format='json', **self.header)
         self.assertEqual(response.status_code, 204)

+ 2 - 1
netbox/netbox/urls.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.conf.urls import include
 from django.urls import path, re_path
+from django.views.decorators.csrf import csrf_exempt
 from django.views.static import serve
 from drf_yasg import openapi
 from drf_yasg.views import get_schema_view
@@ -63,7 +64,7 @@ _patterns = [
     re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
 
     # GraphQL
-    path('graphql/', GraphQLView.as_view(graphiql=True, schema=schema), name='graphql'),
+    path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),
 
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),

+ 5 - 14
netbox/netbox/views/__init__.py

@@ -26,7 +26,6 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
 from netbox.forms import SearchForm
 from tenancy.models import Tenant
-from utilities.tables import paginate_table
 from virtualization.models import Cluster, VirtualMachine
 
 
@@ -154,26 +153,18 @@ class HomeView(View):
 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():
 
+            # If an object type has been specified, redirect to the dedicated view for it
             if form.cleaned_data['obj_type']:
-                # Searching for a single type of object
-                obj_types = [form.cleaned_data['obj_type']]
-            else:
-                # Searching all object types
-                obj_types = SEARCH_TYPES.keys()
+                object_type = form.cleaned_data['obj_type']
+                url = reverse(SEARCH_TYPES[object_type]['url'])
+                return redirect(f"{url}?q={form.cleaned_data['q']}")
 
-            for obj_type in obj_types:
+            for obj_type in SEARCH_TYPES.keys():
 
                 queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view')
                 filterset = SEARCH_TYPES[obj_type]['filterset']

+ 3 - 3
netbox/netbox/views/generic.py

@@ -1010,10 +1010,10 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
         # Are we deleting *all* objects in the queryset or just a selected subset?
         if request.POST.get('_all'):
+            qs = model.objects.all()
             if self.filterset is not None:
-                pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
-            else:
-                pk_list = model.objects.values_list('pk', flat=True)
+                qs = self.filterset(request.GET, qs).qs
+            pk_list = qs.only('pk').values_list('pk', flat=True)
         else:
             pk_list = [int(pk) for pk in request.POST.getlist('pk')]
 

File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox-light.css


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox-print.css


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox.js


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox.js.map


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/status.js


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 1 - 23
netbox/project-static/src/select/api/apiSelect.ts

@@ -149,13 +149,6 @@ export class APISelect {
    */
   private more: Nullable<string> = null;
 
-  /**
-   * This element's options come from the server pre-sorted and should not be sorted client-side.
-   * Determined by the existence of the `pre-sorted` attribute on the base `<select/>` element, or
-   * by existence of specific fields such as `_depth`.
-   */
-  private preSorted: boolean = false;
-
   /**
    * Array of options values which should be considered disabled or static.
    */
@@ -171,10 +164,6 @@ export class APISelect {
     this.base = base;
     this.name = base.name;
 
-    if (base.getAttribute('pre-sorted') !== null) {
-      this.preSorted = true;
-    }
-
     if (hasUrl(base)) {
       const url = base.getAttribute('data-url') as string;
       this.url = url;
@@ -294,9 +283,7 @@ export class APISelect {
   }
 
   /**
-   * Sort incoming options by label and apply the new options to both the SlimSelect instance and
-   * this manager's state. If the `preSorted` attribute exists on the base `<select/>` element,
-   * the options will *not* be sorted.
+   * Apply new options to both the SlimSelect instance and this manager's state.
    */
   private set options(optionsIn: Option[]) {
     let newOptions = optionsIn;
@@ -304,12 +291,6 @@ export class APISelect {
     if (this.nullOption !== null) {
       newOptions = [this.nullOption, ...newOptions];
     }
-    // Sort options unless this element is pre-sorted.
-    if (!this.preSorted) {
-      newOptions = newOptions.sort((a, b) =>
-        a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
-      );
-    }
     // Deduplicate options each time they're set.
     const deduplicated = uniqueByProperty(newOptions, 'value');
     // Determine if the new options have a placeholder.
@@ -471,9 +452,6 @@ export class APISelect {
 
       if (typeof result._depth === 'number' && result._depth > 0) {
         // If the object has a `_depth` property, indent its display text.
-        if (!this.preSorted) {
-          this.preSorted = true;
-        }
         text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`;
       }
       const data = {} as Record<string, string>;

+ 1 - 1
netbox/project-static/src/util.ts

@@ -409,7 +409,7 @@ export function createElement<
  * @returns Degrees in Fahrenheit.
  */
 export function cToF(celsius: number): number {
-  return celsius * (9 / 5) + 32;
+  return Math.round((celsius * (9 / 5) + 32 + Number.EPSILON) * 10) / 10;
 }
 
 /**

+ 2 - 2
netbox/project-static/styles/select.scss

@@ -19,7 +19,7 @@ $spacing-s: $input-padding-x;
   --nbx-select-option-selected-bg: #{$gray-300};
   --nbx-select-option-hover-bg: #{$blue};
   --nbx-select-option-hover-color: #{$white};
-  --nbx-select-placeholder-color: #{$gray-600};
+  --nbx-select-placeholder-color: #{$gray-500};
   --nbx-select-value-color: #{$white};
   &[data-netbox-color-mode='dark'] {
     // Dark Mode Variables.
@@ -27,7 +27,7 @@ $spacing-s: $input-padding-x;
     --nbx-select-option-selected-bg: #{$gray-500};
     --nbx-select-option-hover-bg: #{$blue-200};
     --nbx-select-option-hover-color: #{color-contrast($blue-200)};
-    --nbx-select-placeholder-color: #{$gray-500};
+    --nbx-select-placeholder-color: #{$gray-700};
     --nbx-select-value-color: #{$black};
   }
 }

+ 1 - 1
netbox/project-static/styles/theme-dark.scss

@@ -82,7 +82,7 @@ $input-border-color: $gray-700;
 $input-focus-bg: $input-bg;
 $input-focus-border-color: tint-color($component-active-bg, 10%);
 $input-focus-color: $input-color;
-$input-placeholder-color: $gray-300;
+$input-placeholder-color: $gray-700;
 $input-plaintext-color: $body-color;
 
 $form-check-input-active-filter: brightness(90%);

+ 1 - 0
netbox/project-static/styles/theme-light.scss

@@ -40,6 +40,7 @@ $list-group-disabled-color: $gray-500;
 
 $table-flush-header-bg: $gray-100;
 
+$input-placeholder-color: $gray-500;
 $form-select-disabled-color: $gray-600;
 
 // Tabbed content

+ 11 - 2
netbox/templates/dcim/cable_trace.html

@@ -5,7 +5,10 @@
 
 {% block content %}
     <div class="row">
+
+        {# Cable trace SVG & options #}
         <div class="col col-md-5">
+          {% if path %}
             <div class="text-center my-3">
                 <object data="{{ svg_url }}" class="rack_elevation"></object>
                 <a class="btn btn-outline-primary btn-sm my-3" href="{{ svg_url }}">
@@ -51,9 +54,15 @@
                     {% endif %}
                 {% endwith %}
             </div>
+          {% else %}
+            <h3 class="text-center text-muted my-3">
+              No paths found
+            </h3>
+          {% endif %}
         </div>
-        <div class="col col-md-7">
 
+        {# Related paths #}
+        <div class="col col-md-7">
             <div class="card">
                 <h5 class="card-header">
                   Related Paths
@@ -95,7 +104,7 @@
                     </table>
                 </div>
             </div>
-
         </div>
+
     </div>
 {% endblock %}

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

@@ -95,11 +95,11 @@
                         </tr>
                         <tr>
                             <th scope="row">Serial Number</th>
-                            <td><code>{{ object.serial|placeholder }}</code></td>
+                            <td class="font-monospace">{{ object.serial|placeholder }}</td>
                         </tr>
                         <tr>
                             <th scope="row">Asset Tag</th>
-                            <td><span>{{ object.asset_tag|placeholder }}</span></td>
+                            <td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
                         </tr>
                     </table>
                 </div>

+ 6 - 4
netbox/templates/dcim/inc/devicetype_component_table.html

@@ -1,4 +1,6 @@
 {% load helpers %}
+{% load render_table from django_tables2 %}
+
 {% if perms.dcim.change_devicetype %}
     <form method="post">
         {% csrf_token %}
@@ -6,8 +8,8 @@
             <h5 class="card-header">
                 {{ title }}
             </h5>
-            <div class="card-body">
-                {% include 'inc/responsive_table.html' %}
+            <div class="card-body table-responsive">
+                {% render_table table 'inc/table.html' %}
             </div>
             <div class="card-footer noprint">
                 {% if table.rows %}
@@ -36,8 +38,8 @@
         <h5 class="card-header">
             {{ title }}
         </h5>
-        <div class="card-body">
-            {% include 'inc/responsive_table.html' %}
+        <div class="card-body table-responsive">
+            {% render_table table 'inc/table.html' %}
         </div>
     </div>
 {% endif %}

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

@@ -96,11 +96,11 @@
                     </tr>
                     <tr>
                         <th scope="row">Serial Number</th>
-                        <td>{{ object.serial|placeholder }}</td>
+                        <td class="font-monospace">{{ object.serial|placeholder }}</td>
                     </tr>
                     <tr>
                         <th scope="row">Asset Tag</th>
-                        <td>{{ object.asset_tag|placeholder }}</td>
+                        <td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
                     </tr>
                     <tr>
                         <th scope="row">Devices</th>

+ 35 - 27
netbox/templates/extras/report.html

@@ -1,4 +1,4 @@
-{% extends 'base/layout.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 
 {% block title %}{{ report.name }}{% endblock %}
@@ -8,31 +8,39 @@
   <li class="breadcrumb-item"><a href="{% url 'extras:report_list' %}#module.{{ report.module }}">{{ report.module|bettertitle }}</a></li>
 {% endblock %}
 
-{% block content %}
-{% if report.description %}
-    <p class="text-muted">{{ report.description|render_markdown }}</p>
-{% endif %}
-{% if perms.extras.run_report %}
-<div class="float-end noprint">
-    <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
-        {% csrf_token %}
-        <button type="submit" name="_run" class="btn btn-primary">
-            {% if report.result %}
-                <i class="mdi mdi-replay"></i> Run Again
-            {% else %}
-                <i class="mdi mdi-play"></i> Run Report
-            {% endif %}
-        </button>
-    </form>
-</div>
-{% endif %}
-<div class="row">
-    <div class="col col-md-12">
-        {% if report.result %}
-            Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
-                <strong>{{ report.result.created|annotated_date }}</strong>
-            </a>
-        {% endif %}
+{% block subtitle %}
+  {% if report.description %}
+    <div class="object-subtitle">
+      <div class="text-muted">{{ report.description|render_markdown }}</div>
     </div>
-</div>
+  {% endif %}
+{% endblock %}
+
+{% block controls %}{% endblock %}
+{% block tabs %}{% endblock %}
+
+{% block content-wrapper %}
+  {% if perms.extras.run_report %}
+    <div class="px-3 float-end noprint">
+        <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
+            {% csrf_token %}
+            <button type="submit" name="_run" class="btn btn-primary">
+                {% if report.result %}
+                    <i class="mdi mdi-replay"></i> Run Again
+                {% else %}
+                    <i class="mdi mdi-play"></i> Run Report
+                {% endif %}
+            </button>
+        </form>
+    </div>
+  {% endif %}
+  <div class="row px-3">
+      <div class="col col-md-12">
+          {% if report.result %}
+              Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
+                  <strong>{{ report.result.created|annotated_date }}</strong>
+              </a>
+          {% endif %}
+      </div>
+  </div>
 {% endblock %}

+ 4 - 6
netbox/templates/extras/report_result.html

@@ -2,15 +2,13 @@
 {% load helpers %}
 {% load static %}
 
-{% block title %}{{ report.name }} <span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>{% endblock %}
-
 {% block head %}
 <script src="{% static 'jobs.js' %}?v{{ settings.VERSION }}"
         onerror="window.location='{% url 'media_failure' %}?filename=jobs.js'"></script>
 {% endblock %}
 
-{% block content %}
-<div class="row">
+{% block content-wrapper %}
+<div class="row px-3">
     <div class="col col-md-12">
         <p>
             Run: <strong>{{ result.created|annotated_date }}</strong>
@@ -21,7 +19,7 @@
                     <span class="visually-hidden">Loading...</span>
                 </div>
             {% endif %}
-            
+            <span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>
         </p>
         {% if result.completed %}
             <div class="card">
@@ -32,7 +30,7 @@
                     <table class="table table-hover">
                         {% for method, data in result.data.items %}
                             <tr>
-                                <td><code><a href="#{{ method }}">{{ method }}</a></code></td>
+                                <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
                                 <td class="text-end report-stats">
                                     <span class="badge bg-success">{{ data.success }}</span>
                                     <span class="badge bg-info">{{ data.info }}</span>

+ 3 - 1
netbox/templates/extras/script.html

@@ -1,4 +1,4 @@
-{% extends 'base/layout.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load form_helpers %}
 {% load log_levels %}
@@ -16,6 +16,8 @@
   </div>
 {% endblock %}
 
+{% block controls %}{% endblock %}
+
 {% block tabs %}
 <ul class="nav nav-tabs px-3">
   <li class="nav-item" role="presentation">

+ 2 - 8
netbox/templates/extras/webhook.html

@@ -79,7 +79,7 @@
           </tr>
           <tr>
             <th scope="row">Payload URL</th>
-            <td><code>{{ object.payload_url }}</code></td>
+            <td class="font-monospace">{{ object.payload_url }}</td>
           </tr>
           <tr>
             <th scope="row">HTTP Content Type</th>
@@ -110,13 +110,7 @@
           </tr>
           <tr>
             <th scope="row">CA File Path</th>
-            <td>
-              {% if object.ca_file_path %}
-                <code>{{ object.ca_file_path }}</code>
-              {% else %}
-                &mdash
-              {% endif %}
-            </td>
+            <td>{{ object.ca_file_path|placeholder }}</td>
           </tr>
         </table>
       </div>

+ 2 - 2
netbox/templates/home.html

@@ -71,8 +71,8 @@
               <i class="mdi mdi-clipboard-clock"></i>
               <span class="ms-1">Change Log</span>
             </h6>
-            <div class="card-body">
-              {% include 'inc/responsive_table.html' with table=changelog_table %}
+            <div class="card-body table-responsive">
+              {% render_table changelog_table 'inc/table.html' %}
             </div>
           </div>
         </div>

+ 4 - 1
netbox/templates/import_success.html

@@ -1,9 +1,12 @@
 {% extends 'base/layout.html' %}
+{% load render_table from django_tables2 %}
 
 {% block title %}Import Completed{% endblock %}
 
 {% block content %}
-    {% include 'inc/responsive_table.html' %}
+    <div class="table-responsive">
+        {% render_table table 'inc/table.html' %}
+    </div>
     {% if return_url %}
         <a href="{{ return_url }}" class="btn btn-outline-dark">View All</a>
     {% endif %}

+ 0 - 5
netbox/templates/inc/responsive_table.html

@@ -1,5 +0,0 @@
-{% load render_table from django_tables2 %}
-
-<div class="table-responsive">
-    {% render_table table 'inc/table.html' %}
-</div>

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

@@ -77,6 +77,6 @@
 <div class="row mb-3">
     <div class="col col-md-12">
         {% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
-	</div>
+	  </div>
 </div>
 {% endblock %}

+ 2 - 2
netbox/templates/ipam/prefix/prefixes.html

@@ -15,9 +15,9 @@
 {% block content %}
   <div class="row">
     <div class="col col-md-12">
-      {% include 'inc/table_controls.html' with table_modal="PrefixDetailTable_config" %}
+      {% include 'inc/table_controls.html' with table_modal="PrefixTable_config" %}
       {% include 'utilities/obj_table.html' with heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
     </div>
   </div>
-  {% table_config_form table table_name="PrefixDetailTable" %}
+  {% table_config_form table table_name="PrefixTable" %}
 {% endblock %}

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

@@ -13,7 +13,7 @@
                 <table class="table table-hover attr-table">
                     <tr>
                         <th scope="row">Name</th>
-                        <td><code>{{ object.name }}</code></td>
+                        <td class="font-monospace">{{ object.name }}</td>
                     </tr>
                     <tr>
                         <th  scope="row">Tenant</th>

+ 3 - 2
netbox/templates/ipam/vlan.html

@@ -1,5 +1,6 @@
 {% extends 'ipam/vlan/base.html' %}
 {% load helpers %}
+{% load render_table from django_tables2 %}
 {% load plugins %}
 
 {% block content %}
@@ -92,8 +93,8 @@
                 <h5 class="card-header">
                     Prefixes
                 </h5>
-                <div class="card-body">
-                    {% include 'inc/responsive_table.html' with table=prefix_table %}
+                <div class="card-body table-responsive">
+                    {% render_table prefix_table 'inc/table.html' %}
                 </div>
                 {% if perms.ipam.add_prefix %}
                 <div class="card-footer text-end noprint">

+ 2 - 2
netbox/templates/search.html

@@ -12,7 +12,7 @@
                 <div class="col col-md-9">
                     {% for obj_type in results %}
                         <div class="card">
-                            <h5 class="card-header">{{ obj_type.name|bettertitle }}</h5>
+                            <h5 class="card-header" id="{{ obj_type.name|lower }}">{{ obj_type.name|bettertitle }}</h5>
                             <div class="card-body table-responsive">
                                 {% render_table obj_type.table 'inc/table.html' %}
                             </div>
@@ -54,7 +54,7 @@
         {% endif %}
     {% else %}
         <div class="row">
-            <div class="col col-12 col-lg-4 offset-lg-4">
+            <div class="col col-12 col-lg-6 offset-lg-3">
                 <form action="{% url 'search' %}" method="get" class="form form-horizontal">
                     <div class="card">
                         <h5 class="card-header">

+ 9 - 2
netbox/templates/utilities/obj_table.html

@@ -1,4 +1,5 @@
 {% load helpers %}
+{% load render_table from django_tables2 %}
 
 {% if permissions.change or permissions.delete %}
     <form method="post" class="form form-horizontal">
@@ -7,6 +8,7 @@
 
         {% if table.paginator.num_pages > 1 %}
             <div id="select-all-box" class="d-none card noprint">
+              <div class="card-body">
                 <div class="float-end">
                     {% if bulk_edit_url and permissions.change %}
                         <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
@@ -25,10 +27,13 @@
                     Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
                     </label>
                 </div>
+              </div>
             </div>
         {% endif %}
 
-        {% include table_template|default:'inc/responsive_table.html' %}
+        <div class="table-responsive">
+          {% render_table table 'inc/table.html' %}
+        </div>
 
         <div class="float-start noprint">
             {% block extra_actions %}{% endblock %}
@@ -48,7 +53,9 @@
     </form>
 {% else %}
 
-    {% include table_template|default:'inc/responsive_table.html' %}
+    <div class="table-responsive">
+      {% render_table table 'inc/table.html' %}
+    </div>
 
 {% endif %}
 

+ 3 - 2
netbox/templates/virtualization/cluster/devices.html

@@ -1,5 +1,6 @@
 {% extends 'virtualization/cluster/base.html' %}
 {% load helpers %}
+{% load render_table from django_tables2 %}
 
 {% block content %}
 <div class="row">
@@ -10,8 +11,8 @@
       </h5>
       <form action="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}" method="post">
       {% csrf_token %}
-      <div class="card-body">
-        {% include 'inc/responsive_table.html' with table=devices_table %}
+      <div class="card-body table-responsive">
+        {% render_table devices_table 'inc/table.html' %}
       </div>
       {% if perms.virtualization.change_cluster %}
         <div class="card-footer noprint">

+ 3 - 2
netbox/templates/virtualization/cluster/virtual_machines.html

@@ -1,5 +1,6 @@
 {% extends 'virtualization/cluster/base.html' %}
 {% load helpers %}
+{% load render_table from django_tables2 %}
 
 {% block content %}
 <div class="row">
@@ -8,8 +9,8 @@
       <h5 class="card-header">
         Virtual Machines
       </h5>
-      <div class="card-body">
-        {% include 'inc/responsive_table.html' with table=virtualmachines_table %}
+      <div class="card-body table-responsive">
+        {% render_table virtualmachines_table 'inc/table.html' %}
       </div>
     </div>
   </div>

+ 15 - 0
netbox/tenancy/migrations/0002_tenant_ordering.py

@@ -0,0 +1,15 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0001_squashed_0012'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='tenant',
+            options={'ordering': ['name']},
+        ),
+    ]

+ 1 - 1
netbox/tenancy/models.py

@@ -83,7 +83,7 @@ class Tenant(PrimaryModel):
     ]
 
     class Meta:
-        ordering = ['group', 'name']
+        ordering = ['name']
 
     def __str__(self):
         return self.name

+ 11 - 2
netbox/tenancy/tables.py

@@ -1,8 +1,16 @@
 import django_tables2 as tables
 
-from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn
+from utilities.tables import (
+    BaseTable, ButtonsColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
+)
 from .models import Tenant, TenantGroup
 
+__all__ = (
+    'TenantColumn',
+    'TenantGroupTable',
+    'TenantTable',
+)
+
 
 #
 # Table columns
@@ -60,11 +68,12 @@ class TenantTable(BaseTable):
     name = tables.Column(
         linkify=True
     )
+    comments = MarkdownColumn()
     tags = TagColumn(
         url_name='tenancy:tenant_list'
     )
 
     class Meta(BaseTable.Meta):
         model = Tenant
-        fields = ('pk', 'name', 'slug', 'group', 'description', 'tags')
+        fields = ('pk', 'name', 'slug', 'group', 'description', 'comments', 'tags')
         default_columns = ('pk', 'name', 'group', 'description')

+ 1 - 3
netbox/tenancy/views.py

@@ -32,9 +32,7 @@ class TenantGroupView(generic.ObjectView):
         tenants = Tenant.objects.restrict(request.user, 'view').filter(
             group=instance
         )
-
-        tenants_table = tables.TenantTable(tenants)
-        tenants_table.columns.hide('group')
+        tenants_table = tables.TenantTable(tenants, exclude=('group',))
         paginate_table(tenants_table, request)
 
         return {

+ 24 - 20
netbox/utilities/forms/utils.py

@@ -122,28 +122,32 @@ def get_selected_values(form, field_name):
         form.is_valid()
     filter_data = form.cleaned_data.get(field_name)
     field = form.fields[field_name]
-    # Selection field
-    if hasattr(field, 'choices'):
-        try:
-            choices = unpack_grouped_choices(field.choices)
-
-            if hasattr(field, 'null_option'):
-                # If the field has a `null_option` attribute set and it is selected,
-                # add it to the field's grouped choices.
-                if field.null_option is not None and None in filter_data:
-                    choices.append((settings.FILTERS_NULL_CHOICE_VALUE, field.null_option))
-
-            return [
-                label for value, label in choices if str(value) in filter_data or None in filter_data
-            ]
-        except TypeError:
-            # Field uses dynamic choices. Show all that have been populated.
-            return [
-                subwidget.choice_label for subwidget in form[field_name].subwidgets
-            ]
 
     # Non-selection field
-    return [str(filter_data)]
+    if not hasattr(field, 'choices'):
+        return [str(filter_data)]
+
+    # Get choice labels
+    if type(field.choices) is forms.models.ModelChoiceIterator:
+        # Field uses dynamic choices: show all that have been populated on the widget
+        values = [
+            subwidget.choice_label for subwidget in form[field_name].subwidgets
+        ]
+
+    else:
+        # Static selection field
+        choices = unpack_grouped_choices(field.choices)
+        values = [
+            label for value, label in choices if str(value) in filter_data or None in filter_data
+        ]
+
+    if hasattr(field, 'null_option'):
+        # If the field has a `null_option` attribute set and it is selected,
+        # add it to the field's grouped choices.
+        if field.null_option is not None and None in filter_data:
+            values.append(field.null_option)
+
+    return values
 
 
 def add_blank_choice(choices):

+ 22 - 1
netbox/utilities/tables.py

@@ -12,7 +12,6 @@ from django_tables2.data import TableQuerysetData
 from django_tables2.utils import Accessor
 
 from extras.models import CustomField
-from extras.utils import FeatureQuery
 from .utils import content_type_name
 from .paginator import EnhancedPaginator, get_paginate_count
 
@@ -395,6 +394,28 @@ class UtilizationColumn(tables.TemplateColumn):
         return f'{value}%'
 
 
+class MarkdownColumn(tables.TemplateColumn):
+    """
+    Render a Markdown string.
+    """
+    template_code = """
+    {% load helpers %}
+    {% if value %}
+      {{ value|render_markdown }}
+    {% else %}
+      &mdash;
+    {% endif %}
+    """
+
+    def __init__(self):
+        super().__init__(
+            template_code=self.template_code
+        )
+
+    def value(self, value):
+        return value
+
+
 #
 # Pagination
 #

+ 1 - 1
netbox/utilities/templatetags/helpers.py

@@ -411,10 +411,10 @@ def applied_filters(form, query_params):
     Display the active filters for a given filter form.
     """
     form.is_valid()
-    querydict = query_params.copy()
 
     applied_filters = []
     for filter_name in form.changed_data:
+        querydict = query_params.copy()
         if filter_name not in querydict:
             continue
 

+ 0 - 1
netbox/virtualization/__init__.py

@@ -1 +0,0 @@
-default_app_config = 'virtualization.apps.VirtualizationConfig'

+ 27 - 3
netbox/virtualization/forms.py

@@ -9,7 +9,7 @@ from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldModelBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm,
-    CustomFieldModelFilterForm, CustomFieldsMixin,
+    CustomFieldModelFilterForm, CustomFieldsMixin, LocalConfigContextFilterForm,
 )
 from extras.models import Tag
 from ipam.models import IPAddress, VLAN, VLANGroup
@@ -61,6 +61,18 @@ class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['description']
 
 
+class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = ClusterType
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
 #
 # Cluster groups
 #
@@ -97,6 +109,18 @@ class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm):
         nullable_fields = ['description']
 
 
+class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
+    model = ClusterGroup
+    field_groups = [
+        ['q'],
+    ]
+    q = forms.CharField(
+        required=False,
+        widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
+        label=_('Search')
+    )
+
+
 #
 # Clusters
 #
@@ -545,13 +569,13 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM
         ]
 
 
-class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
+class VirtualMachineFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
     model = VirtualMachine
     field_groups = [
         ['q', 'tag'],
         ['cluster_group_id', 'cluster_type_id', 'cluster_id'],
         ['region_id', 'site_group_id', 'site_id'],
-        ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip'],
+        ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'],
         ['tenant_group_id', 'tenant_id'],
     ]
     q = forms.CharField(

+ 13 - 23
netbox/virtualization/tables.py

@@ -3,7 +3,8 @@ from django.conf import settings
 from dcim.tables.devices import BaseInterfaceTable
 from tenancy.tables import TenantColumn
 from utilities.tables import (
-    BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn,
+    BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn,
+    ToggleColumn,
 )
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
@@ -11,12 +12,13 @@ __all__ = (
     'ClusterTable',
     'ClusterGroupTable',
     'ClusterTypeTable',
-    'VirtualMachineDetailTable',
     'VirtualMachineTable',
     'VirtualMachineVMInterfaceTable',
     'VMInterfaceTable',
 )
 
+PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4')
+
 VMINTERFACE_BUTTONS = """
 {% if perms.ipam.add_ipaddress %}
     <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-sm btn-success" title="Add IP Address">
@@ -91,13 +93,14 @@ class ClusterTable(BaseTable):
         url_params={'cluster_id': 'pk'},
         verbose_name='VMs'
     )
+    comments = MarkdownColumn()
     tags = TagColumn(
         url_name='virtualization:cluster_list'
     )
 
     class Meta(BaseTable.Meta):
         model = Cluster
-        fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count', 'tags')
+        fields = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags')
         default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
 
 
@@ -116,13 +119,7 @@ class VirtualMachineTable(BaseTable):
     )
     role = ColoredLabelColumn()
     tenant = TenantColumn()
-
-    class Meta(BaseTable.Meta):
-        model = VirtualMachine
-        fields = ('pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk')
-
-
-class VirtualMachineDetailTable(VirtualMachineTable):
+    comments = MarkdownColumn()
     primary_ip4 = tables.Column(
         linkify=True,
         verbose_name='IPv4 Address'
@@ -131,18 +128,11 @@ class VirtualMachineDetailTable(VirtualMachineTable):
         linkify=True,
         verbose_name='IPv6 Address'
     )
-    if settings.PREFER_IPV4:
-        primary_ip = tables.Column(
-            linkify=True,
-            order_by=('primary_ip4', 'primary_ip6'),
-            verbose_name='IP Address'
-        )
-    else:
-        primary_ip = tables.Column(
-            linkify=True,
-            order_by=('primary_ip6', 'primary_ip4'),
-            verbose_name='IP Address'
-        )
+    primary_ip = tables.Column(
+        linkify=True,
+        order_by=PRIMARY_IP_ORDERING,
+        verbose_name='IP Address'
+    )
     tags = TagColumn(
         url_name='virtualization:virtualmachine_list'
     )
@@ -151,7 +141,7 @@ class VirtualMachineDetailTable(VirtualMachineTable):
         model = VirtualMachine
         fields = (
             'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4',
-            'primary_ip6', 'primary_ip', 'tags',
+            'primary_ip6', 'primary_ip', 'comments', 'tags',
         )
         default_columns = (
             'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',

+ 13 - 9
netbox/virtualization/views.py

@@ -24,6 +24,8 @@ class ClusterTypeListView(generic.ObjectListView):
     queryset = ClusterType.objects.annotate(
         cluster_count=count_related(Cluster, 'type')
     )
+    filterset = filtersets.ClusterTypeFilterSet
+    filterset_form = forms.ClusterTypeFilterForm
     table = tables.ClusterTypeTable
 
 
@@ -37,9 +39,7 @@ class ClusterTypeView(generic.ObjectView):
             device_count=count_related(Device, 'cluster'),
             vm_count=count_related(VirtualMachine, 'cluster')
         )
-
-        clusters_table = tables.ClusterTable(clusters)
-        clusters_table.columns.hide('type')
+        clusters_table = tables.ClusterTable(clusters, exclude=('type',))
         paginate_table(clusters_table, request)
 
         return {
@@ -86,6 +86,8 @@ class ClusterGroupListView(generic.ObjectListView):
     queryset = ClusterGroup.objects.annotate(
         cluster_count=count_related(Cluster, 'group')
     )
+    filterset = filtersets.ClusterGroupFilterSet
+    filterset_form = forms.ClusterGroupFilterForm
     table = tables.ClusterGroupTable
 
 
@@ -99,9 +101,7 @@ class ClusterGroupView(generic.ObjectView):
             device_count=count_related(Device, 'cluster'),
             vm_count=count_related(VirtualMachine, 'cluster')
         )
-
-        clusters_table = tables.ClusterTable(clusters)
-        clusters_table.columns.hide('group')
+        clusters_table = tables.ClusterTable(clusters, exclude=('group',))
         paginate_table(clusters_table, request)
 
         return {
@@ -167,7 +167,11 @@ class ClusterVirtualMachinesView(generic.ObjectView):
 
     def get_extra_context(self, request, instance):
         virtualmachines = VirtualMachine.objects.restrict(request.user, 'view').filter(cluster=instance)
-        virtualmachines_table = tables.VirtualMachineTable(virtualmachines, orderable=False)
+        virtualmachines_table = tables.VirtualMachineTable(
+            virtualmachines,
+            exclude=('cluster',),
+            orderable=False
+        )
 
         return {
             'virtualmachines_table': virtualmachines_table,
@@ -311,7 +315,7 @@ class VirtualMachineListView(generic.ObjectListView):
     queryset = VirtualMachine.objects.all()
     filterset = filtersets.VirtualMachineFilterSet
     filterset_form = forms.VirtualMachineFilterForm
-    table = tables.VirtualMachineDetailTable
+    table = tables.VirtualMachineTable
     template_name = 'virtualization/virtualmachine_list.html'
 
 
@@ -426,9 +430,9 @@ class VMInterfaceView(generic.ObjectView):
         child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance)
         child_interfaces_tables = tables.VMInterfaceTable(
             child_interfaces,
+            exclude=('virtual_machine',),
             orderable=False
         )
-        child_interfaces_tables.columns.hide('virtual_machine')
 
         # Get assigned VLANs and annotate whether each is tagged or untagged
         vlans = []

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini