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

Merge branch 'feature' into 8366-job-scheduling

jeremystretch 3 лет назад
Родитель
Сommit
893925436d
100 измененных файлов с 1847 добавлено и 1064 удалено
  1. 9 5
      .github/ISSUE_TEMPLATE/documentation_change.yaml
  2. 11 1
      docs/installation/6-ldap.md
  3. 42 1
      docs/integrations/graphql-api.md
  4. 16 16
      docs/plugins/development/forms.md
  5. 4 4
      docs/plugins/development/graphql-api.md
  6. 1 0
      docs/plugins/development/index.md
  7. 29 0
      docs/plugins/development/search.md
  8. 9 9
      docs/plugins/development/tables.md
  9. 10 10
      docs/plugins/development/views.md
  10. 15 0
      docs/release-notes/version-3.3.md
  11. 22 0
      docs/release-notes/version-3.4.md
  12. 2 1
      mkdocs.yml
  13. 1 1
      netbox/circuits/apps.py
  14. 6 0
      netbox/circuits/forms/models.py
  15. 5 4
      netbox/circuits/graphql/types.py
  16. 34 0
      netbox/circuits/search.py
  17. 4 6
      netbox/circuits/tables/circuits.py
  18. 3 5
      netbox/circuits/tables/providers.py
  19. 1 1
      netbox/dcim/apps.py
  20. 1 1
      netbox/dcim/forms/connections.py
  21. 43 0
      netbox/dcim/forms/models.py
  22. 110 0
      netbox/dcim/graphql/gfk_mixins.py
  23. 23 12
      netbox/dcim/graphql/types.py
  24. 143 0
      netbox/dcim/search.py
  25. 20 9
      netbox/dcim/tables/devices.py
  26. 14 6
      netbox/dcim/tables/devicetypes.py
  27. 4 5
      netbox/dcim/tables/power.py
  28. 2 5
      netbox/dcim/tables/racks.py
  29. 7 18
      netbox/dcim/tables/sites.py
  30. 55 28
      netbox/dcim/views.py
  31. 1 2
      netbox/extras/apps.py
  32. 7 0
      netbox/extras/graphql/mixins.py
  33. 2 2
      netbox/extras/graphql/types.py
  34. 32 10
      netbox/extras/plugins/__init__.py
  35. 9 6
      netbox/extras/plugins/urls.py
  36. 0 33
      netbox/extras/plugins/utils.py
  37. 1 0
      netbox/extras/registry.py
  38. 14 0
      netbox/extras/search.py
  39. 13 0
      netbox/extras/tests/dummy_plugin/search.py
  40. 1 1
      netbox/ipam/apps.py
  41. 18 0
      netbox/ipam/forms/models.py
  42. 95 0
      netbox/ipam/graphql/gfk_mixins.py
  43. 12 5
      netbox/ipam/graphql/types.py
  44. 2 0
      netbox/ipam/models/services.py
  45. 69 0
      netbox/ipam/search.py
  46. 8 0
      netbox/netbox/authentication.py
  47. 21 28
      netbox/netbox/forms/__init__.py
  48. 49 3
      netbox/netbox/graphql/__init__.py
  49. 0 274
      netbox/netbox/search.py
  50. 33 0
      netbox/netbox/search/__init__.py
  51. 125 0
      netbox/netbox/search/backends.py
  52. 2 7
      netbox/netbox/settings.py
  53. 4 21
      netbox/netbox/views/__init__.py
  54. 1 2
      netbox/project-static/.eslintrc
  55. 1 1
      netbox/project-static/dist/cable_trace.css
  56. 0 0
      netbox/project-static/dist/config.js
  57. 0 0
      netbox/project-static/dist/config.js.map
  58. 0 0
      netbox/project-static/dist/graphiql.css
  59. 0 0
      netbox/project-static/dist/graphiql.js
  60. 0 0
      netbox/project-static/dist/graphiql.js.map
  61. 0 0
      netbox/project-static/dist/lldp.js
  62. 0 0
      netbox/project-static/dist/lldp.js.map
  63. BIN
      netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff
  64. BIN
      netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2
  65. BIN
      netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot
  66. BIN
      netbox/project-static/dist/materialdesignicons-webfont-WM6M6ZHQ.ttf
  67. 0 0
      netbox/project-static/dist/netbox-dark.css
  68. 0 0
      netbox/project-static/dist/netbox-external.css
  69. 0 0
      netbox/project-static/dist/netbox-light.css
  70. 0 0
      netbox/project-static/dist/netbox-print.css
  71. 0 0
      netbox/project-static/dist/netbox.js
  72. 0 0
      netbox/project-static/dist/netbox.js.map
  73. 1 1
      netbox/project-static/dist/rack_elevation.css
  74. 0 0
      netbox/project-static/dist/status.js
  75. 0 0
      netbox/project-static/dist/status.js.map
  76. 27 32
      netbox/project-static/package.json
  77. 5 7
      netbox/project-static/src/netbox.ts
  78. 1 1
      netbox/project-static/styles/select.scss
  79. 461 433
      netbox/project-static/yarn.lock
  80. 3 9
      netbox/templates/circuits/circuit.html
  81. 2 2
      netbox/templates/dcim/powerport.html
  82. 4 4
      netbox/templates/dcim/rearport.html
  83. 3 0
      netbox/tenancy/apps.py
  84. 18 0
      netbox/tenancy/forms/models.py
  85. 25 0
      netbox/tenancy/search.py
  86. 10 0
      netbox/tenancy/tables/columns.py
  87. 3 5
      netbox/tenancy/tables/tenants.py
  88. 9 0
      netbox/users/utils.py
  89. 6 4
      netbox/utilities/templatetags/search.py
  90. 3 0
      netbox/utilities/testing/api.py
  91. 7 4
      netbox/utilities/views.py
  92. 3 0
      netbox/virtualization/apps.py
  93. 12 0
      netbox/virtualization/forms/models.py
  94. 33 0
      netbox/virtualization/search.py
  95. 4 10
      netbox/virtualization/tables/clusters.py
  96. 4 7
      netbox/virtualization/tables/virtualmachines.py
  97. 1 1
      netbox/wireless/apps.py
  98. 6 0
      netbox/wireless/forms/models.py
  99. 26 0
      netbox/wireless/search.py
  100. 4 1
      requirements.txt

+ 9 - 5
.github/ISSUE_TEMPLATE/documentation_change.yaml

@@ -19,11 +19,15 @@ body:
       label: Area
       label: Area
       description: To what section of the documentation does this change primarily pertain?
       description: To what section of the documentation does this change primarily pertain?
       options:
       options:
-        - Installation instructions
-        - Configuration parameters
-        - Functionality/features
-        - REST API
-        - Administration/development
+        - Features
+        - Installation/upgrade
+        - Getting started
+        - Configuration
+        - Customization
+        - Integrations/API
+        - Plugins
+        - Administration
+        - Development
         - Other
         - Other
     validations:
     validations:
       required: true
       required: true

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

@@ -46,7 +46,7 @@ Next, create a file in the same directory as `configuration.py` (typically `/opt
 ### General Server Configuration
 ### General Server Configuration
 
 
 !!! info
 !!! info
-    When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
+    When using Active Directory you may need to specify a port on `AUTH_LDAP_SERVER_URI` to authenticate users from all domains in the forest. Use `3269` for secure, or `3268` for non-secure access to the GC (Global Catalog).
 
 
 ```python
 ```python
 import ldap
 import ldap
@@ -67,6 +67,16 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
 # Note that this is a NetBox-specific setting which sets:
 # Note that this is a NetBox-specific setting which sets:
 #     ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
 #     ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
 LDAP_IGNORE_CERT_ERRORS = True
 LDAP_IGNORE_CERT_ERRORS = True
+
+# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
+# Note that this is a NetBox-specific setting which sets:
+#     ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
+LDAP_CA_CERT_DIR = '/etc/ssl/certs'
+
+# Include this setting if you want to validate the LDAP server certificates against your own CA.
+# Note that this is a NetBox-specific setting which sets:
+#     ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
+LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
 ```
 ```
 
 
 STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
 STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.

+ 42 - 1
docs/integrations/graphql-api.md

@@ -47,7 +47,7 @@ NetBox provides both a singular and plural query field for each object type:
 
 
 For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
 For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
 
 
-For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/).
+For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/) as well as the [GraphQL queries documentation](https://graphql.org/learn/queries/).
 
 
 ## Filtering
 ## Filtering
 
 
@@ -56,6 +56,47 @@ The GraphQL API employs the same filtering logic as the UI and REST API. Filters
 ```
 ```
 {"query": "query {site_list(region:\"north-carolina\", status:\"active\") {name}}"}
 {"query": "query {site_list(region:\"north-carolina\", status:\"active\") {name}}"}
 ```
 ```
+In addition, filtering can be done on list of related objects as shown in the following query:
+
+```
+{
+  device_list {
+    id
+    name
+    interfaces(enabled: true) {
+      name
+    }
+  }
+}
+```
+
+## Multiple Return Types
+
+Certain queries can return multiple types of objects, for example cable terminations can return circuit terminations, console ports and many others.  These can be queried using [inline fragments](https://graphql.org/learn/schema/#union-types) as shown below:
+
+```
+{
+    cable_list {
+      id
+      a_terminations {
+        ... on CircuitTerminationType {
+          id
+          class_type
+        }
+        ... on ConsolePortType {
+          id
+          class_type
+        }
+        ... on ConsoleServerPortType {
+          id
+          class_type
+        }
+      }
+    }
+}
+
+```
+The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering.  It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
 
 
 ## Authentication
 ## Authentication
 
 

+ 16 - 16
docs/plugins/development/forms.md

@@ -144,73 +144,73 @@ class MyModelFilterForm(NetBoxModelFilterSetForm):
 In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
 In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
 
 
 ::: utilities.forms.ColorField
 ::: utilities.forms.ColorField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.CommentField
 ::: utilities.forms.CommentField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.JSONField
 ::: utilities.forms.JSONField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.MACAddressField
 ::: utilities.forms.MACAddressField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.SlugField
 ::: utilities.forms.SlugField
-    selection:
+    options:
       members: false
       members: false
 
 
 ## Choice Fields
 ## Choice Fields
 
 
 ::: utilities.forms.ChoiceField
 ::: utilities.forms.ChoiceField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.MultipleChoiceField
 ::: utilities.forms.MultipleChoiceField
-    selection:
+    options:
       members: false
       members: false
 
 
 ## Dynamic Object Fields
 ## Dynamic Object Fields
 
 
 ::: utilities.forms.DynamicModelChoiceField
 ::: utilities.forms.DynamicModelChoiceField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.DynamicModelMultipleChoiceField
 ::: utilities.forms.DynamicModelMultipleChoiceField
-    selection:
+    options:
       members: false
       members: false
 
 
 ## Content Type Fields
 ## Content Type Fields
 
 
 ::: utilities.forms.ContentTypeChoiceField
 ::: utilities.forms.ContentTypeChoiceField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.ContentTypeMultipleChoiceField
 ::: utilities.forms.ContentTypeMultipleChoiceField
-    selection:
+    options:
       members: false
       members: false
 
 
 ## CSV Import Fields
 ## CSV Import Fields
 
 
 ::: utilities.forms.CSVChoiceField
 ::: utilities.forms.CSVChoiceField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.CSVMultipleChoiceField
 ::: utilities.forms.CSVMultipleChoiceField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.CSVModelChoiceField
 ::: utilities.forms.CSVModelChoiceField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.CSVContentTypeField
 ::: utilities.forms.CSVContentTypeField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: utilities.forms.CSVMultipleContentTypeField
 ::: utilities.forms.CSVMultipleContentTypeField
-    selection:
+    options:
       members: false
       members: false

+ 4 - 4
docs/plugins/development/graphql-api.md

@@ -32,11 +32,11 @@ schema = MyQuery
 NetBox provides two object type classes for use by plugins.
 NetBox provides two object type classes for use by plugins.
 
 
 ::: netbox.graphql.types.BaseObjectType
 ::: netbox.graphql.types.BaseObjectType
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.graphql.types.NetBoxObjectType
 ::: netbox.graphql.types.NetBoxObjectType
-    selection:
+    options:
       members: false
       members: false
 
 
 ## GraphQL Fields
 ## GraphQL Fields
@@ -44,9 +44,9 @@ NetBox provides two object type classes for use by plugins.
 NetBox provides two field classes for use by plugins.
 NetBox provides two field classes for use by plugins.
 
 
 ::: netbox.graphql.fields.ObjectField
 ::: netbox.graphql.fields.ObjectField
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.graphql.fields.ObjectListField
 ::: netbox.graphql.fields.ObjectListField
-    selection:
+    options:
       members: false
       members: false

+ 1 - 0
docs/plugins/development/index.md

@@ -108,6 +108,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
 | `max_version`         | Maximum version of NetBox with which the plugin is compatible                                                            |
 | `max_version`         | Maximum version of NetBox with which the plugin is compatible                                                            |
 | `middleware`          | A list of middleware classes to append after NetBox's build-in middleware                                                |
 | `middleware`          | A list of middleware classes to append after NetBox's build-in middleware                                                |
 | `queues`              | A list of custom background task queues to create                                                                        |
 | `queues`              | A list of custom background task queues to create                                                                        |
+| `search_extensions`   | The dotted path to the list of search index classes (default: `search.indexes`)                                          |
 | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`)              |
 | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`)              |
 | `menu_items`          | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)                      |
 | `menu_items`          | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)                      |
 | `graphql_schema`      | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`)                                 |
 | `graphql_schema`      | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`)                                 |

+ 29 - 0
docs/plugins/development/search.md

@@ -0,0 +1,29 @@
+# Search
+
+Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below).
+
+```python
+# search.py
+from netbox.search import SearchMixin
+from .filters import MyModelFilterSet
+from .tables import MyModelTable
+from .models import MyModel
+
+class MyModelIndex(SearchMixin):
+    model = MyModel
+    queryset = MyModel.objects.all()
+    filterset = MyModelFilterSet
+    table = MyModelTable
+    url = 'plugins:myplugin:mymodel_list'
+```
+
+To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
+
+```python
+indexes = [MyModelIndex]
+```
+
+!!! tip
+    The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance.
+
+::: netbox.search.SearchIndex

+ 9 - 9
docs/plugins/development/tables.md

@@ -52,38 +52,38 @@ This will automatically apply any user-specific preferences for the table. (If u
 The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
 The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
 
 
 ::: netbox.tables.BooleanColumn
 ::: netbox.tables.BooleanColumn
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.tables.ChoiceFieldColumn
 ::: netbox.tables.ChoiceFieldColumn
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.tables.ColorColumn
 ::: netbox.tables.ColorColumn
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.tables.ColoredLabelColumn
 ::: netbox.tables.ColoredLabelColumn
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.tables.ContentTypeColumn
 ::: netbox.tables.ContentTypeColumn
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.tables.ContentTypesColumn
 ::: netbox.tables.ContentTypesColumn
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.tables.MarkdownColumn
 ::: netbox.tables.MarkdownColumn
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.tables.TagColumn
 ::: netbox.tables.TagColumn
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.tables.TemplateColumn
 ::: netbox.tables.TemplateColumn
-    selection:
+    options:
       members:
       members:
         - __init__
         - __init__

+ 10 - 10
docs/plugins/development/views.md

@@ -84,24 +84,24 @@ Below are the class definitions for NetBox's object views. These views handle CR
 ::: netbox.views.generic.base.BaseObjectView
 ::: netbox.views.generic.base.BaseObjectView
 
 
 ::: netbox.views.generic.ObjectView
 ::: netbox.views.generic.ObjectView
-    selection:
+    options:
       members:
       members:
         - get_object
         - get_object
         - get_template_name
         - get_template_name
 
 
 ::: netbox.views.generic.ObjectEditView
 ::: netbox.views.generic.ObjectEditView
-    selection:
+    options:
       members:
       members:
         - get_object
         - get_object
         - alter_object
         - alter_object
 
 
 ::: netbox.views.generic.ObjectDeleteView
 ::: netbox.views.generic.ObjectDeleteView
-    selection:
+    options:
       members:
       members:
         - get_object
         - get_object
 
 
 ::: netbox.views.generic.ObjectChildrenView
 ::: netbox.views.generic.ObjectChildrenView
-    selection:
+    options:
       members:
       members:
         - get_children
         - get_children
         - prep_table_data
         - prep_table_data
@@ -113,22 +113,22 @@ Below are the class definitions for NetBox's multi-object views. These views han
 ::: netbox.views.generic.base.BaseMultiObjectView
 ::: netbox.views.generic.base.BaseMultiObjectView
 
 
 ::: netbox.views.generic.ObjectListView
 ::: netbox.views.generic.ObjectListView
-    selection:
+    options:
       members:
       members:
         - get_table
         - get_table
         - export_table
         - export_table
         - export_template
         - export_template
 
 
 ::: netbox.views.generic.BulkImportView
 ::: netbox.views.generic.BulkImportView
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.views.generic.BulkEditView
 ::: netbox.views.generic.BulkEditView
-    selection:
+    options:
       members: false
       members: false
 
 
 ::: netbox.views.generic.BulkDeleteView
 ::: netbox.views.generic.BulkDeleteView
-    selection:
+    options:
       members:
       members:
         - get_form
         - get_form
 
 
@@ -137,12 +137,12 @@ Below are the class definitions for NetBox's multi-object views. These views han
 These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
 These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
 
 
 ::: netbox.views.generic.ObjectChangeLogView
 ::: netbox.views.generic.ObjectChangeLogView
-    selection:
+    options:
       members:
       members:
         - get_form
         - get_form
 
 
 ::: netbox.views.generic.ObjectJournalView
 ::: netbox.views.generic.ObjectJournalView
-    selection:
+    options:
       members:
       members:
         - get_form
         - get_form
 
 

+ 15 - 0
docs/release-notes/version-3.3.md

@@ -2,6 +2,21 @@
 
 
 ## v3.3.6 (FUTURE)
 ## v3.3.6 (FUTURE)
 
 
+### Enhancements
+
+* [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates
+* [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view
+
+### Bug Fixes
+
+* [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication
+* [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth
+* [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link
+* [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms
+* [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable
+* [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables
+* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
+
 ---
 ---
 
 
 ## v3.3.5 (2022-10-05)
 ## v3.3.5 (2022-10-05)

+ 22 - 0
docs/release-notes/version-3.4.md

@@ -17,15 +17,19 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
 
 
 ### Enhancements
 ### Enhancements
 
 
+* [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
 * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive
 * [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive
 * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects
 * [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects
 * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types
 * [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types
+* [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations
 * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
 * [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
 * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
 * [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
 * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
 * [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
+* [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields
 
 
 ### Plugins API
 ### Plugins API
 
 
+* [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex`
 * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus
 * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus
 * [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
 * [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
 * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter
 * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter
@@ -36,6 +40,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
 * [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model
 * [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model
 * [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model
 * [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model
 * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
 * [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
+* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function
 
 
 ### REST API Changes
 ### REST API Changes
 
 
@@ -54,3 +59,20 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
 
 
 * All object types now include a `display` field
 * All object types now include a `display` field
 * All cabled object types now include a `link_peers` field
 * All cabled object types now include a `link_peers` field
+* Add a `contacts` relationship for all relevant models
+* dcim.Cable
+    * Add A/B terminations fields
+* dcim.CableTermination
+    * Add `termination` field
+* dcim.InventoryItem
+    * Add `component` field
+* dcim.InventoryItemTemplate
+    * Add `component` field
+* ipam.FHRPGroupAssignment
+    * Add `interface` field
+* ipam.IPAddress
+    * Add `assigned_object` field
+* ipam.L2VPNTermination
+    * Add `assigned_object` field
+* ipam.VLANGroupType
+    * Add `scope` field

+ 2 - 1
mkdocs.yml

@@ -30,7 +30,7 @@ plugins:
             - os.chdir('netbox/')
             - os.chdir('netbox/')
             - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
             - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
             - django.setup()
             - django.setup()
-          rendering:
+          options:
             heading_level: 3
             heading_level: 3
             members_order: source
             members_order: source
             show_root_heading: true
             show_root_heading: true
@@ -132,6 +132,7 @@ nav:
             - GraphQL API: 'plugins/development/graphql-api.md'
             - GraphQL API: 'plugins/development/graphql-api.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
             - Exceptions: 'plugins/development/exceptions.md'
             - Exceptions: 'plugins/development/exceptions.md'
+            - Search: 'plugins/development/search.md'
     - Administration:
     - Administration:
         - Authentication:
         - Authentication:
             - Overview: 'administration/authentication/overview.md'
             - Overview: 'administration/authentication/overview.md'

+ 1 - 1
netbox/circuits/apps.py

@@ -6,4 +6,4 @@ class CircuitsConfig(AppConfig):
     verbose_name = "Circuits"
     verbose_name = "Circuits"
 
 
     def ready(self):
     def ready(self):
-        import circuits.signals
+        from . import signals, search

+ 6 - 0
netbox/circuits/forms/models.py

@@ -64,6 +64,12 @@ class ProviderNetworkForm(NetBoxModelForm):
 class CircuitTypeForm(NetBoxModelForm):
 class CircuitTypeForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Circuit Type', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
         fields = [
         fields = [

+ 5 - 4
netbox/circuits/graphql/types.py

@@ -1,6 +1,8 @@
+import graphene
+
 from circuits import filtersets, models
 from circuits import filtersets, models
 from dcim.graphql.mixins import CabledObjectMixin
 from dcim.graphql.mixins import CabledObjectMixin
-from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
+from extras.graphql.mixins import CustomFieldsMixin, TagsMixin, ContactsMixin
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
 
 
 __all__ = (
 __all__ = (
@@ -20,8 +22,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob
         filterset_class = filtersets.CircuitTerminationFilterSet
         filterset_class = filtersets.CircuitTerminationFilterSet
 
 
 
 
-class CircuitType(NetBoxObjectType):
-
+class CircuitType(NetBoxObjectType, ContactsMixin):
     class Meta:
     class Meta:
         model = models.Circuit
         model = models.Circuit
         fields = '__all__'
         fields = '__all__'
@@ -36,7 +37,7 @@ class CircuitTypeType(OrganizationalObjectType):
         filterset_class = filtersets.CircuitTypeFilterSet
         filterset_class = filtersets.CircuitTypeFilterSet
 
 
 
 
-class ProviderType(NetBoxObjectType):
+class ProviderType(NetBoxObjectType, ContactsMixin):
 
 
     class Meta:
     class Meta:
         model = models.Provider
         model = models.Provider

+ 34 - 0
netbox/circuits/search.py

@@ -0,0 +1,34 @@
+import circuits.filtersets
+import circuits.tables
+from circuits.models import Circuit, Provider, ProviderNetwork
+from netbox.search import SearchIndex, register_search
+from utilities.utils import count_related
+
+
+@register_search()
+class ProviderIndex(SearchIndex):
+    model = Provider
+    queryset = Provider.objects.annotate(count_circuits=count_related(Circuit, 'provider'))
+    filterset = circuits.filtersets.ProviderFilterSet
+    table = circuits.tables.ProviderTable
+    url = 'circuits:provider_list'
+
+
+@register_search()
+class CircuitIndex(SearchIndex):
+    model = Circuit
+    queryset = Circuit.objects.prefetch_related(
+        'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
+    )
+    filterset = circuits.filtersets.CircuitFilterSet
+    table = circuits.tables.CircuitTable
+    url = 'circuits:circuit_list'
+
+
+@register_search()
+class ProviderNetworkIndex(SearchIndex):
+    model = ProviderNetwork
+    queryset = ProviderNetwork.objects.prefetch_related('provider')
+    filterset = circuits.filtersets.ProviderNetworkFilterSet
+    table = circuits.tables.ProviderNetworkTable
+    url = 'circuits:providernetwork_list'

+ 4 - 6
netbox/circuits/tables/circuits.py

@@ -1,8 +1,9 @@
 import django_tables2 as tables
 import django_tables2 as tables
-
 from circuits.models import *
 from circuits.models import *
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
 from .columns import CommitRateColumn
 from .columns import CommitRateColumn
 
 
 __all__ = (
 __all__ = (
@@ -39,7 +40,7 @@ class CircuitTypeTable(NetBoxTable):
         default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
         default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
 
 
 
 
-class CircuitTable(TenancyColumnsMixin, NetBoxTable):
+class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     cid = tables.Column(
     cid = tables.Column(
         linkify=True,
         linkify=True,
         verbose_name='Circuit ID'
         verbose_name='Circuit ID'
@@ -58,9 +59,6 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
     )
     )
     commit_rate = CommitRateColumn()
     commit_rate = CommitRateColumn()
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='circuits:circuit_list'
         url_name='circuits:circuit_list'
     )
     )

+ 3 - 5
netbox/circuits/tables/providers.py

@@ -1,7 +1,8 @@
 import django_tables2 as tables
 import django_tables2 as tables
+from circuits.models import *
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
+from tenancy.tables import ContactsColumnMixin
 
 
-from circuits.models import *
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
 
 
 __all__ = (
 __all__ = (
@@ -10,7 +11,7 @@ __all__ = (
 )
 )
 
 
 
 
-class ProviderTable(NetBoxTable):
+class ProviderTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -31,9 +32,6 @@ class ProviderTable(NetBoxTable):
         verbose_name='Circuits'
         verbose_name='Circuits'
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='circuits:provider_list'
         url_name='circuits:provider_list'
     )
     )

+ 1 - 1
netbox/dcim/apps.py

@@ -8,7 +8,7 @@ class DCIMConfig(AppConfig):
     verbose_name = "DCIM"
     verbose_name = "DCIM"
 
 
     def ready(self):
     def ready(self):
-        import dcim.signals
+        from . import signals, search
         from .models import CableTermination
         from .models import CableTermination
 
 
         # Register denormalized fields
         # Register denormalized fields

+ 1 - 1
netbox/dcim/forms/connections.py

@@ -108,7 +108,7 @@ def get_cable_form(a_type, b_type):
                         label='Power Feed',
                         label='Power Feed',
                         disabled_indicator='_occupied',
                         disabled_indicator='_occupied',
                         query_params={
                         query_params={
-                            'powerpanel_id': f'$termination_{cable_end}_powerpanel',
+                            'power_panel_id': f'$termination_{cable_end}_powerpanel',
                         }
                         }
                     )
                     )
 
 

+ 43 - 0
netbox/dcim/forms/models.py

@@ -78,6 +78,12 @@ class RegionForm(NetBoxModelForm):
     )
     )
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Region', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = Region
         model = Region
         fields = (
         fields = (
@@ -92,6 +98,12 @@ class SiteGroupForm(NetBoxModelForm):
     )
     )
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Site Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = SiteGroup
         model = SiteGroup
         fields = (
         fields = (
@@ -213,6 +225,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
 class RackRoleForm(NetBoxModelForm):
 class RackRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Rack Role', (
+            'name', 'slug', 'color', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
         fields = [
         fields = [
@@ -341,6 +359,12 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
 class ManufacturerForm(NetBoxModelForm):
 class ManufacturerForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Manufacturer', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
         fields = [
         fields = [
@@ -413,6 +437,12 @@ class ModuleTypeForm(NetBoxModelForm):
 class DeviceRoleForm(NetBoxModelForm):
 class DeviceRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Device Role', (
+            'name', 'slug', 'color', 'vm_role', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
         fields = [
         fields = [
@@ -429,6 +459,13 @@ class PlatformForm(NetBoxModelForm):
         max_length=64
         max_length=64
     )
     )
 
 
+    fieldsets = (
+        ('Platform', (
+            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
+
+        )),
+    )
+
     class Meta:
     class Meta:
         model = Platform
         model = Platform
         fields = [
         fields = [
@@ -1584,6 +1621,12 @@ class InventoryItemForm(DeviceComponentForm):
 class InventoryItemRoleForm(NetBoxModelForm):
 class InventoryItemRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Inventory Item Role', (
+            'name', 'slug', 'color', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = InventoryItemRole
         model = InventoryItemRole
         fields = [
         fields = [

+ 110 - 0
netbox/dcim/graphql/gfk_mixins.py

@@ -2,24 +2,38 @@ import graphene
 from circuits.graphql.types import CircuitTerminationType
 from circuits.graphql.types import CircuitTerminationType
 from circuits.models import CircuitTermination
 from circuits.models import CircuitTermination
 from dcim.graphql.types import (
 from dcim.graphql.types import (
+    ConsolePortTemplateType,
     ConsolePortType,
     ConsolePortType,
+    ConsoleServerPortTemplateType,
     ConsoleServerPortType,
     ConsoleServerPortType,
+    FrontPortTemplateType,
     FrontPortType,
     FrontPortType,
+    InterfaceTemplateType,
     InterfaceType,
     InterfaceType,
     PowerFeedType,
     PowerFeedType,
+    PowerOutletTemplateType,
     PowerOutletType,
     PowerOutletType,
+    PowerPortTemplateType,
     PowerPortType,
     PowerPortType,
+    RearPortTemplateType,
     RearPortType,
     RearPortType,
 )
 )
 from dcim.models import (
 from dcim.models import (
     ConsolePort,
     ConsolePort,
+    ConsolePortTemplate,
     ConsoleServerPort,
     ConsoleServerPort,
+    ConsoleServerPortTemplate,
     FrontPort,
     FrontPort,
+    FrontPortTemplate,
     Interface,
     Interface,
+    InterfaceTemplate,
     PowerFeed,
     PowerFeed,
     PowerOutlet,
     PowerOutlet,
+    PowerOutletTemplate,
     PowerPort,
     PowerPort,
+    PowerPortTemplate,
     RearPort,
     RearPort,
+    RearPortTemplate,
 )
 )
 
 
 
 
@@ -57,3 +71,99 @@ class LinkPeerType(graphene.Union):
             return PowerPortType
             return PowerPortType
         if type(instance) == RearPort:
         if type(instance) == RearPort:
             return RearPortType
             return RearPortType
+
+
+class CableTerminationTerminationType(graphene.Union):
+    class Meta:
+        types = (
+            CircuitTerminationType,
+            ConsolePortType,
+            ConsoleServerPortType,
+            FrontPortType,
+            InterfaceType,
+            PowerFeedType,
+            PowerOutletType,
+            PowerPortType,
+            RearPortType,
+        )
+
+    @classmethod
+    def resolve_type(cls, instance, info):
+        if type(instance) == CircuitTermination:
+            return CircuitTerminationType
+        if type(instance) == ConsolePortType:
+            return ConsolePortType
+        if type(instance) == ConsoleServerPort:
+            return ConsoleServerPortType
+        if type(instance) == FrontPort:
+            return FrontPortType
+        if type(instance) == Interface:
+            return InterfaceType
+        if type(instance) == PowerFeed:
+            return PowerFeedType
+        if type(instance) == PowerOutlet:
+            return PowerOutletType
+        if type(instance) == PowerPort:
+            return PowerPortType
+        if type(instance) == RearPort:
+            return RearPortType
+
+
+class InventoryItemTemplateComponentType(graphene.Union):
+    class Meta:
+        types = (
+            ConsolePortTemplateType,
+            ConsoleServerPortTemplateType,
+            FrontPortTemplateType,
+            InterfaceTemplateType,
+            PowerOutletTemplateType,
+            PowerPortTemplateType,
+            RearPortTemplateType,
+        )
+
+    @classmethod
+    def resolve_type(cls, instance, info):
+        if type(instance) == ConsolePortTemplate:
+            return ConsolePortTemplateType
+        if type(instance) == ConsoleServerPortTemplate:
+            return ConsoleServerPortTemplateType
+        if type(instance) == FrontPortTemplate:
+            return FrontPortTemplateType
+        if type(instance) == InterfaceTemplate:
+            return InterfaceTemplateType
+        if type(instance) == PowerOutletTemplate:
+            return PowerOutletTemplateType
+        if type(instance) == PowerPortTemplate:
+            return PowerPortTemplateType
+        if type(instance) == RearPortTemplate:
+            return RearPortTemplateType
+
+
+class InventoryItemComponentType(graphene.Union):
+    class Meta:
+        types = (
+            ConsolePortType,
+            ConsoleServerPortType,
+            FrontPortType,
+            InterfaceType,
+            PowerOutletType,
+            PowerPortType,
+            RearPortType,
+        )
+
+    @classmethod
+    def resolve_type(cls, instance, info):
+        if type(instance) == ConsolePort:
+            return ConsolePortType
+        if type(instance) == ConsoleServerPort:
+            return ConsoleServerPortType
+        if type(instance) == FrontPort:
+            return FrontPortType
+        if type(instance) == Interface:
+            return InterfaceType
+        if type(instance) == PowerOutlet:
+            return PowerOutletType
+        if type(instance) == PowerPort:
+            return PowerPortType
+        if type(instance) == RearPort:
+            return RearPortType

+ 23 - 12
netbox/dcim/graphql/types.py

@@ -2,7 +2,7 @@ import graphene
 
 
 from dcim import filtersets, models
 from dcim import filtersets, models
 from extras.graphql.mixins import (
 from extras.graphql.mixins import (
-    ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
+    ChangelogMixin, ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
 )
 )
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
@@ -87,6 +87,8 @@ class ComponentTemplateObjectType(
 #
 #
 
 
 class CableType(NetBoxObjectType):
 class CableType(NetBoxObjectType):
+    a_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType')
+    b_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType')
 
 
     class Meta:
     class Meta:
         model = models.Cable
         model = models.Cable
@@ -99,12 +101,19 @@ class CableType(NetBoxObjectType):
     def resolve_length_unit(self, info):
     def resolve_length_unit(self, info):
         return self.length_unit or None
         return self.length_unit or None
 
 
+    def resolve_a_terminations(self, info):
+        return self.a_terminations
+
+    def resolve_b_terminations(self, info):
+        return self.b_terminations
+
 
 
 class CableTerminationType(NetBoxObjectType):
 class CableTerminationType(NetBoxObjectType):
+    termination = graphene.Field('dcim.graphql.gfk_mixins.CableTerminationTerminationType')
 
 
     class Meta:
     class Meta:
         model = models.CableTermination
         model = models.CableTermination
-        fields = '__all__'
+        exclude = ('termination_type', 'termination_id')
         filterset_class = filtersets.CableTerminationFilterSet
         filterset_class = filtersets.CableTerminationFilterSet
 
 
 
 
@@ -152,7 +161,7 @@ class ConsoleServerPortTemplateType(ComponentTemplateObjectType):
         return self.type or None
         return self.type or None
 
 
 
 
-class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, NetBoxObjectType):
+class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
 
 
     class Meta:
     class Meta:
         model = models.Device
         model = models.Device
@@ -183,10 +192,11 @@ class DeviceBayTemplateType(ComponentTemplateObjectType):
 
 
 
 
 class InventoryItemTemplateType(ComponentTemplateObjectType):
 class InventoryItemTemplateType(ComponentTemplateObjectType):
+    component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemTemplateComponentType')
 
 
     class Meta:
     class Meta:
         model = models.InventoryItemTemplate
         model = models.InventoryItemTemplate
-        fields = '__all__'
+        exclude = ('component_type', 'component_id')
         filterset_class = filtersets.InventoryItemTemplateFilterSet
         filterset_class = filtersets.InventoryItemTemplateFilterSet
 
 
 
 
@@ -269,10 +279,11 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
 
 
 
 
 class InventoryItemType(ComponentObjectType):
 class InventoryItemType(ComponentObjectType):
+    component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType')
 
 
     class Meta:
     class Meta:
         model = models.InventoryItem
         model = models.InventoryItem
-        fields = '__all__'
+        exclude = ('component_type', 'component_id')
         filterset_class = filtersets.InventoryItemFilterSet
         filterset_class = filtersets.InventoryItemFilterSet
 
 
 
 
@@ -284,7 +295,7 @@ class InventoryItemRoleType(OrganizationalObjectType):
         filterset_class = filtersets.InventoryItemRoleFilterSet
         filterset_class = filtersets.InventoryItemRoleFilterSet
 
 
 
 
-class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType):
+class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType):
 
 
     class Meta:
     class Meta:
         model = models.Location
         model = models.Location
@@ -292,7 +303,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectT
         filterset_class = filtersets.LocationFilterSet
         filterset_class = filtersets.LocationFilterSet
 
 
 
 
-class ManufacturerType(OrganizationalObjectType):
+class ManufacturerType(OrganizationalObjectType, ContactsMixin):
 
 
     class Meta:
     class Meta:
         model = models.Manufacturer
         model = models.Manufacturer
@@ -379,7 +390,7 @@ class PowerOutletTemplateType(ComponentTemplateObjectType):
         return self.type or None
         return self.type or None
 
 
 
 
-class PowerPanelType(NetBoxObjectType):
+class PowerPanelType(NetBoxObjectType, ContactsMixin):
 
 
     class Meta:
     class Meta:
         model = models.PowerPanel
         model = models.PowerPanel
@@ -409,7 +420,7 @@ class PowerPortTemplateType(ComponentTemplateObjectType):
         return self.type or None
         return self.type or None
 
 
 
 
-class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
+class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
 
 
     class Meta:
     class Meta:
         model = models.Rack
         model = models.Rack
@@ -458,7 +469,7 @@ class RearPortTemplateType(ComponentTemplateObjectType):
         filterset_class = filtersets.RearPortTemplateFilterSet
         filterset_class = filtersets.RearPortTemplateFilterSet
 
 
 
 
-class RegionType(VLANGroupsMixin, OrganizationalObjectType):
+class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
 
 
     class Meta:
     class Meta:
         model = models.Region
         model = models.Region
@@ -466,7 +477,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType):
         filterset_class = filtersets.RegionFilterSet
         filterset_class = filtersets.RegionFilterSet
 
 
 
 
-class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
+class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
     asn = graphene.Field(BigInt)
     asn = graphene.Field(BigInt)
 
 
     class Meta:
     class Meta:
@@ -475,7 +486,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
         filterset_class = filtersets.SiteFilterSet
         filterset_class = filtersets.SiteFilterSet
 
 
 
 
-class SiteGroupType(VLANGroupsMixin, OrganizationalObjectType):
+class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
 
 
     class Meta:
     class Meta:
         model = models.SiteGroup
         model = models.SiteGroup

+ 143 - 0
netbox/dcim/search.py

@@ -0,0 +1,143 @@
+import dcim.filtersets
+import dcim.tables
+from dcim.models import (
+    Cable,
+    Device,
+    DeviceType,
+    Location,
+    Module,
+    ModuleType,
+    PowerFeed,
+    Rack,
+    RackReservation,
+    Site,
+    VirtualChassis,
+)
+from netbox.search import SearchIndex, register_search
+from utilities.utils import count_related
+
+
+@register_search()
+class SiteIndex(SearchIndex):
+    model = Site
+    queryset = Site.objects.prefetch_related('region', 'tenant', 'tenant__group')
+    filterset = dcim.filtersets.SiteFilterSet
+    table = dcim.tables.SiteTable
+    url = 'dcim:site_list'
+
+
+@register_search()
+class RackIndex(SearchIndex):
+    model = Rack
+    queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate(
+        device_count=count_related(Device, 'rack')
+    )
+    filterset = dcim.filtersets.RackFilterSet
+    table = dcim.tables.RackTable
+    url = 'dcim:rack_list'
+
+
+@register_search()
+class RackReservationIndex(SearchIndex):
+    model = RackReservation
+    queryset = RackReservation.objects.prefetch_related('rack', 'user')
+    filterset = dcim.filtersets.RackReservationFilterSet
+    table = dcim.tables.RackReservationTable
+    url = 'dcim:rackreservation_list'
+
+
+@register_search()
+class LocationIndex(SearchIndex):
+    model = Location
+    queryset = Location.objects.add_related_count(
+        Location.objects.add_related_count(Location.objects.all(), Device, 'location', 'device_count', cumulative=True),
+        Rack,
+        'location',
+        'rack_count',
+        cumulative=True,
+    ).prefetch_related('site')
+    filterset = dcim.filtersets.LocationFilterSet
+    table = dcim.tables.LocationTable
+    url = 'dcim:location_list'
+
+
+@register_search()
+class DeviceTypeIndex(SearchIndex):
+    model = DeviceType
+    queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+        instance_count=count_related(Device, 'device_type')
+    )
+    filterset = dcim.filtersets.DeviceTypeFilterSet
+    table = dcim.tables.DeviceTypeTable
+    url = 'dcim:devicetype_list'
+
+
+@register_search()
+class DeviceIndex(SearchIndex):
+    model = Device
+    queryset = Device.objects.prefetch_related(
+        'device_type__manufacturer',
+        'device_role',
+        'tenant',
+        'tenant__group',
+        'site',
+        'rack',
+        'primary_ip4',
+        'primary_ip6',
+    )
+    filterset = dcim.filtersets.DeviceFilterSet
+    table = dcim.tables.DeviceTable
+    url = 'dcim:device_list'
+
+
+@register_search()
+class ModuleTypeIndex(SearchIndex):
+    model = ModuleType
+    queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+        instance_count=count_related(Module, 'module_type')
+    )
+    filterset = dcim.filtersets.ModuleTypeFilterSet
+    table = dcim.tables.ModuleTypeTable
+    url = 'dcim:moduletype_list'
+
+
+@register_search()
+class ModuleIndex(SearchIndex):
+    model = Module
+    queryset = Module.objects.prefetch_related(
+        'module_type__manufacturer',
+        'device',
+        'module_bay',
+    )
+    filterset = dcim.filtersets.ModuleFilterSet
+    table = dcim.tables.ModuleTable
+    url = 'dcim:module_list'
+
+
+@register_search()
+class VirtualChassisIndex(SearchIndex):
+    model = VirtualChassis
+    queryset = VirtualChassis.objects.prefetch_related('master').annotate(
+        member_count=count_related(Device, 'virtual_chassis')
+    )
+    filterset = dcim.filtersets.VirtualChassisFilterSet
+    table = dcim.tables.VirtualChassisTable
+    url = 'dcim:virtualchassis_list'
+
+
+@register_search()
+class CableIndex(SearchIndex):
+    model = Cable
+    queryset = Cable.objects.all()
+    filterset = dcim.filtersets.CableFilterSet
+    table = dcim.tables.CableTable
+    url = 'dcim:cable_list'
+
+
+@register_search()
+class PowerFeedIndex(SearchIndex):
+    model = PowerFeed
+    queryset = PowerFeed.objects.all()
+    filterset = dcim.filtersets.PowerFeedFilterSet
+    table = dcim.tables.PowerFeedTable
+    url = 'dcim:powerfeed_list'

+ 20 - 9
netbox/dcim/tables/devices.py

@@ -1,12 +1,26 @@
 import django_tables2 as tables
 import django_tables2 as tables
-from django_tables2.utils import Accessor
-
 from dcim.models import (
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
-    InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
+    ConsolePort,
+    ConsoleServerPort,
+    Device,
+    DeviceBay,
+    DeviceRole,
+    FrontPort,
+    Interface,
+    InventoryItem,
+    InventoryItemRole,
+    ModuleBay,
+    Platform,
+    PowerOutlet,
+    PowerPort,
+    RearPort,
+    VirtualChassis,
 )
 )
+from django_tables2.utils import Accessor
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
 from .template_code import *
 from .template_code import *
 
 
 __all__ = (
 __all__ = (
@@ -137,7 +151,7 @@ class PlatformTable(NetBoxTable):
 # Devices
 # Devices
 #
 #
 
 
-class DeviceTable(TenancyColumnsMixin, NetBoxTable):
+class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.TemplateColumn(
     name = tables.TemplateColumn(
         order_by=('_name',),
         order_by=('_name',),
         template_code=DEVICE_LINK
         template_code=DEVICE_LINK
@@ -201,9 +215,6 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name='VC Priority'
         verbose_name='VC Priority'
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:device_list'
         url_name='dcim:device_list'
     )
     )

+ 14 - 6
netbox/dcim/tables/devicetypes.py

@@ -1,10 +1,21 @@
 import django_tables2 as tables
 import django_tables2 as tables
 
 
 from dcim.models import (
 from dcim.models import (
-    ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
-    InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+    ConsolePortTemplate,
+    ConsoleServerPortTemplate,
+    DeviceBayTemplate,
+    DeviceType,
+    FrontPortTemplate,
+    InterfaceTemplate,
+    InventoryItemTemplate,
+    Manufacturer,
+    ModuleBayTemplate,
+    PowerOutletTemplate,
+    PowerPortTemplate,
+    RearPortTemplate,
 )
 )
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
+from tenancy.tables import ContactsColumnMixin
 from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
 from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
 
 
 __all__ = (
 __all__ = (
@@ -27,7 +38,7 @@ __all__ = (
 # Manufacturers
 # Manufacturers
 #
 #
 
 
-class ManufacturerTable(NetBoxTable):
+class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -43,9 +54,6 @@ class ManufacturerTable(NetBoxTable):
         verbose_name='Platforms'
         verbose_name='Platforms'
     )
     )
     slug = tables.Column()
     slug = tables.Column()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:manufacturer_list'
         url_name='dcim:manufacturer_list'
     )
     )

+ 4 - 5
netbox/dcim/tables/power.py

@@ -1,7 +1,9 @@
 import django_tables2 as tables
 import django_tables2 as tables
-
 from dcim.models import PowerFeed, PowerPanel
 from dcim.models import PowerFeed, PowerPanel
+from tenancy.tables import ContactsColumnMixin
+
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
+
 from .devices import CableTerminationTable
 from .devices import CableTerminationTable
 
 
 __all__ = (
 __all__ = (
@@ -14,7 +16,7 @@ __all__ = (
 # Power panels
 # Power panels
 #
 #
 
 
-class PowerPanelTable(NetBoxTable):
+class PowerPanelTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -29,9 +31,6 @@ class PowerPanelTable(NetBoxTable):
         url_params={'power_panel_id': 'pk'},
         url_params={'power_panel_id': 'pk'},
         verbose_name='Feeds'
         verbose_name='Feeds'
     )
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:powerpanel_list'
         url_name='dcim:powerpanel_list'
     )
     )

+ 2 - 5
netbox/dcim/tables/racks.py

@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
 
 
 from dcim.models import Rack, RackReservation, RackRole
 from dcim.models import Rack, RackReservation, RackRole
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 from .template_code import DEVICE_WEIGHT
 from .template_code import DEVICE_WEIGHT
 
 
 __all__ = (
 __all__ = (
@@ -38,7 +38,7 @@ class RackRoleTable(NetBoxTable):
 # Racks
 # Racks
 #
 #
 
 
-class RackTable(TenancyColumnsMixin, NetBoxTable):
+class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         order_by=('_name',),
         order_by=('_name',),
         linkify=True
         linkify=True
@@ -69,9 +69,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
         orderable=False,
         orderable=False,
         verbose_name='Power'
         verbose_name='Power'
     )
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:rack_list'
         url_name='dcim:rack_list'
     )
     )

+ 7 - 18
netbox/dcim/tables/sites.py

@@ -1,8 +1,9 @@
 import django_tables2 as tables
 import django_tables2 as tables
-
 from dcim.models import Location, Region, Site, SiteGroup
 from dcim.models import Location, Region, Site, SiteGroup
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
 from .template_code import LOCATION_BUTTONS
 from .template_code import LOCATION_BUTTONS
 
 
 __all__ = (
 __all__ = (
@@ -17,7 +18,7 @@ __all__ = (
 # Regions
 # Regions
 #
 #
 
 
-class RegionTable(NetBoxTable):
+class RegionTable(ContactsColumnMixin, NetBoxTable):
     name = columns.MPTTColumn(
     name = columns.MPTTColumn(
         linkify=True
         linkify=True
     )
     )
@@ -26,9 +27,6 @@ class RegionTable(NetBoxTable):
         url_params={'region_id': 'pk'},
         url_params={'region_id': 'pk'},
         verbose_name='Sites'
         verbose_name='Sites'
     )
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:region_list'
         url_name='dcim:region_list'
     )
     )
@@ -46,7 +44,7 @@ class RegionTable(NetBoxTable):
 # Site groups
 # Site groups
 #
 #
 
 
-class SiteGroupTable(NetBoxTable):
+class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
     name = columns.MPTTColumn(
     name = columns.MPTTColumn(
         linkify=True
         linkify=True
     )
     )
@@ -55,9 +53,6 @@ class SiteGroupTable(NetBoxTable):
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
         verbose_name='Sites'
         verbose_name='Sites'
     )
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:sitegroup_list'
         url_name='dcim:sitegroup_list'
     )
     )
@@ -75,7 +70,7 @@ class SiteGroupTable(NetBoxTable):
 # Sites
 # Sites
 #
 #
 
 
-class SiteTable(TenancyColumnsMixin, NetBoxTable):
+class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -97,9 +92,6 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name='ASN Count'
         verbose_name='ASN Count'
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:site_list'
         url_name='dcim:site_list'
     )
     )
@@ -118,7 +110,7 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
 # Locations
 # Locations
 #
 #
 
 
-class LocationTable(TenancyColumnsMixin, NetBoxTable):
+class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = columns.MPTTColumn(
     name = columns.MPTTColumn(
         linkify=True
         linkify=True
     )
     )
@@ -136,9 +128,6 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
         url_params={'location_id': 'pk'},
         url_params={'location_id': 'pk'},
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:location_list'
         url_name='dcim:location_list'
     )
     )

+ 55 - 28
netbox/dcim/views.py

@@ -951,7 +951,8 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Console Ports'),
         label=_('Console Ports'),
         badge=lambda obj: obj.consoleporttemplates.count(),
         badge=lambda obj: obj.consoleporttemplates.count(),
-        permission='dcim.view_consoleporttemplate'
+        permission='dcim.view_consoleporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -964,7 +965,8 @@ class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Console Server Ports'),
         label=_('Console Server Ports'),
         badge=lambda obj: obj.consoleserverporttemplates.count(),
         badge=lambda obj: obj.consoleserverporttemplates.count(),
-        permission='dcim.view_consoleserverporttemplate'
+        permission='dcim.view_consoleserverporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -977,7 +979,8 @@ class DeviceTypePowerPortsView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Power Ports'),
         label=_('Power Ports'),
         badge=lambda obj: obj.powerporttemplates.count(),
         badge=lambda obj: obj.powerporttemplates.count(),
-        permission='dcim.view_powerporttemplate'
+        permission='dcim.view_powerporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -990,7 +993,8 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Power Outlets'),
         label=_('Power Outlets'),
         badge=lambda obj: obj.poweroutlettemplates.count(),
         badge=lambda obj: obj.poweroutlettemplates.count(),
-        permission='dcim.view_poweroutlettemplate'
+        permission='dcim.view_poweroutlettemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1003,7 +1007,8 @@ class DeviceTypeInterfacesView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Interfaces'),
         label=_('Interfaces'),
         badge=lambda obj: obj.interfacetemplates.count(),
         badge=lambda obj: obj.interfacetemplates.count(),
-        permission='dcim.view_interfacetemplate'
+        permission='dcim.view_interfacetemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1016,7 +1021,8 @@ class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Front Ports'),
         label=_('Front Ports'),
         badge=lambda obj: obj.frontporttemplates.count(),
         badge=lambda obj: obj.frontporttemplates.count(),
-        permission='dcim.view_frontporttemplate'
+        permission='dcim.view_frontporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1029,7 +1035,8 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Rear Ports'),
         label=_('Rear Ports'),
         badge=lambda obj: obj.rearporttemplates.count(),
         badge=lambda obj: obj.rearporttemplates.count(),
-        permission='dcim.view_rearporttemplate'
+        permission='dcim.view_rearporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1042,7 +1049,8 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Module Bays'),
         label=_('Module Bays'),
         badge=lambda obj: obj.modulebaytemplates.count(),
         badge=lambda obj: obj.modulebaytemplates.count(),
-        permission='dcim.view_modulebaytemplate'
+        permission='dcim.view_modulebaytemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1055,7 +1063,8 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Device Bays'),
         label=_('Device Bays'),
         badge=lambda obj: obj.devicebaytemplates.count(),
         badge=lambda obj: obj.devicebaytemplates.count(),
-        permission='dcim.view_devicebaytemplate'
+        permission='dcim.view_devicebaytemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1068,7 +1077,8 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Inventory Items'),
         label=_('Inventory Items'),
         badge=lambda obj: obj.inventoryitemtemplates.count(),
         badge=lambda obj: obj.inventoryitemtemplates.count(),
-        permission='dcim.view_invenotryitemtemplate'
+        permission='dcim.view_invenotryitemtemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1168,7 +1178,8 @@ class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Console Ports'),
         label=_('Console Ports'),
         badge=lambda obj: obj.consoleporttemplates.count(),
         badge=lambda obj: obj.consoleporttemplates.count(),
-        permission='dcim.view_consoleporttemplate'
+        permission='dcim.view_consoleporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1181,7 +1192,8 @@ class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Console Server Ports'),
         label=_('Console Server Ports'),
         badge=lambda obj: obj.consoleserverporttemplates.count(),
         badge=lambda obj: obj.consoleserverporttemplates.count(),
-        permission='dcim.view_consoleserverporttemplate'
+        permission='dcim.view_consoleserverporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1194,7 +1206,8 @@ class ModuleTypePowerPortsView(ModuleTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Power Ports'),
         label=_('Power Ports'),
         badge=lambda obj: obj.powerporttemplates.count(),
         badge=lambda obj: obj.powerporttemplates.count(),
-        permission='dcim.view_powerporttemplate'
+        permission='dcim.view_powerporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1207,7 +1220,8 @@ class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Power Outlets'),
         label=_('Power Outlets'),
         badge=lambda obj: obj.poweroutlettemplates.count(),
         badge=lambda obj: obj.poweroutlettemplates.count(),
-        permission='dcim.view_poweroutlettemplate'
+        permission='dcim.view_poweroutlettemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1220,7 +1234,8 @@ class ModuleTypeInterfacesView(ModuleTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Interfaces'),
         label=_('Interfaces'),
         badge=lambda obj: obj.interfacetemplates.count(),
         badge=lambda obj: obj.interfacetemplates.count(),
-        permission='dcim.view_interfacetemplate'
+        permission='dcim.view_interfacetemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1233,7 +1248,8 @@ class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Front Ports'),
         label=_('Front Ports'),
         badge=lambda obj: obj.frontporttemplates.count(),
         badge=lambda obj: obj.frontporttemplates.count(),
-        permission='dcim.view_frontporttemplate'
+        permission='dcim.view_frontporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1246,7 +1262,8 @@ class ModuleTypeRearPortsView(ModuleTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Rear Ports'),
         label=_('Rear Ports'),
         badge=lambda obj: obj.rearporttemplates.count(),
         badge=lambda obj: obj.rearporttemplates.count(),
-        permission='dcim.view_rearporttemplate'
+        permission='dcim.view_rearporttemplate',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1845,11 +1862,12 @@ class DeviceConsolePortsView(DeviceComponentsView):
     child_model = ConsolePort
     child_model = ConsolePort
     table = tables.DeviceConsolePortTable
     table = tables.DeviceConsolePortTable
     filterset = filtersets.ConsolePortFilterSet
     filterset = filtersets.ConsolePortFilterSet
-    template_name = 'dcim/device/consoleports.html'
+    template_name = 'dcim/device/consoleports.html',
     tab = ViewTab(
     tab = ViewTab(
         label=_('Console Ports'),
         label=_('Console Ports'),
         badge=lambda obj: obj.consoleports.count(),
         badge=lambda obj: obj.consoleports.count(),
-        permission='dcim.view_consoleport'
+        permission='dcim.view_consoleport',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1862,7 +1880,8 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Console Server Ports'),
         label=_('Console Server Ports'),
         badge=lambda obj: obj.consoleserverports.count(),
         badge=lambda obj: obj.consoleserverports.count(),
-        permission='dcim.view_consoleserverport'
+        permission='dcim.view_consoleserverport',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1875,7 +1894,8 @@ class DevicePowerPortsView(DeviceComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Power Ports'),
         label=_('Power Ports'),
         badge=lambda obj: obj.powerports.count(),
         badge=lambda obj: obj.powerports.count(),
-        permission='dcim.view_powerport'
+        permission='dcim.view_powerport',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1888,7 +1908,8 @@ class DevicePowerOutletsView(DeviceComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Power Outlets'),
         label=_('Power Outlets'),
         badge=lambda obj: obj.poweroutlets.count(),
         badge=lambda obj: obj.poweroutlets.count(),
-        permission='dcim.view_poweroutlet'
+        permission='dcim.view_poweroutlet',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1901,7 +1922,8 @@ class DeviceInterfacesView(DeviceComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Interfaces'),
         label=_('Interfaces'),
         badge=lambda obj: obj.interfaces.count(),
         badge=lambda obj: obj.interfaces.count(),
-        permission='dcim.view_interface'
+        permission='dcim.view_interface',
+        hide_if_empty=True
     )
     )
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
@@ -1920,7 +1942,8 @@ class DeviceFrontPortsView(DeviceComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Front Ports'),
         label=_('Front Ports'),
         badge=lambda obj: obj.frontports.count(),
         badge=lambda obj: obj.frontports.count(),
-        permission='dcim.view_frontport'
+        permission='dcim.view_frontport',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1933,7 +1956,8 @@ class DeviceRearPortsView(DeviceComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Rear Ports'),
         label=_('Rear Ports'),
         badge=lambda obj: obj.rearports.count(),
         badge=lambda obj: obj.rearports.count(),
-        permission='dcim.view_rearport'
+        permission='dcim.view_rearport',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1946,7 +1970,8 @@ class DeviceModuleBaysView(DeviceComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Module Bays'),
         label=_('Module Bays'),
         badge=lambda obj: obj.modulebays.count(),
         badge=lambda obj: obj.modulebays.count(),
-        permission='dcim.view_modulebay'
+        permission='dcim.view_modulebay',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1959,7 +1984,8 @@ class DeviceDeviceBaysView(DeviceComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Device Bays'),
         label=_('Device Bays'),
         badge=lambda obj: obj.devicebays.count(),
         badge=lambda obj: obj.devicebays.count(),
-        permission='dcim.view_devicebay'
+        permission='dcim.view_devicebay',
+        hide_if_empty=True
     )
     )
 
 
 
 
@@ -1972,7 +1998,8 @@ class DeviceInventoryView(DeviceComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Inventory Items'),
         label=_('Inventory Items'),
         badge=lambda obj: obj.inventoryitems.count(),
         badge=lambda obj: obj.inventoryitems.count(),
-        permission='dcim.view_inventoryitem'
+        permission='dcim.view_inventoryitem',
+        hide_if_empty=True
     )
     )
 
 
 
 

+ 1 - 2
netbox/extras/apps.py

@@ -5,5 +5,4 @@ class ExtrasConfig(AppConfig):
     name = "extras"
     name = "extras"
 
 
     def ready(self):
     def ready(self):
-        import extras.lookups
-        import extras.signals
+        from . import lookups, search, signals

+ 7 - 0
netbox/extras/graphql/mixins.py

@@ -59,3 +59,10 @@ class TagsMixin:
 
 
     def resolve_tags(self, info):
     def resolve_tags(self, info):
         return self.tags.all()
         return self.tags.all()
+
+
+class ContactsMixin:
+    contacts = graphene.List('tenancy.graphql.types.ContactAssignmentType')
+
+    def resolve_contacts(self, info):
+        return list(self.contacts.all())

+ 2 - 2
netbox/extras/graphql/types.py

@@ -27,7 +27,7 @@ class CustomFieldType(ObjectType):
 
 
     class Meta:
     class Meta:
         model = models.CustomField
         model = models.CustomField
-        fields = '__all__'
+        exclude = ('content_types', )
         filterset_class = filtersets.CustomFieldFilterSet
         filterset_class = filtersets.CustomFieldFilterSet
 
 
 
 
@@ -83,5 +83,5 @@ class WebhookType(ObjectType):
 
 
     class Meta:
     class Meta:
         model = models.Webhook
         model = models.Webhook
-        fields = '__all__'
+        exclude = ('content_types', )
         filterset_class = filtersets.WebhookFilterSet
         filterset_class = filtersets.WebhookFilterSet

+ 32 - 10
netbox/extras/plugins/__init__.py

@@ -5,10 +5,11 @@ from packaging import version
 from django.apps import AppConfig
 from django.apps import AppConfig
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 from django.template.loader import get_template
 from django.template.loader import get_template
+from django.utils.module_loading import import_string
 
 
-from extras.plugins.utils import import_object
 from extras.registry import registry
 from extras.registry import registry
 from netbox.navigation import MenuGroup
 from netbox.navigation import MenuGroup
+from netbox.search import register_search
 from utilities.choices import ButtonColorChoices
 from utilities.choices import ButtonColorChoices
 
 
 
 
@@ -60,6 +61,7 @@ class PluginConfig(AppConfig):
 
 
     # Default integration paths. Plugin authors can override these to customize the paths to
     # Default integration paths. Plugin authors can override these to customize the paths to
     # integrated components.
     # integrated components.
+    search_indexes = 'search.indexes'
     graphql_schema = 'graphql.schema'
     graphql_schema = 'graphql.schema'
     menu = 'navigation.menu'
     menu = 'navigation.menu'
     menu_items = 'navigation.menu_items'
     menu_items = 'navigation.menu_items'
@@ -69,26 +71,46 @@ class PluginConfig(AppConfig):
     def ready(self):
     def ready(self):
         plugin_name = self.name.rsplit('.', 1)[-1]
         plugin_name = self.name.rsplit('.', 1)[-1]
 
 
+        # Register search extensions (if defined)
+        try:
+            search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
+            for idx in search_indexes:
+                register_search()(idx)
+        except ImportError:
+            pass
+
         # Register template content (if defined)
         # Register template content (if defined)
-        template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
-        if template_extensions is not None:
+        try:
+            template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
             register_template_extensions(template_extensions)
             register_template_extensions(template_extensions)
+        except ImportError:
+            pass
 
 
-        # Register navigation menu or menu items (if defined)
-        if menu := import_object(f"{self.__module__}.{self.menu}"):
+        # Register navigation menu and/or menu items (if defined)
+        try:
+            menu = import_string(f"{self.__module__}.{self.menu}")
             register_menu(menu)
             register_menu(menu)
-        if menu_items := import_object(f"{self.__module__}.{self.menu_items}"):
+        except ImportError:
+            pass
+        try:
+            menu_items = import_string(f"{self.__module__}.{self.menu_items}")
             register_menu_items(self.verbose_name, menu_items)
             register_menu_items(self.verbose_name, menu_items)
+        except ImportError:
+            pass
 
 
         # Register GraphQL schema (if defined)
         # Register GraphQL schema (if defined)
-        graphql_schema = import_object(f"{self.__module__}.{self.graphql_schema}")
-        if graphql_schema is not None:
+        try:
+            graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}")
             register_graphql_schema(graphql_schema)
             register_graphql_schema(graphql_schema)
+        except ImportError:
+            pass
 
 
         # Register user preferences (if defined)
         # Register user preferences (if defined)
-        user_preferences = import_object(f"{self.__module__}.{self.user_preferences}")
-        if user_preferences is not None:
+        try:
+            user_preferences = import_string(f"{self.__module__}.{self.user_preferences}")
             register_user_preferences(plugin_name, user_preferences)
             register_user_preferences(plugin_name, user_preferences)
+        except ImportError:
+            pass
 
 
     @classmethod
     @classmethod
     def validate(cls, user_config, netbox_version):
     def validate(cls, user_config, netbox_version):

+ 9 - 6
netbox/extras/plugins/urls.py

@@ -3,8 +3,7 @@ from django.conf import settings
 from django.conf.urls import include
 from django.conf.urls import include
 from django.contrib.admin.views.decorators import staff_member_required
 from django.contrib.admin.views.decorators import staff_member_required
 from django.urls import path
 from django.urls import path
-
-from extras.plugins.utils import import_object
+from django.utils.module_loading import import_string
 
 
 from . import views
 from . import views
 
 
@@ -25,15 +24,19 @@ for plugin_path in settings.PLUGINS:
     base_url = getattr(app, 'base_url') or app.label
     base_url = getattr(app, 'base_url') or app.label
 
 
     # Check if the plugin specifies any base URLs
     # Check if the plugin specifies any base URLs
-    urlpatterns = import_object(f"{plugin_path}.urls.urlpatterns")
-    if urlpatterns is not None:
+    try:
+        urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
         plugin_patterns.append(
         plugin_patterns.append(
             path(f"{base_url}/", include((urlpatterns, app.label)))
             path(f"{base_url}/", include((urlpatterns, app.label)))
         )
         )
+    except ImportError:
+        pass
 
 
     # Check if the plugin specifies any API URLs
     # Check if the plugin specifies any API URLs
-    urlpatterns = import_object(f"{plugin_path}.api.urls.urlpatterns")
-    if urlpatterns is not None:
+    try:
+        urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
         plugin_api_patterns.append(
         plugin_api_patterns.append(
             path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
             path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
         )
         )
+    except ImportError:
+        pass

+ 0 - 33
netbox/extras/plugins/utils.py

@@ -1,33 +0,0 @@
-import importlib.util
-import sys
-
-
-def import_object(module_and_object):
-    """
-    Import a specific object from a specific module by name, such as "extras.plugins.utils.import_object".
-
-    Returns the imported object, or None if it doesn't exist.
-    """
-    target_module_name, object_name = module_and_object.rsplit('.', 1)
-    module_hierarchy = target_module_name.split('.')
-
-    # Iterate through the module hierarchy, checking for the existence of each successive submodule.
-    # We have to do this rather than jumping directly to calling find_spec(target_module_name)
-    # because find_spec will raise a ModuleNotFoundError if any parent module of target_module_name does not exist.
-    module_name = ""
-    for module_component in module_hierarchy:
-        module_name = f"{module_name}.{module_component}" if module_name else module_component
-        spec = importlib.util.find_spec(module_name)
-        if spec is None:
-            # No such module
-            return None
-
-    # Okay, target_module_name exists. Load it if not already loaded
-    if target_module_name in sys.modules:
-        module = sys.modules[target_module_name]
-    else:
-        module = importlib.util.module_from_spec(spec)
-        sys.modules[target_module_name] = module
-        spec.loader.exec_module(module)
-
-    return getattr(module, object_name, None)

+ 1 - 0
netbox/extras/registry.py

@@ -29,4 +29,5 @@ registry['model_features'] = {
     feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
     feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
 }
 }
 registry['denormalized_fields'] = collections.defaultdict(list)
 registry['denormalized_fields'] = collections.defaultdict(list)
+registry['search'] = collections.defaultdict(dict)
 registry['views'] = collections.defaultdict(dict)
 registry['views'] = collections.defaultdict(dict)

+ 14 - 0
netbox/extras/search.py

@@ -0,0 +1,14 @@
+import extras.filtersets
+import extras.tables
+from extras.models import JournalEntry
+from netbox.search import SearchIndex, register_search
+
+
+@register_search()
+class JournalEntryIndex(SearchIndex):
+    model = JournalEntry
+    queryset = JournalEntry.objects.prefetch_related('assigned_object', 'created_by')
+    filterset = extras.filtersets.JournalEntryFilterSet
+    table = extras.tables.JournalEntryTable
+    url = 'extras:journalentry_list'
+    category = 'Journal'

+ 13 - 0
netbox/extras/tests/dummy_plugin/search.py

@@ -0,0 +1,13 @@
+from netbox.search import SearchIndex
+from .models import DummyModel
+
+
+class DummyModelIndex(SearchIndex):
+    model = DummyModel
+    queryset = DummyModel.objects.all()
+    url = 'plugins:dummy_plugin:dummy_models'
+
+
+indexes = (
+    DummyModelIndex,
+)

+ 1 - 1
netbox/ipam/apps.py

@@ -6,4 +6,4 @@ class IPAMConfig(AppConfig):
     verbose_name = "IPAM"
     verbose_name = "IPAM"
 
 
     def ready(self):
     def ready(self):
-        import ipam.signals
+        from . import signals, search

+ 18 - 0
netbox/ipam/forms/models.py

@@ -88,6 +88,12 @@ class RouteTargetForm(TenancyForm, NetBoxModelForm):
 class RIRForm(NetBoxModelForm):
 class RIRForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('RIR', (
+            'name', 'slug', 'is_private', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = RIR
         model = RIR
         fields = [
         fields = [
@@ -164,6 +170,12 @@ class ASNForm(TenancyForm, NetBoxModelForm):
 class RoleForm(NetBoxModelForm):
 class RoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Role', (
+            'name', 'slug', 'weight', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = Role
         model = Role
         fields = [
         fields = [
@@ -784,6 +796,12 @@ class ServiceTemplateForm(NetBoxModelForm):
         help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
         help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
     )
     )
 
 
+    fieldsets = (
+        ('Service Template', (
+            'name', 'protocol', 'ports', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = ServiceTemplate
         model = ServiceTemplate
         fields = ('name', 'protocol', 'ports', 'description', 'tags')
         fields = ('name', 'protocol', 'ports', 'description', 'tags')

+ 95 - 0
netbox/ipam/graphql/gfk_mixins.py

@@ -0,0 +1,95 @@
+import graphene
+from dcim.graphql.types import (
+    InterfaceType,
+    LocationType,
+    RackType,
+    RegionType,
+    SiteGroupType,
+    SiteType,
+)
+from dcim.models import Interface, Location, Rack, Region, Site, SiteGroup
+from ipam.graphql.types import FHRPGroupType, VLANType
+from ipam.models import VLAN, FHRPGroup
+from virtualization.graphql.types import ClusterGroupType, ClusterType, VMInterfaceType
+from virtualization.models import Cluster, ClusterGroup, VMInterface
+
+
+class IPAddressAssignmentType(graphene.Union):
+    class Meta:
+        types = (
+            InterfaceType,
+            FHRPGroupType,
+            VMInterfaceType,
+        )
+
+    @classmethod
+    def resolve_type(cls, instance, info):
+        if type(instance) == Interface:
+            return InterfaceType
+        if type(instance) == FHRPGroup:
+            return FHRPGroupType
+        if type(instance) == VMInterface:
+            return VMInterfaceType
+
+
+class L2VPNAssignmentType(graphene.Union):
+    class Meta:
+        types = (
+            InterfaceType,
+            VLANType,
+            VMInterfaceType,
+        )
+
+    @classmethod
+    def resolve_type(cls, instance, info):
+        if type(instance) == Interface:
+            return InterfaceType
+        if type(instance) == VLAN:
+            return VLANType
+        if type(instance) == VMInterface:
+            return VMInterfaceType
+
+
+class FHRPGroupInterfaceType(graphene.Union):
+    class Meta:
+        types = (
+            InterfaceType,
+            VMInterfaceType,
+        )
+
+    @classmethod
+    def resolve_type(cls, instance, info):
+        if type(instance) == Interface:
+            return InterfaceType
+        if type(instance) == VMInterface:
+            return VMInterfaceType
+
+
+class VLANGroupScopeType(graphene.Union):
+    class Meta:
+        types = (
+            ClusterType,
+            ClusterGroupType,
+            LocationType,
+            RackType,
+            RegionType,
+            SiteType,
+            SiteGroupType,
+        )
+
+    @classmethod
+    def resolve_type(cls, instance, info):
+        if type(instance) == Cluster:
+            return ClusterType
+        if type(instance) == ClusterGroup:
+            return ClusterGroupType
+        if type(instance) == Location:
+            return LocationType
+        if type(instance) == Rack:
+            return RackType
+        if type(instance) == Region:
+            return RegionType
+        if type(instance) == Site:
+            return SiteType
+        if type(instance) == SiteGroup:
+            return SiteGroupType

+ 12 - 5
netbox/ipam/graphql/types.py

@@ -1,5 +1,7 @@
 import graphene
 import graphene
 
 
+from graphene_django import DjangoObjectType
+from extras.graphql.mixins import ContactsMixin
 from ipam import filtersets, models
 from ipam import filtersets, models
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
@@ -54,18 +56,20 @@ class FHRPGroupType(NetBoxObjectType):
 
 
 
 
 class FHRPGroupAssignmentType(BaseObjectType):
 class FHRPGroupAssignmentType(BaseObjectType):
+    interface = graphene.Field('ipam.graphql.gfk_mixins.FHRPGroupInterfaceType')
 
 
     class Meta:
     class Meta:
         model = models.FHRPGroupAssignment
         model = models.FHRPGroupAssignment
-        fields = '__all__'
+        exclude = ('interface_type', 'interface_id')
         filterset_class = filtersets.FHRPGroupAssignmentFilterSet
         filterset_class = filtersets.FHRPGroupAssignmentFilterSet
 
 
 
 
 class IPAddressType(NetBoxObjectType):
 class IPAddressType(NetBoxObjectType):
+    assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
 
 
     class Meta:
     class Meta:
         model = models.IPAddress
         model = models.IPAddress
-        fields = '__all__'
+        exclude = ('assigned_object_type', 'assigned_object_id')
         filterset_class = filtersets.IPAddressFilterSet
         filterset_class = filtersets.IPAddressFilterSet
 
 
     def resolve_role(self, info):
     def resolve_role(self, info):
@@ -140,10 +144,11 @@ class VLANType(NetBoxObjectType):
 
 
 
 
 class VLANGroupType(OrganizationalObjectType):
 class VLANGroupType(OrganizationalObjectType):
+    scope = graphene.Field('ipam.graphql.gfk_mixins.VLANGroupScopeType')
 
 
     class Meta:
     class Meta:
         model = models.VLANGroup
         model = models.VLANGroup
-        fields = '__all__'
+        exclude = ('scope_type', 'scope_id')
         filterset_class = filtersets.VLANGroupFilterSet
         filterset_class = filtersets.VLANGroupFilterSet
 
 
 
 
@@ -155,7 +160,7 @@ class VRFType(NetBoxObjectType):
         filterset_class = filtersets.VRFFilterSet
         filterset_class = filtersets.VRFFilterSet
 
 
 
 
-class L2VPNType(NetBoxObjectType):
+class L2VPNType(ContactsMixin, NetBoxObjectType):
     class Meta:
     class Meta:
         model = models.L2VPN
         model = models.L2VPN
         fields = '__all__'
         fields = '__all__'
@@ -163,7 +168,9 @@ class L2VPNType(NetBoxObjectType):
 
 
 
 
 class L2VPNTerminationType(NetBoxObjectType):
 class L2VPNTerminationType(NetBoxObjectType):
+    assigned_object = graphene.Field('ipam.graphql.gfk_mixins.L2VPNAssignmentType')
+
     class Meta:
     class Meta:
         model = models.L2VPNTermination
         model = models.L2VPNTermination
-        fields = '__all__'
+        exclude = ('assigned_object_type', 'assigned_object_id')
         filtersets_class = filtersets.L2VPNTerminationFilterSet
         filtersets_class = filtersets.L2VPNTerminationFilterSet

+ 2 - 0
netbox/ipam/models/services.py

@@ -92,6 +92,8 @@ class Service(ServiceBase, NetBoxModel):
         verbose_name='IP addresses'
         verbose_name='IP addresses'
     )
     )
 
 
+    clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ]
+
     class Meta:
     class Meta:
         ordering = ('protocol', 'ports', 'pk')  # (protocol, port) may be non-unique
         ordering = ('protocol', 'ports', 'pk')  # (protocol, port) may be non-unique
 
 

+ 69 - 0
netbox/ipam/search.py

@@ -0,0 +1,69 @@
+import ipam.filtersets
+import ipam.tables
+from ipam.models import ASN, VLAN, VRF, Aggregate, IPAddress, Prefix, Service
+from netbox.search import SearchIndex, register_search
+
+
+@register_search()
+class VRFIndex(SearchIndex):
+    model = VRF
+    queryset = VRF.objects.prefetch_related('tenant', 'tenant__group')
+    filterset = ipam.filtersets.VRFFilterSet
+    table = ipam.tables.VRFTable
+    url = 'ipam:vrf_list'
+
+
+@register_search()
+class AggregateIndex(SearchIndex):
+    model = Aggregate
+    queryset = Aggregate.objects.prefetch_related('rir')
+    filterset = ipam.filtersets.AggregateFilterSet
+    table = ipam.tables.AggregateTable
+    url = 'ipam:aggregate_list'
+
+
+@register_search()
+class PrefixIndex(SearchIndex):
+    model = Prefix
+    queryset = Prefix.objects.prefetch_related(
+        'site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'
+    )
+    filterset = ipam.filtersets.PrefixFilterSet
+    table = ipam.tables.PrefixTable
+    url = 'ipam:prefix_list'
+
+
+@register_search()
+class IPAddressIndex(SearchIndex):
+    model = IPAddress
+    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group')
+    filterset = ipam.filtersets.IPAddressFilterSet
+    table = ipam.tables.IPAddressTable
+    url = 'ipam:ipaddress_list'
+
+
+@register_search()
+class VLANIndex(SearchIndex):
+    model = VLAN
+    queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role')
+    filterset = ipam.filtersets.VLANFilterSet
+    table = ipam.tables.VLANTable
+    url = 'ipam:vlan_list'
+
+
+@register_search()
+class ASNIndex(SearchIndex):
+    model = ASN
+    queryset = ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group')
+    filterset = ipam.filtersets.ASNFilterSet
+    table = ipam.tables.ASNTable
+    url = 'ipam:asn_list'
+
+
+@register_search()
+class ServiceIndex(SearchIndex):
+    model = Service
+    queryset = Service.objects.prefetch_related('device', 'virtual_machine')
+    filterset = ipam.filtersets.ServiceFilterSet
+    table = ipam.tables.ServiceTable
+    url = 'ipam:service_list'

+ 8 - 0
netbox/netbox/authentication.py

@@ -351,6 +351,14 @@ class LDAPBackend:
         if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
         if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
             ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
             ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
 
 
+        # Optionally set CA cert directory
+        if ca_cert_dir := getattr(ldap_config, 'LDAP_CA_CERT_DIR', None):
+            ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, ca_cert_dir)
+
+        # Optionally set CA cert file
+        if ca_cert_file := getattr(ldap_config, 'LDAP_CA_CERT_FILE', None):
+            ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_cert_file)
+
         return obj
         return obj
 
 
 
 

+ 21 - 28
netbox/netbox/forms/__init__.py

@@ -1,31 +1,15 @@
 from django import forms
 from django import forms
 
 
-from netbox.search import SEARCH_TYPE_HIERARCHY
+from netbox.search.backends import default_search_engine
 from utilities.forms import BootstrapMixin
 from utilities.forms import BootstrapMixin
-from .base import *
-
-
-def build_search_choices():
-    result = list()
-    result.append(('', 'All Objects'))
-    for category, items in SEARCH_TYPE_HIERARCHY.items():
-        subcategories = list()
-        for slug, obj in items.items():
-            name = obj['queryset'].model._meta.verbose_name_plural
-            name = name[0].upper() + name[1:]
-            subcategories.append((slug, name))
-        result.append((category, tuple(subcategories)))
 
 
-    return tuple(result)
-
-
-OBJ_TYPE_CHOICES = build_search_choices()
+from .base import *
 
 
 
 
-def build_options():
-    options = [{"label": OBJ_TYPE_CHOICES[0][1], "items": []}]
+def build_options(choices):
+    options = [{"label": choices[0][1], "items": []}]
 
 
-    for label, choices in OBJ_TYPE_CHOICES[1:]:
+    for label, choices in choices[1:]:
         items = []
         items = []
 
 
         for value, choice_label in choices:
         for value, choice_label in choices:
@@ -36,10 +20,19 @@ def build_options():
 
 
 
 
 class SearchForm(BootstrapMixin, forms.Form):
 class SearchForm(BootstrapMixin, forms.Form):
-    q = forms.CharField(
-        label='Search'
-    )
-    obj_type = forms.ChoiceField(
-        choices=OBJ_TYPE_CHOICES, required=False, label='Type'
-    )
-    options = build_options()
+    q = forms.CharField(label='Search')
+    options = None
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields["obj_type"] = forms.ChoiceField(
+            choices=default_search_engine.get_search_choices(),
+            required=False,
+            label='Type'
+        )
+
+    def get_options(self):
+        if not self.options:
+            self.options = build_options(default_search_engine.get_search_choices())
+
+        return self.options

+ 49 - 3
netbox/netbox/graphql/__init__.py

@@ -1,9 +1,13 @@
 import graphene
 import graphene
-from graphene_django.converter import convert_django_field
-from taggit.managers import TaggableManager
-
 from dcim.fields import MACAddressField, WWNField
 from dcim.fields import MACAddressField, WWNField
+from django.db import models
+from graphene import Dynamic
+from graphene_django.converter import convert_django_field, get_django_field_description
+from graphene_django.fields import DjangoConnectionField
 from ipam.fields import IPAddressField, IPNetworkField
 from ipam.fields import IPAddressField, IPNetworkField
+from taggit.managers import TaggableManager
+
+from .fields import ObjectListField
 
 
 
 
 @convert_django_field.register(TaggableManager)
 @convert_django_field.register(TaggableManager)
@@ -21,3 +25,45 @@ def convert_field_to_tags_list(field, registry=None):
 def convert_field_to_string(field, registry=None):
 def convert_field_to_string(field, registry=None):
     # TODO: Update to use get_django_field_description under django_graphene v3.0
     # TODO: Update to use get_django_field_description under django_graphene v3.0
     return graphene.String(description=field.help_text, required=not field.null)
     return graphene.String(description=field.help_text, required=not field.null)
+
+
+@convert_django_field.register(models.ManyToManyField)
+@convert_django_field.register(models.ManyToManyRel)
+@convert_django_field.register(models.ManyToOneRel)
+def convert_field_to_list_or_connection(field, registry=None):
+    """
+    From graphene_django.converter.py we need to monkey-patch this to return
+    our ObjectListField with filtering support instead of DjangoListField
+    """
+    model = field.related_model
+
+    def dynamic_type():
+        _type = registry.get_type_for_model(model)
+        if not _type:
+            return
+
+        if isinstance(field, models.ManyToManyField):
+            description = get_django_field_description(field)
+        else:
+            description = get_django_field_description(field.field)
+
+        # If there is a connection, we should transform the field
+        # into a DjangoConnectionField
+        if _type._meta.connection:
+            # Use a DjangoFilterConnectionField if there are
+            # defined filter_fields or a filterset_class in the
+            # DjangoObjectType Meta
+            if _type._meta.filter_fields or _type._meta.filterset_class:
+                from .filter.fields import DjangoFilterConnectionField
+
+                return DjangoFilterConnectionField(_type, required=True, description=description)
+
+            return DjangoConnectionField(_type, required=True, description=description)
+
+        return ObjectListField(
+            _type,
+            required=True,  # A Set is always returned, never None.
+            description=description,
+        )
+
+    return Dynamic(dynamic_type)

+ 0 - 274
netbox/netbox/search.py

@@ -1,274 +0,0 @@
-import circuits.filtersets
-import circuits.tables
-import dcim.filtersets
-import dcim.tables
-import extras.filtersets
-import extras.tables
-import ipam.filtersets
-import ipam.tables
-import tenancy.filtersets
-import tenancy.tables
-import virtualization.filtersets
-import wireless.tables
-import wireless.filtersets
-import virtualization.tables
-from circuits.models import Circuit, ProviderNetwork, Provider
-from dcim.models import (
-    Cable, Device, DeviceType, Interface, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site,
-    VirtualChassis,
-)
-from extras.models import JournalEntry
-from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
-from tenancy.models import Contact, Tenant, ContactAssignment
-from utilities.utils import count_related
-from wireless.models import WirelessLAN, WirelessLink
-from virtualization.models import Cluster, VirtualMachine
-
-CIRCUIT_TYPES = {
-    'provider': {
-        'queryset': Provider.objects.annotate(
-            count_circuits=count_related(Circuit, 'provider')
-        ),
-        'filterset': circuits.filtersets.ProviderFilterSet,
-        'table': circuits.tables.ProviderTable,
-        'url': 'circuits:provider_list',
-    },
-    'circuit': {
-        'queryset': Circuit.objects.prefetch_related(
-            'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
-        ),
-        'filterset': circuits.filtersets.CircuitFilterSet,
-        'table': circuits.tables.CircuitTable,
-        'url': 'circuits:circuit_list',
-    },
-    'providernetwork': {
-        'queryset': ProviderNetwork.objects.prefetch_related('provider'),
-        'filterset': circuits.filtersets.ProviderNetworkFilterSet,
-        'table': circuits.tables.ProviderNetworkTable,
-        'url': 'circuits:providernetwork_list',
-    },
-}
-
-DCIM_TYPES = {
-    'site': {
-        'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'),
-        'filterset': dcim.filtersets.SiteFilterSet,
-        'table': dcim.tables.SiteTable,
-        'url': 'dcim:site_list',
-    },
-    'rack': {
-        'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate(
-            device_count=count_related(Device, 'rack')
-        ),
-        'filterset': dcim.filtersets.RackFilterSet,
-        'table': dcim.tables.RackTable,
-        'url': 'dcim:rack_list',
-    },
-    'rackreservation': {
-        'queryset': RackReservation.objects.prefetch_related('rack', 'user'),
-        'filterset': dcim.filtersets.RackReservationFilterSet,
-        'table': dcim.tables.RackReservationTable,
-        'url': 'dcim:rackreservation_list',
-    },
-    'location': {
-        'queryset': Location.objects.add_related_count(
-            Location.objects.add_related_count(
-                Location.objects.all(),
-                Device,
-                'location',
-                'device_count',
-                cumulative=True
-            ),
-            Rack,
-            'location',
-            'rack_count',
-            cumulative=True
-        ).prefetch_related('site'),
-        'filterset': dcim.filtersets.LocationFilterSet,
-        'table': dcim.tables.LocationTable,
-        'url': 'dcim:location_list',
-    },
-    'devicetype': {
-        'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
-            instance_count=count_related(Device, 'device_type')
-        ),
-        'filterset': dcim.filtersets.DeviceTypeFilterSet,
-        'table': dcim.tables.DeviceTypeTable,
-        'url': 'dcim:devicetype_list',
-    },
-    'device': {
-        'queryset': Device.objects.prefetch_related(
-            'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4',
-            'primary_ip6',
-        ),
-        'filterset': dcim.filtersets.DeviceFilterSet,
-        'table': dcim.tables.DeviceTable,
-        'url': 'dcim:device_list',
-    },
-    'moduletype': {
-        'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
-            instance_count=count_related(Module, 'module_type')
-        ),
-        'filterset': dcim.filtersets.ModuleTypeFilterSet,
-        'table': dcim.tables.ModuleTypeTable,
-        'url': 'dcim:moduletype_list',
-    },
-    'module': {
-        'queryset': Module.objects.prefetch_related(
-            'module_type__manufacturer', 'device', 'module_bay',
-        ),
-        'filterset': dcim.filtersets.ModuleFilterSet,
-        'table': dcim.tables.ModuleTable,
-        'url': 'dcim:module_list',
-    },
-    'virtualchassis': {
-        'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
-            member_count=count_related(Device, 'virtual_chassis')
-        ),
-        'filterset': dcim.filtersets.VirtualChassisFilterSet,
-        'table': dcim.tables.VirtualChassisTable,
-        'url': 'dcim:virtualchassis_list',
-    },
-    'cable': {
-        'queryset': Cable.objects.all(),
-        'filterset': dcim.filtersets.CableFilterSet,
-        'table': dcim.tables.CableTable,
-        'url': 'dcim:cable_list',
-    },
-    'powerfeed': {
-        'queryset': PowerFeed.objects.all(),
-        'filterset': dcim.filtersets.PowerFeedFilterSet,
-        'table': dcim.tables.PowerFeedTable,
-        'url': 'dcim:powerfeed_list',
-    },
-}
-
-IPAM_TYPES = {
-    'vrf': {
-        'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'),
-        'filterset': ipam.filtersets.VRFFilterSet,
-        'table': ipam.tables.VRFTable,
-        'url': 'ipam:vrf_list',
-    },
-    'aggregate': {
-        'queryset': Aggregate.objects.prefetch_related('rir'),
-        'filterset': ipam.filtersets.AggregateFilterSet,
-        'table': ipam.tables.AggregateTable,
-        'url': 'ipam:aggregate_list',
-    },
-    'prefix': {
-        'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'),
-        'filterset': ipam.filtersets.PrefixFilterSet,
-        'table': ipam.tables.PrefixTable,
-        'url': 'ipam:prefix_list',
-    },
-    'ipaddress': {
-        'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'),
-        'filterset': ipam.filtersets.IPAddressFilterSet,
-        'table': ipam.tables.IPAddressTable,
-        'url': 'ipam:ipaddress_list',
-    },
-    'vlan': {
-        'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'),
-        'filterset': ipam.filtersets.VLANFilterSet,
-        'table': ipam.tables.VLANTable,
-        'url': 'ipam:vlan_list',
-    },
-    'asn': {
-        'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'),
-        'filterset': ipam.filtersets.ASNFilterSet,
-        'table': ipam.tables.ASNTable,
-        'url': 'ipam:asn_list',
-    },
-    'service': {
-        'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
-        'filterset': ipam.filtersets.ServiceFilterSet,
-        'table': ipam.tables.ServiceTable,
-        'url': 'ipam:service_list',
-    },
-}
-
-TENANCY_TYPES = {
-    'tenant': {
-        'queryset': Tenant.objects.prefetch_related('group'),
-        'filterset': tenancy.filtersets.TenantFilterSet,
-        'table': tenancy.tables.TenantTable,
-        'url': 'tenancy:tenant_list',
-    },
-    'contact': {
-        'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
-            assignment_count=count_related(ContactAssignment, 'contact')),
-        'filterset': tenancy.filtersets.ContactFilterSet,
-        'table': tenancy.tables.ContactTable,
-        'url': 'tenancy:contact_list',
-    },
-}
-
-VIRTUALIZATION_TYPES = {
-    'cluster': {
-        'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
-            device_count=count_related(Device, 'cluster'),
-            vm_count=count_related(VirtualMachine, 'cluster')
-        ),
-        'filterset': virtualization.filtersets.ClusterFilterSet,
-        'table': virtualization.tables.ClusterTable,
-        'url': 'virtualization:cluster_list',
-    },
-    'virtualmachine': {
-        'queryset': VirtualMachine.objects.prefetch_related(
-            'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6',
-        ),
-        'filterset': virtualization.filtersets.VirtualMachineFilterSet,
-        'table': virtualization.tables.VirtualMachineTable,
-        'url': 'virtualization:virtualmachine_list',
-    },
-}
-
-WIRELESS_TYPES = {
-    'wirelesslan': {
-        'queryset': WirelessLAN.objects.prefetch_related('group', 'vlan').annotate(
-            interface_count=count_related(Interface, 'wireless_lans')
-        ),
-        'filterset': wireless.filtersets.WirelessLANFilterSet,
-        'table': wireless.tables.WirelessLANTable,
-        'url': 'wireless:wirelesslan_list',
-    },
-    'wirelesslink': {
-        'queryset': WirelessLink.objects.prefetch_related('interface_a__device', 'interface_b__device'),
-        'filterset': wireless.filtersets.WirelessLinkFilterSet,
-        'table': wireless.tables.WirelessLinkTable,
-        'url': 'wireless:wirelesslink_list',
-    },
-}
-
-JOURNAL_TYPES = {
-    'journalentry': {
-        'queryset': JournalEntry.objects.prefetch_related('assigned_object', 'created_by'),
-        'filterset': extras.filtersets.JournalEntryFilterSet,
-        'table': extras.tables.JournalEntryTable,
-        'url': 'extras:journalentry_list',
-    },
-}
-
-SEARCH_TYPE_HIERARCHY = {
-    'Circuits': CIRCUIT_TYPES,
-    'DCIM': DCIM_TYPES,
-    'IPAM': IPAM_TYPES,
-    'Tenancy': TENANCY_TYPES,
-    'Virtualization': VIRTUALIZATION_TYPES,
-    'Wireless': WIRELESS_TYPES,
-    'Journal': JOURNAL_TYPES,
-}
-
-
-def build_search_types():
-    result = dict()
-
-    for app_types in SEARCH_TYPE_HIERARCHY.values():
-        for name, items in app_types.items():
-            result[name] = items
-
-    return result
-
-
-SEARCH_TYPES = build_search_types()

+ 33 - 0
netbox/netbox/search/__init__.py

@@ -0,0 +1,33 @@
+from extras.registry import registry
+
+
+class SearchIndex:
+    """
+    Base class for building search indexes.
+
+    Attrs:
+        model: The model class for which this index is used.
+    """
+    model = None
+
+    @classmethod
+    def get_category(cls):
+        """
+        Return the title of the search category under which this model is registered.
+        """
+        if hasattr(cls, 'category'):
+            return cls.category
+        return cls.model._meta.app_config.verbose_name
+
+
+def register_search():
+    def _wrapper(cls):
+        model = cls.model
+        app_label = model._meta.app_label
+        model_name = model._meta.model_name
+
+        registry['search'][app_label][model_name] = cls
+
+        return cls
+
+    return _wrapper

+ 125 - 0
netbox/netbox/search/backends.py

@@ -0,0 +1,125 @@
+from collections import defaultdict
+from importlib import import_module
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.urls import reverse
+
+from extras.registry import registry
+from netbox.constants import SEARCH_MAX_RESULTS
+
+# The cache for the initialized backend.
+_backends_cache = {}
+
+
+class SearchEngineError(Exception):
+    """Something went wrong with a search engine."""
+    pass
+
+
+class SearchBackend:
+    """A search engine capable of performing multi-table searches."""
+    _search_choice_options = tuple()
+
+    def get_registry(self):
+        r = {}
+        for app_label, models in registry['search'].items():
+            r.update(**models)
+
+        return r
+
+    def get_search_choices(self):
+        """Return the set of choices for individual object types, organized by category."""
+        if not self._search_choice_options:
+
+            # Organize choices by category
+            categories = defaultdict(dict)
+            for app_label, models in registry['search'].items():
+                for name, cls in models.items():
+                    title = cls.model._meta.verbose_name.title()
+                    categories[cls.get_category()][name] = title
+
+            # Compile a nested tuple of choices for form rendering
+            results = (
+                ('', 'All Objects'),
+                *[(category, choices.items()) for category, choices in categories.items()]
+            )
+
+            self._search_choice_options = results
+
+        return self._search_choice_options
+
+    def search(self, request, value, **kwargs):
+        """Execute a search query for the given value."""
+        raise NotImplementedError
+
+    def cache(self, instance):
+        """Create or update the cached copy of an instance."""
+        raise NotImplementedError
+
+
+class FilterSetSearchBackend(SearchBackend):
+    """
+    Legacy search backend. Performs a discrete database query for each registered object type, using the FilterSet
+    class specified by the index for each.
+    """
+    def search(self, request, value, **kwargs):
+        results = []
+
+        search_registry = self.get_registry()
+        for obj_type in search_registry.keys():
+
+            queryset = search_registry[obj_type].queryset
+            url = search_registry[obj_type].url
+
+            # Restrict the queryset for the current user
+            if hasattr(queryset, 'restrict'):
+                queryset = queryset.restrict(request.user, 'view')
+
+            filterset = getattr(search_registry[obj_type], 'filterset', None)
+            if not filterset:
+                # This backend requires a FilterSet class for the model
+                continue
+
+            table = getattr(search_registry[obj_type], 'table', None)
+            if not table:
+                # This backend requires a Table class for the model
+                continue
+
+            # Construct the results table for this object type
+            filtered_queryset = filterset({'q': value}, queryset=queryset).qs
+            table = table(filtered_queryset, orderable=False)
+            table.paginate(per_page=SEARCH_MAX_RESULTS)
+
+            if table.page:
+                results.append({
+                    'name': queryset.model._meta.verbose_name_plural,
+                    'table': table,
+                    'url': f"{reverse(url)}?q={value}"
+                })
+
+        return results
+
+    def cache(self, instance):
+        # This backend does not utilize a cache
+        pass
+
+
+def get_backend():
+    """Initializes and returns the configured search backend."""
+    backend_name = settings.SEARCH_BACKEND
+
+    # Load the backend class
+    backend_module_name, backend_cls_name = backend_name.rsplit('.', 1)
+    backend_module = import_module(backend_module_name)
+    try:
+        backend_cls = getattr(backend_module, backend_cls_name)
+    except AttributeError:
+        raise ImproperlyConfigured(f"Could not find a class named {backend_module_name} in {backend_cls_name}")
+
+    # Initialize and return the backend instance
+    return backend_cls()
+
+
+default_search_engine = get_backend()
+search = default_search_engine.search

