Explorar o código

Merge branch 'feature' into 8366-job-scheduling

jeremystretch %!s(int64=3) %!d(string=hai) anos
pai
achega
893925436d
Modificáronse 100 ficheiros con 1847 adicións e 1064 borrados
  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=BIN
      netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff
  64. BIN=BIN
      netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2
  65. BIN=BIN
      netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot
  66. BIN=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
       description: To what section of the documentation does this change primarily pertain?
       options:
-        - Installation instructions
-        - Configuration parameters
-        - Functionality/features
-        - REST API
-        - Administration/development
+        - Features
+        - Installation/upgrade
+        - Getting started
+        - Configuration
+        - Customization
+        - Integrations/API
+        - Plugins
+        - Administration
+        - Development
         - Other
     validations:
       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
 
 !!! 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
 import ldap
@@ -67,6 +67,16 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
 # 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_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.

+ 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 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
 
@@ -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}}"}
 ```
+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
 

+ 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.
 
 ::: utilities.forms.ColorField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CommentField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.JSONField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.MACAddressField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.SlugField
-    selection:
+    options:
       members: false
 
 ## Choice Fields
 
 ::: utilities.forms.ChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.MultipleChoiceField
-    selection:
+    options:
       members: false
 
 ## Dynamic Object Fields
 
 ::: utilities.forms.DynamicModelChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.DynamicModelMultipleChoiceField
-    selection:
+    options:
       members: false
 
 ## Content Type Fields
 
 ::: utilities.forms.ContentTypeChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.ContentTypeMultipleChoiceField
-    selection:
+    options:
       members: false
 
 ## CSV Import Fields
 
 ::: utilities.forms.CSVChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CSVMultipleChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CSVModelChoiceField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CSVContentTypeField
-    selection:
+    options:
       members: false
 
 ::: utilities.forms.CSVMultipleContentTypeField
-    selection:
+    options:
       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.graphql.types.BaseObjectType
-    selection:
+    options:
       members: false
 
 ::: netbox.graphql.types.NetBoxObjectType
-    selection:
+    options:
       members: false
 
 ## 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.graphql.fields.ObjectField
-    selection:
+    options:
       members: false
 
 ::: netbox.graphql.fields.ObjectListField
-    selection:
+    options:
       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                                                            |
 | `middleware`          | A list of middleware classes to append after NetBox's build-in middleware                                                |
 | `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`)              |
 | `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`)                                 |

+ 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`.
 
 ::: netbox.tables.BooleanColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ChoiceFieldColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ColorColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ColoredLabelColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ContentTypeColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.ContentTypesColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.MarkdownColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.TagColumn
-    selection:
+    options:
       members: false
 
 ::: netbox.tables.TemplateColumn
-    selection:
+    options:
       members:
         - __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.ObjectView
-    selection:
+    options:
       members:
         - get_object
         - get_template_name
 
 ::: netbox.views.generic.ObjectEditView
-    selection:
+    options:
       members:
         - get_object
         - alter_object
 
 ::: netbox.views.generic.ObjectDeleteView
-    selection:
+    options:
       members:
         - get_object
 
 ::: netbox.views.generic.ObjectChildrenView
-    selection:
+    options:
       members:
         - get_children
         - 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.ObjectListView
-    selection:
+    options:
       members:
         - get_table
         - export_table
         - export_template
 
 ::: netbox.views.generic.BulkImportView
-    selection:
+    options:
       members: false
 
 ::: netbox.views.generic.BulkEditView
-    selection:
+    options:
       members: false
 
 ::: netbox.views.generic.BulkDeleteView
-    selection:
+    options:
       members:
         - 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.
 
 ::: netbox.views.generic.ObjectChangeLogView
-    selection:
+    options:
       members:
         - get_form
 
 ::: netbox.views.generic.ObjectJournalView
-    selection:
+    options:
       members:
         - get_form
 

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

@@ -2,6 +2,21 @@
 
 ## 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)

+ 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
 
