Merge branch 'feature' into 8366-job-scheduling

This commit is contained in:
jeremystretch 2022-10-20 17:03:57 -04:00
commit 893925436d
100 changed files with 2887 additions and 2660 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

34
netbox/circuits/search.py Normal file
View File

@ -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'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

143
netbox/dcim/search.py Normal file
View File

@ -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'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)

View File

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

14
netbox/extras/search.py Normal file
View File

@ -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'

View File

@ -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,
)

View File

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

View File

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

View File

@ -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

View File

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

View File

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

69
netbox/ipam/search.py Normal file
View File

@ -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'

View File

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

View File

@ -1,31 +1,15 @@
from django import forms from django import forms
from netbox.search import SEARCH_TYPE_HIERARCHY from netbox.search.backends import default_search_engine
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin
from .base import * from .base import *
def build_search_choices(): def build_options(choices):
result = list() options = [{"label": choices[0][1], "items": []}]
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) for label, choices in choices[1:]:
OBJ_TYPE_CHOICES = build_search_choices()
def build_options():
options = [{"label": OBJ_TYPE_CHOICES[0][1], "items": []}]
for label, choices in OBJ_TYPE_CHOICES[1:]:
items = [] items = []
for value, choice_label in choices: for value, choice_label in choices:
@ -36,10 +20,19 @@ def build_options():
class SearchForm(BootstrapMixin, forms.Form): class SearchForm(BootstrapMixin, forms.Form):
q = forms.CharField( q = forms.CharField(label='Search')
label='Search' options = None
)
obj_type = forms.ChoiceField( def __init__(self, *args, **kwargs):
choices=OBJ_TYPE_CHOICES, required=False, label='Type' super().__init__(*args, **kwargs)
) self.fields["obj_type"] = forms.ChoiceField(
options = build_options() 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

View File

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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

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

View File

@ -23,7 +23,7 @@ from extras.tables import ObjectChangeTable
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
from netbox.constants import SEARCH_MAX_RESULTS from netbox.constants import SEARCH_MAX_RESULTS
from netbox.forms import SearchForm from netbox.forms import SearchForm
from netbox.search import SEARCH_TYPES from netbox.search.backends import default_search_engine
from tenancy.models import Tenant from tenancy.models import Tenant
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from wireless.models import WirelessLAN, WirelessLink from wireless.models import WirelessLAN, WirelessLink
@ -153,31 +153,14 @@ class SearchView(View):
results = [] results = []
if form.is_valid(): if form.is_valid():
search_registry = default_search_engine.get_registry()
# If an object type has been specified, redirect to the dedicated view for it # If an object type has been specified, redirect to the dedicated view for it
if form.cleaned_data['obj_type']: if form.cleaned_data['obj_type']:
object_type = form.cleaned_data['obj_type'] object_type = form.cleaned_data['obj_type']
url = reverse(SEARCH_TYPES[object_type]['url']) url = reverse(search_registry[object_type].url)
return redirect(f"{url}?q={form.cleaned_data['q']}") return redirect(f"{url}?q={form.cleaned_data['q']}")
for obj_type in SEARCH_TYPES.keys(): results = default_search_engine.search(request, form.cleaned_data['q'])
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')}"
})
return render(request, 'search.html', { return render(request, 'search.html', {
'form': form, 'form': form,

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

25
netbox/tenancy/search.py Normal file
View File

@ -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'

View File

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

View File

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

9
netbox/users/utils.py Normal file
View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'

View File

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

View File

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

View File

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

View File

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

26
netbox/wireless/search.py Normal file
View File

@ -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'

View File

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