+ 2 - 7
netbox/netbox/settings.py

@@ -18,11 +18,6 @@ from sentry_sdk.integrations.django import DjangoIntegration
 
 
 from netbox.config import PARAMS
 from netbox.config import PARAMS
 
 
-# Monkey patch to fix Django 4.0 support for graphene-django (see
-# https://github.com/graphql-python/graphene-django/issues/1284)
-# TODO: Remove this when graphene-django 2.16 becomes available
-django.utils.encoding.force_text = force_str  # type: ignore
-
 
 
 #
 #
 # Environment setup
 # Environment setup
@@ -121,6 +116,7 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
+SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.FilterSetSearchBackend')
 SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
 SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
 SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
 SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
 SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
 SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
@@ -497,7 +493,7 @@ for param in dir(configuration):
 
 
 # Force usage of PostgreSQL's JSONB field for extra data
 # Force usage of PostgreSQL's JSONB field for extra data
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
-
+SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'netbox.users.utils.clean_username'
 
 
 #
 #
 # Django Prometheus
 # Django Prometheus
@@ -648,7 +644,6 @@ RQ_QUEUES = {
 #
 #
 
 
 for plugin_name in PLUGINS:
 for plugin_name in PLUGINS:
-
     # Import plugin module
     # Import plugin module
     try:
     try:
         plugin = importlib.import_module(plugin_name)
         plugin = importlib.import_module(plugin_name)

+ 4 - 21
netbox/netbox/views/__init__.py

@@ -23,7 +23,7 @@ from extras.tables import ObjectChangeTable
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from netbox.constants import SEARCH_MAX_RESULTS
 from netbox.constants import SEARCH_MAX_RESULTS
 from netbox.forms import SearchForm
 from netbox.forms import SearchForm
-from netbox.search import SEARCH_TYPES
+from netbox.search.backends import default_search_engine
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from virtualization.models import Cluster, VirtualMachine
 from virtualization.models import Cluster, VirtualMachine
 from wireless.models import WirelessLAN, WirelessLink
 from wireless.models import WirelessLAN, WirelessLink
@@ -153,31 +153,14 @@ class SearchView(View):
         results = []
         results = []
 
 
         if form.is_valid():
         if form.is_valid():
-
+            search_registry = default_search_engine.get_registry()
             # If an object type has been specified, redirect to the dedicated view for it
             # If an object type has been specified, redirect to the dedicated view for it
             if form.cleaned_data['obj_type']:
             if form.cleaned_data['obj_type']:
                 object_type = form.cleaned_data['obj_type']
                 object_type = form.cleaned_data['obj_type']
-                url = reverse(SEARCH_TYPES[object_type]['url'])
+                url = reverse(search_registry[object_type].url)
                 return redirect(f"{url}?q={form.cleaned_data['q']}")
                 return redirect(f"{url}?q={form.cleaned_data['q']}")
 
 
-            for obj_type in SEARCH_TYPES.keys():
-
-                queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view')
-                filterset = SEARCH_TYPES[obj_type]['filterset']
-                table = SEARCH_TYPES[obj_type]['table']
-                url = SEARCH_TYPES[obj_type]['url']
-
-                # Construct the results table for this object type
-                filtered_queryset = filterset({'q': form.cleaned_data['q']}, queryset=queryset).qs
-                table = table(filtered_queryset, orderable=False)
-                table.paginate(per_page=SEARCH_MAX_RESULTS)
-
-                if table.page:
-                    results.append({
-                        'name': queryset.model._meta.verbose_name_plural,
-                        'table': table,
-                        'url': f"{reverse(url)}?q={form.cleaned_data.get('q')}"
-                    })
+            results = default_search_engine.search(request, form.cleaned_data['q'])
 
 
         return render(request, 'search.html', {
         return render(request, 'search.html', {
             'form': form,
             'form': form,

+ 1 - 2
netbox/project-static/.eslintrc

@@ -31,8 +31,7 @@
     }
     }
   },
   },
   "rules": {
   "rules": {
-    "@typescript-eslint/no-unused-vars": "off",
-    "@typescript-eslint/no-unused-vars-experimental": "error",
+    "@typescript-eslint/no-unused-vars": "error",
     "no-unused-vars": "off",
     "no-unused-vars": "off",
     "no-inner-declarations": "off",
     "no-inner-declarations": "off",
     "comma-dangle": ["error", "always-multiline"],
     "comma-dangle": ["error", "always-multiline"],

+ 1 - 1
netbox/project-static/dist/cable_trace.css

@@ -1 +1 @@
-:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}
+:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Liberation Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/graphiql.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/graphiql.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/graphiql.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js.map


BIN
netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff


BIN
netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2


BIN
netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot


BIN
netbox/project-static/dist/materialdesignicons-webfont-WM6M6ZHQ.ttf


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-external.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-print.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 1 - 1
netbox/project-static/dist/rack_elevation.css

@@ -1 +1 @@
-svg{--nbx-rack-bg: #e9ecef;--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-netbox-color-mode=dark]{--nbx-rack-bg: #343a40;--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: #9ec5fe;--nbx-rack-unit-color: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg rect.shaded,svg image.shaded{opacity:25%}svg text.shaded{opacity:50%}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border);background-color:var(--nbx-rack-bg)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0}svg .reservation[class]{fill:url(#reserved)}
+svg{--nbx-rack-bg: #e9ecef;--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-netbox-color-mode=dark]{--nbx-rack-bg: #343a40;--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: #9ec5fe;--nbx-rack-unit-color: #6c757d}*{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Liberation Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:.875rem}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg rect.shaded,svg image.shaded{opacity:25%}svg text.shaded{opacity:50%}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border);background-color:var(--nbx-rack-bg)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0}svg .reservation[class]{fill:url(#reserved)}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 27 - 32
netbox/project-static/package.json

@@ -22,43 +22,38 @@
     "validate:formatting:scripts": "prettier -c src/**/*.ts"
     "validate:formatting:scripts": "prettier -c src/**/*.ts"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@mdi/font": "^5.9.55",
-    "@popperjs/core": "^2.9.2",
+    "@mdi/font": "^7.0.96",
+    "@popperjs/core": "^2.11.6",
     "bootstrap": "~5.0.2",
     "bootstrap": "~5.0.2",
-    "clipboard": "^2.0.8",
-    "color2k": "^1.2.4",
-    "dayjs": "^1.10.4",
+    "clipboard": "^2.0.11",
+    "color2k": "^2.0.0",
+    "dayjs": "^1.11.5",
     "flatpickr": "4.6.13",
     "flatpickr": "4.6.13",
-    "htmx.org": "^1.6.1",
-    "just-debounce-it": "^1.4.0",
+    "htmx.org": "^1.8.0",
+    "just-debounce-it": "^3.1.1",
     "masonry-layout": "^4.2.2",
     "masonry-layout": "^4.2.2",
-    "query-string": "^6.14.1",
-    "sass": "^1.32.8",
-    "simplebar": "^5.3.4",
-    "slim-select": "^1.27.0"
+    "query-string": "^7.1.1",
+    "sass": "^1.55.0",
+    "simplebar": "^5.3.9",
+    "slim-select": "^1.27.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@types/bootstrap": "^5.0.12",
-    "@types/cookie": "^0.4.0",
-    "@types/masonry-layout": "^4.2.2",
-    "@typescript-eslint/eslint-plugin": "^4.29.3",
-    "@typescript-eslint/parser": "^4.29.3",
-    "esbuild": "^0.12.24",
-    "esbuild-sass-plugin": "^1.5.2",
-    "eslint": "^7.32.0",
-    "eslint-config-prettier": "^8.3.0",
-    "eslint-import-resolver-typescript": "^2.4.0",
-    "eslint-plugin-import": "^2.24.2",
-    "eslint-plugin-prettier": "^3.4.1",
-    "prettier": "^2.3.2",
-    "typescript": "~4.3.5"
+    "@types/bootstrap": "^5.0.17",
+    "@types/cookie": "^0.5.1",
+    "@types/masonry-layout": "^4.2.5",
+    "@typescript-eslint/eslint-plugin": "^5.39.0",
+    "@typescript-eslint/parser": "^5.39.0",
+    "esbuild": "^0.13.15",
+    "esbuild-sass-plugin": "^2.3.3",
+    "eslint": "^8.24.0",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-import-resolver-typescript": "^3.5.1",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "prettier": "^2.7.1",
+    "typescript": "~4.8.4"
   },
   },
   "resolutions": {
   "resolutions": {
-    "eslint-import-resolver-typescript/**/path-parse": "^1.0.7",
-    "slim-select/**/trim-newlines": "^3.0.1",
-    "eslint/glob-parent": "^5.1.2",
-    "esbuild-sass-plugin/**/glob-parent": "^5.1.2",
-    "@typescript-eslint/**/glob-parent": "^5.1.2",
-    "eslint-plugin-import/**/hosted-git-info": "^2.8.9"
+    "@types/bootstrap/**/@popperjs/core": "^2.11.6"
   }
   }
-}
+}

+ 5 - 7
netbox/project-static/src/netbox.ts

@@ -37,14 +37,12 @@ function initDocument(): void {
 }
 }
 
 
 function initWindow(): void {
 function initWindow(): void {
-
-  const documentForms = document.forms
-  for (var documentForm of documentForms) {
+  const documentForms = document.forms;
+  for (const documentForm of documentForms) {
     if (documentForm.method.toUpperCase() == 'GET') {
     if (documentForm.method.toUpperCase() == 'GET') {
-      // @ts-ignore: Our version of typescript seems to be too old for FormDataEvent
-      documentForm.addEventListener('formdata', function(event: FormDataEvent) {
-      let formData: FormData = event.formData;
-      for (let [name, value] of Array.from(formData.entries())) {
+      documentForm.addEventListener('formdata', function (event: FormDataEvent) {
+        const formData: FormData = event.formData;
+        for (const [name, value] of Array.from(formData.entries())) {
           if (value === '') formData.delete(name);
           if (value === '') formData.delete(name);
         }
         }
       });
       });

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

@@ -32,7 +32,7 @@ $spacing-s: $input-padding-x;
   }
   }
 }
 }
 
 
-@import './node_modules/slim-select/src/slim-select/slimselect';
+@import '../node_modules/slim-select/src/slim-select/slimselect';
 
 
 .ss-main {
 .ss-main {
   color: $form-select-color;
   color: $form-select-color;

Разница между файлами не показана из-за своего большого размера
+ 461 - 433
netbox/project-static/yarn.lock


+ 3 - 9
netbox/templates/circuits/circuit.html

@@ -60,23 +60,17 @@
       </div>
       </div>
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/tags.html' %}
       {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/comments.html' %}
       {% plugin_left_page object %}
       {% plugin_left_page object %}
     </div>
     </div>
     <div class="col col-md-6">
     <div class="col col-md-6">
-      {% include 'inc/panels/comments.html' %}
+      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
+      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
       {% include 'inc/panels/contacts.html' %}
       {% include 'inc/panels/contacts.html' %}
       {% include 'inc/panels/image_attachments.html' %}
       {% include 'inc/panels/image_attachments.html' %}
       {% plugin_right_page object %}
       {% plugin_right_page object %}
     </div>
     </div>
   </div>
   </div>
-  <div class="row">
-    <div class="col col-md-6">
-      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
-    </div>
-    <div class="col col-md-6">
-      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
-    </div>
-  </div>
   <div class="row">
   <div class="row">
     <div class="col col-md-12">
     <div class="col col-md-12">
       {% plugin_full_width_page object %}
       {% plugin_full_width_page object %}

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

@@ -77,10 +77,10 @@
                       </button>
                       </button>
                       <ul class="dropdown-menu dropdown-menu-end">
                       <ul class="dropdown-menu dropdown-menu-end">
                         <li>
                         <li>
-                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Outlet</a>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
                         </li>
                         </li>
                         <li>
                         <li>
-                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Feed</a>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
                         </li>
                         </li>
                       </ul>
                       </ul>
                     </span>
                     </span>

+ 4 - 4
netbox/templates/dcim/rearport.html

@@ -105,16 +105,16 @@
                                 </button>
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Interface</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Interface</a>
                                     </li>
                                     </li>
                                     <li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
                                     </li>
                                     </li>
                                     <li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
                                     </li>
                                     </li>
                                     <li>
                                     <li>
-                                        <a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
+                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Circuit Termination</a>
                                     </li>
                                     </li>
                                 </ul>
                                 </ul>
                             </span>
                             </span>

+ 3 - 0
netbox/tenancy/apps.py

@@ -3,3 +3,6 @@ from django.apps import AppConfig
 
 
 class TenancyConfig(AppConfig):
 class TenancyConfig(AppConfig):
     name = 'tenancy'
     name = 'tenancy'
+
+    def ready(self):
+        from . import search

+ 18 - 0
netbox/tenancy/forms/models.py

@@ -27,6 +27,12 @@ class TenantGroupForm(NetBoxModelForm):
     )
     )
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Tenant Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup
         fields = [
         fields = [
@@ -64,6 +70,12 @@ class ContactGroupForm(NetBoxModelForm):
     )
     )
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Contact Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = ContactGroup
         model = ContactGroup
         fields = ('parent', 'name', 'slug', 'description', 'tags')
         fields = ('parent', 'name', 'slug', 'description', 'tags')
@@ -72,6 +84,12 @@ class ContactGroupForm(NetBoxModelForm):
 class ContactRoleForm(NetBoxModelForm):
 class ContactRoleForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Contact Role', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = ContactRole
         model = ContactRole
         fields = ('name', 'slug', 'description', 'tags')
         fields = ('name', 'slug', 'description', 'tags')

+ 25 - 0
netbox/tenancy/search.py

@@ -0,0 +1,25 @@
+import tenancy.filtersets
+import tenancy.tables
+from netbox.search import SearchIndex, register_search
+from tenancy.models import Contact, ContactAssignment, Tenant
+from utilities.utils import count_related
+
+
+@register_search()
+class TenantIndex(SearchIndex):
+    model = Tenant
+    queryset = Tenant.objects.prefetch_related('group')
+    filterset = tenancy.filtersets.TenantFilterSet
+    table = tenancy.tables.TenantTable
+    url = 'tenancy:tenant_list'
+
+
+@register_search()
+class ContactIndex(SearchIndex):
+    model = Contact
+    queryset = Contact.objects.prefetch_related('group', 'assignments').annotate(
+        assignment_count=count_related(ContactAssignment, 'contact')
+    )
+    filterset = tenancy.filtersets.ContactFilterSet
+    table = tenancy.tables.ContactTable
+    url = 'tenancy:contact_list'

+ 10 - 0
netbox/tenancy/tables/columns.py

@@ -1,6 +1,9 @@
 import django_tables2 as tables
 import django_tables2 as tables
 
 
+from netbox.tables import columns
+
 __all__ = (
 __all__ = (
+    'ContactsColumnMixin',
     'TenantColumn',
     'TenantColumn',
     'TenantGroupColumn',
     'TenantGroupColumn',
     'TenancyColumnsMixin',
     'TenancyColumnsMixin',
@@ -55,3 +58,10 @@ class TenantGroupColumn(tables.TemplateColumn):
 class TenancyColumnsMixin(tables.Table):
 class TenancyColumnsMixin(tables.Table):
     tenant_group = TenantGroupColumn()
     tenant_group = TenantGroupColumn()
     tenant = TenantColumn()
     tenant = TenantColumn()
+
+
+class ContactsColumnMixin(tables.Table):
+    contacts = columns.ManyToManyColumn(
+        linkify_item=True,
+        transform=lambda obj: obj.contact.name
+    )

+ 3 - 5
netbox/tenancy/tables/tenants.py

@@ -1,7 +1,8 @@
 import django_tables2 as tables
 import django_tables2 as tables
+from tenancy.models import *
+from tenancy.tables import ContactsColumnMixin
 
 
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
-from tenancy.models import *
 
 
 __all__ = (
 __all__ = (
     'TenantGroupTable',
     'TenantGroupTable',
@@ -30,7 +31,7 @@ class TenantGroupTable(NetBoxTable):
         default_columns = ('pk', 'name', 'tenant_count', 'description')
         default_columns = ('pk', 'name', 'tenant_count', 'description')
 
 
 
 
-class TenantTable(NetBoxTable):
+class TenantTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -38,9 +39,6 @@ class TenantTable(NetBoxTable):
         linkify=True
         linkify=True
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='tenancy:contact_list'
         url_name='tenancy:contact_list'
     )
     )

+ 9 - 0
netbox/users/utils.py

@@ -0,0 +1,9 @@
+from social_core.storage import NO_ASCII_REGEX, NO_SPECIAL_REGEX
+
+
+def clean_username(value):
+    """Clean username removing any unsupported character"""
+    value = NO_ASCII_REGEX.sub('', value)
+    value = NO_SPECIAL_REGEX.sub('', value)
+    value = value.replace(':', '')
+    return value

+ 6 - 4
netbox/utilities/templatetags/search.py

@@ -1,16 +1,18 @@
 from typing import Dict
 from typing import Dict
-from netbox.forms import SearchForm
+
 from django import template
 from django import template
 
 
-register = template.Library()
+from netbox.forms import SearchForm
 
 
+register = template.Library()
 search_form = SearchForm()
 search_form = SearchForm()
 
 
 
 
 @register.inclusion_tag("search/searchbar.html")
 @register.inclusion_tag("search/searchbar.html")
 def search_options(request) -> Dict:
 def search_options(request) -> Dict:
-    """Provide search options to template."""
+
+    # Provide search options to template.
     return {
     return {
-        'options': search_form.options,
+        'options': search_form.get_options(),
         'request': request,
         'request': request,
     }
     }

+ 3 - 0
netbox/utilities/testing/api.py

@@ -450,6 +450,9 @@ class APIViewTestCases:
                 if type(field) is GQLDynamic:
                 if type(field) is GQLDynamic:
                     # Dynamic fields must specify a subselection
                     # Dynamic fields must specify a subselection
                     fields_string += f'{field_name} {{ id }}\n'
                     fields_string += f'{field_name} {{ id }}\n'
+                elif inspect.isclass(field.type) and issubclass(field.type, GQLUnion):
+                    # Union types dont' have an id or consistent values
+                    continue
                 elif type(field.type) is GQLList and inspect.isclass(field.type.of_type) and issubclass(field.type.of_type, GQLUnion):
                 elif type(field.type) is GQLList and inspect.isclass(field.type.of_type) and issubclass(field.type.of_type, GQLUnion):
                     # Union types dont' have an id or consistent values
                     # Union types dont' have an id or consistent values
                     continue
                     continue

+ 7 - 4
netbox/utilities/views.py

@@ -140,19 +140,22 @@ class ViewTab:
 
 
     Args:
     Args:
         label: Human-friendly text
         label: Human-friendly text
-        badge: A static value or callable to display alongside the label (optional). If a callable is used, it must accept a single
-            argument representing the object being viewed.
+        badge: A static value or callable to display alongside the label (optional). If a callable is used, it must
+            accept a single argument representing the object being viewed.
         permission: The permission required to display the tab (optional).
         permission: The permission required to display the tab (optional).
+        hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (Tabs without a
+            badge are always displayed.)
     """
     """
-    def __init__(self, label, badge=None, permission=None):
+    def __init__(self, label, badge=None, permission=None, hide_if_empty=False):
         self.label = label
         self.label = label
         self.badge = badge
         self.badge = badge
         self.permission = permission
         self.permission = permission
+        self.hide_if_empty = hide_if_empty
 
 
     def render(self, instance):
     def render(self, instance):
         """Return the attributes needed to render a tab in HTML."""
         """Return the attributes needed to render a tab in HTML."""
         badge_value = self._get_badge_value(instance)
         badge_value = self._get_badge_value(instance)
-        if self.badge and not badge_value:
+        if self.badge and self.hide_if_empty and not badge_value:
             return None
             return None
         return {
         return {
             'label': self.label,
             'label': self.label,

+ 3 - 0
netbox/virtualization/apps.py

@@ -3,3 +3,6 @@ from django.apps import AppConfig
 
 
 class VirtualizationConfig(AppConfig):
 class VirtualizationConfig(AppConfig):
     name = 'virtualization'
     name = 'virtualization'
+
+    def ready(self):
+        from . import search

+ 12 - 0
netbox/virtualization/forms/models.py

@@ -28,6 +28,12 @@ __all__ = (
 class ClusterTypeForm(NetBoxModelForm):
 class ClusterTypeForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Cluster Type', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = ClusterType
         model = ClusterType
         fields = (
         fields = (
@@ -38,6 +44,12 @@ class ClusterTypeForm(NetBoxModelForm):
 class ClusterGroupForm(NetBoxModelForm):
 class ClusterGroupForm(NetBoxModelForm):
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Cluster Group', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
         fields = (
         fields = (

+ 33 - 0
netbox/virtualization/search.py

@@ -0,0 +1,33 @@
+import virtualization.filtersets
+import virtualization.tables
+from dcim.models import Device
+from netbox.search import SearchIndex, register_search
+from utilities.utils import count_related
+from virtualization.models import Cluster, VirtualMachine
+
+
+@register_search()
+class ClusterIndex(SearchIndex):
+    model = Cluster
+    queryset = Cluster.objects.prefetch_related('type', 'group').annotate(
+        device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster')
+    )
+    filterset = virtualization.filtersets.ClusterFilterSet
+    table = virtualization.tables.ClusterTable
+    url = 'virtualization:cluster_list'
+
+
+@register_search()
+class VirtualMachineIndex(SearchIndex):
+    model = VirtualMachine
+    queryset = VirtualMachine.objects.prefetch_related(
+        'cluster',
+        'tenant',
+        'tenant__group',
+        'platform',
+        'primary_ip4',
+        'primary_ip6',
+    )
+    filterset = virtualization.filtersets.VirtualMachineFilterSet
+    table = virtualization.tables.VirtualMachineTable
+    url = 'virtualization:virtualmachine_list'

+ 4 - 10
netbox/virtualization/tables/clusters.py

@@ -1,8 +1,8 @@
 import django_tables2 as tables
 import django_tables2 as tables
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 from netbox.tables import NetBoxTable, columns
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
-from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 __all__ = (
 __all__ = (
     'ClusterTable',
     'ClusterTable',
@@ -32,7 +32,7 @@ class ClusterTypeTable(NetBoxTable):
         default_columns = ('pk', 'name', 'cluster_count', 'description')
         default_columns = ('pk', 'name', 'cluster_count', 'description')
 
 
 
 
-class ClusterGroupTable(NetBoxTable):
+class ClusterGroupTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -41,9 +41,6 @@ class ClusterGroupTable(NetBoxTable):
         url_params={'group_id': 'pk'},
         url_params={'group_id': 'pk'},
         verbose_name='Clusters'
         verbose_name='Clusters'
     )
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:clustergroup_list'
         url_name='virtualization:clustergroup_list'
     )
     )
@@ -57,7 +54,7 @@ class ClusterGroupTable(NetBoxTable):
         default_columns = ('pk', 'name', 'cluster_count', 'description')
         default_columns = ('pk', 'name', 'cluster_count', 'description')
 
 
 
 
-class ClusterTable(TenancyColumnsMixin, NetBoxTable):
+class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -81,9 +78,6 @@ class ClusterTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name='VMs'
         verbose_name='VMs'
     )
     )
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:cluster_list'
         url_name='virtualization:cluster_list'
     )
     )

+ 4 - 7
netbox/virtualization/tables/virtualmachines.py

@@ -1,10 +1,10 @@
 import django_tables2 as tables
 import django_tables2 as tables
-
 from dcim.tables.devices import BaseInterfaceTable
 from dcim.tables.devices import BaseInterfaceTable
-from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
 
 
+from netbox.tables import NetBoxTable, columns
+
 __all__ = (
 __all__ = (
     'VirtualMachineTable',
     'VirtualMachineTable',
     'VirtualMachineVMInterfaceTable',
     'VirtualMachineVMInterfaceTable',
@@ -37,7 +37,7 @@ VMINTERFACE_BUTTONS = """
 # Virtual machines
 # Virtual machines
 #
 #
 
 
-class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
+class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         order_by=('_name',),
         order_by=('_name',),
         linkify=True
         linkify=True
@@ -67,9 +67,6 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
         order_by=('primary_ip4', 'primary_ip6'),
         order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
         verbose_name='IP Address'
     )
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:virtualmachine_list'
         url_name='virtualization:virtualmachine_list'
     )
     )

+ 1 - 1
netbox/wireless/apps.py

@@ -5,4 +5,4 @@ class WirelessConfig(AppConfig):
     name = 'wireless'
     name = 'wireless'
 
 
     def ready(self):
     def ready(self):
-        import wireless.signals
+        from . import signals, search

+ 6 - 0
netbox/wireless/forms/models.py

@@ -19,6 +19,12 @@ class WirelessLANGroupForm(NetBoxModelForm):
     )
     )
     slug = SlugField()
     slug = SlugField()
 
 
+    fieldsets = (
+        ('Wireless LAN Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
     class Meta:
         model = WirelessLANGroup
         model = WirelessLANGroup
         fields = [
         fields = [

+ 26 - 0
netbox/wireless/search.py

@@ -0,0 +1,26 @@
+import wireless.filtersets
+import wireless.tables
+from dcim.models import Interface
+from netbox.search import SearchIndex, register_search
+from utilities.utils import count_related
+from wireless.models import WirelessLAN, WirelessLink
+
+
+@register_search()
+class WirelessLANIndex(SearchIndex):
+    model = WirelessLAN
+    queryset = WirelessLAN.objects.prefetch_related('group', 'vlan').annotate(
+        interface_count=count_related(Interface, 'wireless_lans')
+    )
+    filterset = wireless.filtersets.WirelessLANFilterSet
+    table = wireless.tables.WirelessLANTable
+    url = 'wireless:wirelesslan_list'
+
+
+@register_search()
+class WirelessLinkIndex(SearchIndex):
+    model = WirelessLink
+    queryset = WirelessLink.objects.prefetch_related('interface_a__device', 'interface_b__device')
+    filterset = wireless.filtersets.WirelessLinkFilterSet
+    table = wireless.tables.WirelessLinkTable
+    url = 'wireless:wirelesslink_list'

+ 4 - 1
requirements.txt

@@ -27,10 +27,13 @@ psycopg2-binary==2.9.3
 PyYAML==6.0
 PyYAML==6.0
 sentry-sdk==1.9.10
 sentry-sdk==1.9.10
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
-social-auth-core==4.3.0
+social-auth-core[openidconnect]==4.3.0
 svgwrite==1.4.3
 svgwrite==1.4.3
 tablib==3.2.1
 tablib==3.2.1
 tzdata==2022.4
 tzdata==2022.4
 
 
 # Workaround for #7401
 # Workaround for #7401
 jsonschema==3.2.0
 jsonschema==3.2.0
+
+# Temporary fix for #10712
+swagger-spec-validator==2.7.6

Некоторые файлы не были показаны из-за большого количества измененных файлов