+* [#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
 * [#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
+* [#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
 * [#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
+* [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields
 
 ### 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
 * [#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
@@ -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
 * [#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
+* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function
 
 ### 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 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.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
             - django.setup()
-          rendering:
+          options:
             heading_level: 3
             members_order: source
             show_root_heading: true
@@ -132,6 +132,7 @@ nav:
             - GraphQL API: 'plugins/development/graphql-api.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
             - Exceptions: 'plugins/development/exceptions.md'
+            - Search: 'plugins/development/search.md'
     - Administration:
         - Authentication:
             - Overview: 'administration/authentication/overview.md'

+ 1 - 1
netbox/circuits/apps.py

@@ -6,4 +6,4 @@ class CircuitsConfig(AppConfig):
     verbose_name = "Circuits"
 
     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):
     slug = SlugField()
 
+    fieldsets = (
+        ('Circuit Type', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = CircuitType
         fields = [

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

@@ -1,6 +1,8 @@
+import graphene
+
 from circuits import filtersets, models
 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
 
 __all__ = (
@@ -20,8 +22,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob
         filterset_class = filtersets.CircuitTerminationFilterSet
 
 
-class CircuitType(NetBoxObjectType):
-
+class CircuitType(NetBoxObjectType, ContactsMixin):
     class Meta:
         model = models.Circuit
         fields = '__all__'
@@ -36,7 +37,7 @@ class CircuitTypeType(OrganizationalObjectType):
         filterset_class = filtersets.CircuitTypeFilterSet
 
 
-class ProviderType(NetBoxObjectType):
+class ProviderType(NetBoxObjectType, ContactsMixin):
 
     class Meta:
         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
-
 from circuits.models import *
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
 from .columns import CommitRateColumn
 
 __all__ = (
@@ -39,7 +40,7 @@ class CircuitTypeTable(NetBoxTable):
         default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
 
 
-class CircuitTable(TenancyColumnsMixin, NetBoxTable):
+class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     cid = tables.Column(
         linkify=True,
         verbose_name='Circuit ID'
@@ -58,9 +59,6 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
     )
     commit_rate = CommitRateColumn()
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='circuits:circuit_list'
     )

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

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

+ 1 - 1
netbox/dcim/apps.py

@@ -8,7 +8,7 @@ class DCIMConfig(AppConfig):
     verbose_name = "DCIM"
 
     def ready(self):
-        import dcim.signals
+        from . import signals, search
         from .models import CableTermination
 
         # 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',
                         disabled_indicator='_occupied',
                         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()
 
+    fieldsets = (
+        ('Region', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = Region
         fields = (
@@ -92,6 +98,12 @@ class SiteGroupForm(NetBoxModelForm):
     )
     slug = SlugField()
 
+    fieldsets = (
+        ('Site Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = SiteGroup
         fields = (
@@ -213,6 +225,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
 class RackRoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Rack Role', (
+            'name', 'slug', 'color', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = RackRole
         fields = [
@@ -341,6 +359,12 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
 class ManufacturerForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Manufacturer', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = Manufacturer
         fields = [
@@ -413,6 +437,12 @@ class ModuleTypeForm(NetBoxModelForm):
 class DeviceRoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Device Role', (
+            'name', 'slug', 'color', 'vm_role', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = DeviceRole
         fields = [
@@ -429,6 +459,13 @@ class PlatformForm(NetBoxModelForm):
         max_length=64
     )
 
+    fieldsets = (
+        ('Platform', (
+            'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
+
+        )),
+    )
+
     class Meta:
         model = Platform
         fields = [
@@ -1584,6 +1621,12 @@ class InventoryItemForm(DeviceComponentForm):
 class InventoryItemRoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Inventory Item Role', (
+            'name', 'slug', 'color', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = InventoryItemRole
         fields = [

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

@@ -2,24 +2,38 @@ import graphene
 from circuits.graphql.types import CircuitTerminationType
 from circuits.models import CircuitTermination
 from dcim.graphql.types import (
+    ConsolePortTemplateType,
     ConsolePortType,
+    ConsoleServerPortTemplateType,
     ConsoleServerPortType,
+    FrontPortTemplateType,
     FrontPortType,
+    InterfaceTemplateType,
     InterfaceType,
     PowerFeedType,
+    PowerOutletTemplateType,
     PowerOutletType,
+    PowerPortTemplateType,
     PowerPortType,
+    RearPortTemplateType,
     RearPortType,
 )
 from dcim.models import (
     ConsolePort,
+    ConsolePortTemplate,
     ConsoleServerPort,
+    ConsoleServerPortTemplate,
     FrontPort,
+    FrontPortTemplate,
     Interface,
+    InterfaceTemplate,
     PowerFeed,
     PowerOutlet,
+    PowerOutletTemplate,
     PowerPort,
+    PowerPortTemplate,
     RearPort,
+    RearPortTemplate,
 )
 
 
@@ -57,3 +71,99 @@ class LinkPeerType(graphene.Union):
             return PowerPortType
         if type(instance) == RearPort:
             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 extras.graphql.mixins import (
-    ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
+    ChangelogMixin, ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
 )
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.scalars import BigInt
@@ -87,6 +87,8 @@ class ComponentTemplateObjectType(
 #
 
 class CableType(NetBoxObjectType):
+    a_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType')
+    b_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType')
 
     class Meta:
         model = models.Cable
@@ -99,12 +101,19 @@ class CableType(NetBoxObjectType):
     def resolve_length_unit(self, info):
         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):
+    termination = graphene.Field('dcim.graphql.gfk_mixins.CableTerminationTerminationType')
 
     class Meta:
         model = models.CableTermination
-        fields = '__all__'
+        exclude = ('termination_type', 'termination_id')
         filterset_class = filtersets.CableTerminationFilterSet
 
 
@@ -152,7 +161,7 @@ class ConsoleServerPortTemplateType(ComponentTemplateObjectType):
         return self.type or None
 
 
-class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, NetBoxObjectType):
+class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
 
     class Meta:
         model = models.Device
@@ -183,10 +192,11 @@ class DeviceBayTemplateType(ComponentTemplateObjectType):
 
 
 class InventoryItemTemplateType(ComponentTemplateObjectType):
+    component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemTemplateComponentType')
 
     class Meta:
         model = models.InventoryItemTemplate
-        fields = '__all__'
+        exclude = ('component_type', 'component_id')
         filterset_class = filtersets.InventoryItemTemplateFilterSet
 
 
@@ -269,10 +279,11 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
 
 
 class InventoryItemType(ComponentObjectType):
+    component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType')
 
     class Meta:
         model = models.InventoryItem
-        fields = '__all__'
+        exclude = ('component_type', 'component_id')
         filterset_class = filtersets.InventoryItemFilterSet
 
 
@@ -284,7 +295,7 @@ class InventoryItemRoleType(OrganizationalObjectType):
         filterset_class = filtersets.InventoryItemRoleFilterSet
 
 
-class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType):
+class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType):
 
     class Meta:
         model = models.Location
@@ -292,7 +303,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectT
         filterset_class = filtersets.LocationFilterSet
 
 
-class ManufacturerType(OrganizationalObjectType):
+class ManufacturerType(OrganizationalObjectType, ContactsMixin):
 
     class Meta:
         model = models.Manufacturer
@@ -379,7 +390,7 @@ class PowerOutletTemplateType(ComponentTemplateObjectType):
         return self.type or None
 
 
-class PowerPanelType(NetBoxObjectType):
+class PowerPanelType(NetBoxObjectType, ContactsMixin):
 
     class Meta:
         model = models.PowerPanel
@@ -409,7 +420,7 @@ class PowerPortTemplateType(ComponentTemplateObjectType):
         return self.type or None
 
 
-class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
+class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
 
     class Meta:
         model = models.Rack
@@ -458,7 +469,7 @@ class RearPortTemplateType(ComponentTemplateObjectType):
         filterset_class = filtersets.RearPortTemplateFilterSet
 
 
-class RegionType(VLANGroupsMixin, OrganizationalObjectType):
+class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
 
     class Meta:
         model = models.Region
@@ -466,7 +477,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType):
         filterset_class = filtersets.RegionFilterSet
 
 
-class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
+class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
     asn = graphene.Field(BigInt)
 
     class Meta:
@@ -475,7 +486,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
         filterset_class = filtersets.SiteFilterSet
 
 
-class SiteGroupType(VLANGroupsMixin, OrganizationalObjectType):
+class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
 
     class Meta:
         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
-from django_tables2.utils import Accessor
-
 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 tenancy.tables import TenancyColumnsMixin
+
 from .template_code import *
 
 __all__ = (
@@ -137,7 +151,7 @@ class PlatformTable(NetBoxTable):
 # Devices
 #
 
-class DeviceTable(TenancyColumnsMixin, NetBoxTable):
+class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.TemplateColumn(
         order_by=('_name',),
         template_code=DEVICE_LINK
@@ -201,9 +215,6 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name='VC Priority'
     )
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:device_list'
     )

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

@@ -1,10 +1,21 @@
 import django_tables2 as tables
 
 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 tenancy.tables import ContactsColumnMixin
 from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
 
 __all__ = (
@@ -27,7 +38,7 @@ __all__ = (
 # Manufacturers
 #
 
-class ManufacturerTable(NetBoxTable):
+class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -43,9 +54,6 @@ class ManufacturerTable(NetBoxTable):
         verbose_name='Platforms'
     )
     slug = tables.Column()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:manufacturer_list'
     )

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

@@ -1,7 +1,9 @@
 import django_tables2 as tables
-
 from dcim.models import PowerFeed, PowerPanel
+from tenancy.tables import ContactsColumnMixin
+
 from netbox.tables import NetBoxTable, columns
+
 from .devices import CableTerminationTable
 
 __all__ = (
@@ -14,7 +16,7 @@ __all__ = (
 # Power panels
 #
 
-class PowerPanelTable(NetBoxTable):
+class PowerPanelTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -29,9 +31,6 @@ class PowerPanelTable(NetBoxTable):
         url_params={'power_panel_id': 'pk'},
         verbose_name='Feeds'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         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 netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
 from .template_code import DEVICE_WEIGHT
 
 __all__ = (
@@ -38,7 +38,7 @@ class RackRoleTable(NetBoxTable):
 # Racks
 #
 
-class RackTable(TenancyColumnsMixin, NetBoxTable):
+class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         order_by=('_name',),
         linkify=True
@@ -69,9 +69,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
         orderable=False,
         verbose_name='Power'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='dcim:rack_list'
     )

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

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

+ 55 - 28
netbox/dcim/views.py

@@ -951,7 +951,8 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
     tab = ViewTab(
         label=_('Console Ports'),
         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(
         label=_('Console Server Ports'),
         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(
         label=_('Power Ports'),
         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(
         label=_('Power Outlets'),
         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(
         label=_('Interfaces'),
         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(
         label=_('Front Ports'),
         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(
         label=_('Rear Ports'),
         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(
         label=_('Module Bays'),
         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(
         label=_('Device Bays'),
         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(
         label=_('Inventory Items'),
         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(
         label=_('Console Ports'),
         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(
         label=_('Console Server Ports'),
         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(
         label=_('Power Ports'),
         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(
         label=_('Power Outlets'),
         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(
         label=_('Interfaces'),
         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(
         label=_('Front Ports'),
         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(
         label=_('Rear Ports'),
         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
     table = tables.DeviceConsolePortTable
     filterset = filtersets.ConsolePortFilterSet
-    template_name = 'dcim/device/consoleports.html'
+    template_name = 'dcim/device/consoleports.html',
     tab = ViewTab(
         label=_('Console Ports'),
         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(
         label=_('Console Server Ports'),
         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(
         label=_('Power Ports'),
         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(
         label=_('Power Outlets'),
         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(
         label=_('Interfaces'),
         badge=lambda obj: obj.interfaces.count(),
-        permission='dcim.view_interface'
+        permission='dcim.view_interface',
+        hide_if_empty=True
     )
 
     def get_children(self, request, parent):
@@ -1920,7 +1942,8 @@ class DeviceFrontPortsView(DeviceComponentsView):
     tab = ViewTab(
         label=_('Front Ports'),
         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(
         label=_('Rear Ports'),
         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(
         label=_('Module Bays'),
         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(
         label=_('Device Bays'),
         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(
         label=_('Inventory Items'),
         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"
 
     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):
         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:
         model = models.CustomField
-        fields = '__all__'
+        exclude = ('content_types', )
         filterset_class = filtersets.CustomFieldFilterSet
 
 
@@ -83,5 +83,5 @@ class WebhookType(ObjectType):
 
     class Meta:
         model = models.Webhook
-        fields = '__all__'
+        exclude = ('content_types', )
         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.core.exceptions import ImproperlyConfigured
 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 netbox.navigation import MenuGroup
+from netbox.search import register_search
 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
     # integrated components.
+    search_indexes = 'search.indexes'
     graphql_schema = 'graphql.schema'
     menu = 'navigation.menu'
     menu_items = 'navigation.menu_items'
@@ -69,26 +71,46 @@ class PluginConfig(AppConfig):
     def ready(self):
         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)
-        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)
+        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)
-        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)
+        except ImportError:
+            pass
 
         # 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)
+        except ImportError:
+            pass
 
         # 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)
+        except ImportError:
+            pass
 
     @classmethod
     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.contrib.admin.views.decorators import staff_member_required
 from django.urls import path
-
-from extras.plugins.utils import import_object
+from django.utils.module_loading import import_string
 
 from . import views
 
@@ -25,15 +24,19 @@ for plugin_path in settings.PLUGINS:
     base_url = getattr(app, 'base_url') or app.label
 
     # 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(
             path(f"{base_url}/", include((urlpatterns, app.label)))
         )
+    except ImportError:
+        pass
 
     # 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(
             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
 }
 registry['denormalized_fields'] = collections.defaultdict(list)
+registry['search'] = 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"
 
     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):
     slug = SlugField()
 
+    fieldsets = (
+        ('RIR', (
+            'name', 'slug', 'is_private', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = RIR
         fields = [
@@ -164,6 +170,12 @@ class ASNForm(TenancyForm, NetBoxModelForm):
 class RoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Role', (
+            'name', 'slug', 'weight', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = Role
         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."
     )
 
+    fieldsets = (
+        ('Service Template', (
+            'name', 'protocol', 'ports', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ServiceTemplate
         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
 
+from graphene_django import DjangoObjectType
+from extras.graphql.mixins import ContactsMixin
 from ipam import filtersets, models
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
@@ -54,18 +56,20 @@ class FHRPGroupType(NetBoxObjectType):
 
 
 class FHRPGroupAssignmentType(BaseObjectType):
+    interface = graphene.Field('ipam.graphql.gfk_mixins.FHRPGroupInterfaceType')
 
     class Meta:
         model = models.FHRPGroupAssignment
-        fields = '__all__'
+        exclude = ('interface_type', 'interface_id')
         filterset_class = filtersets.FHRPGroupAssignmentFilterSet
 
 
 class IPAddressType(NetBoxObjectType):
+    assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
 
     class Meta:
         model = models.IPAddress
-        fields = '__all__'
+        exclude = ('assigned_object_type', 'assigned_object_id')
         filterset_class = filtersets.IPAddressFilterSet
 
     def resolve_role(self, info):
@@ -140,10 +144,11 @@ class VLANType(NetBoxObjectType):
 
 
 class VLANGroupType(OrganizationalObjectType):
+    scope = graphene.Field('ipam.graphql.gfk_mixins.VLANGroupScopeType')
 
     class Meta:
         model = models.VLANGroup
-        fields = '__all__'
+        exclude = ('scope_type', 'scope_id')
         filterset_class = filtersets.VLANGroupFilterSet
 
 
@@ -155,7 +160,7 @@ class VRFType(NetBoxObjectType):
         filterset_class = filtersets.VRFFilterSet
 
 
-class L2VPNType(NetBoxObjectType):
+class L2VPNType(ContactsMixin, NetBoxObjectType):
     class Meta:
         model = models.L2VPN
         fields = '__all__'
@@ -163,7 +168,9 @@ class L2VPNType(NetBoxObjectType):
 
 
 class L2VPNTerminationType(NetBoxObjectType):
+    assigned_object = graphene.Field('ipam.graphql.gfk_mixins.L2VPNAssignmentType')
+
     class Meta:
         model = models.L2VPNTermination
-        fields = '__all__'
+        exclude = ('assigned_object_type', 'assigned_object_id')
         filtersets_class = filtersets.L2VPNTerminationFilterSet

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

@@ -92,6 +92,8 @@ class Service(ServiceBase, NetBoxModel):
         verbose_name='IP addresses'
     )
 
+    clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ]
+
     class Meta:
         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):
             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
 
 

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

@@ -1,31 +1,15 @@
 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 .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 = []
 
         for value, choice_label in choices:
@@ -36,10 +20,19 @@ def build_options():
 
 
 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
-from graphene_django.converter import convert_django_field
-from taggit.managers import TaggableManager
-
 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 taggit.managers import TaggableManager
+
+from .fields import ObjectListField
 
 
 @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):
     # TODO: Update to use get_django_field_description under django_graphene v3.0
     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
 
-# 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
@@ -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('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 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_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
 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
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
-
+SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'netbox.users.utils.clean_username'
 
 #
 # Django Prometheus
@@ -648,7 +644,6 @@ RQ_QUEUES = {
 #
 
 for plugin_name in PLUGINS:
-
     # Import plugin module
     try:
         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 netbox.constants import SEARCH_MAX_RESULTS
 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 virtualization.models import Cluster, VirtualMachine
 from wireless.models import WirelessLAN, WirelessLink
@@ -153,31 +153,14 @@ class SearchView(View):
         results = []
 
         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 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']}")
 
-            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', {
             'form': form,

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

@@ -31,8 +31,7 @@
     }
   },
   "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-inner-declarations": "off",
     "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}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/config.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/config.js.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/graphiql.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/graphiql.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/graphiql.js.map


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/lldp.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/lldp.js.map


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


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


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


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


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-external.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-light.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox-print.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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)}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/status.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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"
   },
   "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",
-    "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",
-    "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",
-    "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": {
-    "@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": {
-    "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 {
-
-  const documentForms = document.forms
-  for (var documentForm of documentForms) {
+  const documentForms = document.forms;
+  for (const documentForm of documentForms) {
     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);
         }
       });

+ 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 {
   color: $form-select-color;

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 461 - 433
netbox/project-static/yarn.lock


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

@@ -60,23 +60,17 @@
       </div>
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/comments.html' %}
       {% plugin_left_page object %}
     </div>
     <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/image_attachments.html' %}
       {% plugin_right_page object %}
     </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="col col-md-12">
       {% plugin_full_width_page object %}

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

@@ -77,10 +77,10 @@
                       </button>
                       <ul class="dropdown-menu dropdown-menu-end">
                         <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>
-                          <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>
                       </ul>
                     </span>

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

@@ -105,16 +105,16 @@
                                 </button>
                                 <ul class="dropdown-menu dropdown-menu-end">
                                     <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>
-                                        <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>
-                                        <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>
-                                        <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>
                                 </ul>
                             </span>

+ 3 - 0
netbox/tenancy/apps.py

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

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

@@ -27,6 +27,12 @@ class TenantGroupForm(NetBoxModelForm):
     )
     slug = SlugField()
 
+    fieldsets = (
+        ('Tenant Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = TenantGroup
         fields = [
@@ -64,6 +70,12 @@ class ContactGroupForm(NetBoxModelForm):
     )
     slug = SlugField()
 
+    fieldsets = (
+        ('Contact Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ContactGroup
         fields = ('parent', 'name', 'slug', 'description', 'tags')
@@ -72,6 +84,12 @@ class ContactGroupForm(NetBoxModelForm):
 class ContactRoleForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Contact Role', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ContactRole
         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
 
+from netbox.tables import columns
+
 __all__ = (
+    'ContactsColumnMixin',
     'TenantColumn',
     'TenantGroupColumn',
     'TenancyColumnsMixin',
@@ -55,3 +58,10 @@ class TenantGroupColumn(tables.TemplateColumn):
 class TenancyColumnsMixin(tables.Table):
     tenant_group = TenantGroupColumn()
     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
+from tenancy.models import *
+from tenancy.tables import ContactsColumnMixin
 
 from netbox.tables import NetBoxTable, columns
-from tenancy.models import *
 
 __all__ = (
     'TenantGroupTable',
@@ -30,7 +31,7 @@ class TenantGroupTable(NetBoxTable):
         default_columns = ('pk', 'name', 'tenant_count', 'description')
 
 
-class TenantTable(NetBoxTable):
+class TenantTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -38,9 +39,6 @@ class TenantTable(NetBoxTable):
         linkify=True
     )
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         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 netbox.forms import SearchForm
+
 from django import template
 
-register = template.Library()
+from netbox.forms import SearchForm
 
+register = template.Library()
 search_form = SearchForm()
 
 
 @register.inclusion_tag("search/searchbar.html")
 def search_options(request) -> Dict:
-    """Provide search options to template."""
+
+    # Provide search options to template.
     return {
-        'options': search_form.options,
+        'options': search_form.get_options(),
         'request': request,
     }

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

@@ -450,6 +450,9 @@ class APIViewTestCases:
                 if type(field) is GQLDynamic:
                     # Dynamic fields must specify a subselection
                     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):
                     # Union types dont' have an id or consistent values
                     continue

+ 7 - 4
netbox/utilities/views.py

@@ -140,19 +140,22 @@ class ViewTab:
 
     Args:
         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).
+        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.badge = badge
         self.permission = permission
+        self.hide_if_empty = hide_if_empty
 
     def render(self, instance):
         """Return the attributes needed to render a tab in HTML."""
         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 {
             'label': self.label,

+ 3 - 0
netbox/virtualization/apps.py

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

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

@@ -28,6 +28,12 @@ __all__ = (
 class ClusterTypeForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Cluster Type', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ClusterType
         fields = (
@@ -38,6 +44,12 @@ class ClusterTypeForm(NetBoxModelForm):
 class ClusterGroupForm(NetBoxModelForm):
     slug = SlugField()
 
+    fieldsets = (
+        ('Cluster Group', (
+            'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ClusterGroup
         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
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
-from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 __all__ = (
     'ClusterTable',
@@ -32,7 +32,7 @@ class ClusterTypeTable(NetBoxTable):
         default_columns = ('pk', 'name', 'cluster_count', 'description')
 
 
-class ClusterGroupTable(NetBoxTable):
+class ClusterGroupTable(ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -41,9 +41,6 @@ class ClusterGroupTable(NetBoxTable):
         url_params={'group_id': 'pk'},
         verbose_name='Clusters'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='virtualization:clustergroup_list'
     )
@@ -57,7 +54,7 @@ class ClusterGroupTable(NetBoxTable):
         default_columns = ('pk', 'name', 'cluster_count', 'description')
 
 
-class ClusterTable(TenancyColumnsMixin, NetBoxTable):
+class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         linkify=True
     )
@@ -81,9 +78,6 @@ class ClusterTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name='VMs'
     )
     comments = columns.MarkdownColumn()
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='virtualization:cluster_list'
     )

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

@@ -1,10 +1,10 @@
 import django_tables2 as tables
-
 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 netbox.tables import NetBoxTable, columns
+
 __all__ = (
     'VirtualMachineTable',
     'VirtualMachineVMInterfaceTable',
@@ -37,7 +37,7 @@ VMINTERFACE_BUTTONS = """
 # Virtual machines
 #
 
-class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
+class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     name = tables.Column(
         order_by=('_name',),
         linkify=True
@@ -67,9 +67,6 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
         order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
         url_name='virtualization:virtualmachine_list'
     )

+ 1 - 1
netbox/wireless/apps.py

@@ -5,4 +5,4 @@ class WirelessConfig(AppConfig):
     name = 'wireless'
 
     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()
 
+    fieldsets = (
+        ('Wireless LAN Group', (
+            'parent', 'name', 'slug', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = WirelessLANGroup
         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
 sentry-sdk==1.9.10
 social-auth-app-django==5.0.0
-social-auth-core==4.3.0
+social-auth-core[openidconnect]==4.3.0
 svgwrite==1.4.3
 tablib==3.2.1
 tzdata==2022.4
 
 # Workaround for #7401
 jsonschema==3.2.0
+
+# Temporary fix for #10712
+swagger-spec-validator==2.7.6

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio