mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-24 16:26:09 -06:00
Merge branch 'feature' into 7961-csv-bulk-update
This commit is contained in:
commit
89893185ea
14
.github/ISSUE_TEMPLATE/documentation_change.yaml
vendored
14
.github/ISSUE_TEMPLATE/documentation_change.yaml
vendored
@ -19,11 +19,15 @@ body:
|
||||
label: Area
|
||||
description: To what section of the documentation does this change primarily pertain?
|
||||
options:
|
||||
- Installation instructions
|
||||
- Configuration parameters
|
||||
- Functionality/features
|
||||
- REST API
|
||||
- Administration/development
|
||||
- Features
|
||||
- Installation/upgrade
|
||||
- Getting started
|
||||
- Configuration
|
||||
- Customization
|
||||
- Integrations/API
|
||||
- Plugins
|
||||
- Administration
|
||||
- Development
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
@ -157,6 +157,14 @@ The file path to the location where [custom scripts](../customization/custom-scr
|
||||
|
||||
---
|
||||
|
||||
## SEARCH_BACKEND
|
||||
|
||||
Default: `'netbox.search.backends.CachedValueSearchBackend'`
|
||||
|
||||
The dotted path to the desired search backend class. `CachedValueSearchBackend` is currently the only search backend provided in NetBox, however this setting can be used to enable a custom backend.
|
||||
|
||||
---
|
||||
|
||||
## STORAGE_BACKEND
|
||||
|
||||
Default: None (local storage)
|
||||
|
@ -267,7 +267,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
|
||||
### Via the Web UI
|
||||
|
||||
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button.
|
||||
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object.
|
||||
|
||||
### Via the API
|
||||
|
||||
@ -282,6 +282,8 @@ http://netbox/api/extras/scripts/example.MyReport/ \
|
||||
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
|
||||
```
|
||||
|
||||
Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
|
||||
|
||||
### Via the CLI
|
||||
|
||||
Scripts can be run on the CLI by invoking the management command:
|
||||
|
@ -136,7 +136,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
|
||||
### Via the Web UI
|
||||
|
||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
|
||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view. It is possible to schedule a report to be executed at specified time in the future. A scheduled report can be canceled by deleting the associated job result object.
|
||||
|
||||
### Via the API
|
||||
|
||||
@ -152,6 +152,8 @@ Our example report above would be called as:
|
||||
POST /api/extras/reports/devices.DeviceConnectionsReport/run/
|
||||
```
|
||||
|
||||
Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
|
||||
|
||||
### Via the CLI
|
||||
|
||||
Reports can be run on the CLI by invoking the management command:
|
||||
|
37
docs/development/search.md
Normal file
37
docs/development/search.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Search
|
||||
|
||||
NetBox v3.4 introduced a new global search mechanism, which employs the `extras.CachedValue` model to store discrete field values from many models in a single table.
|
||||
|
||||
## SearchIndex
|
||||
|
||||
To enable search support for a model, declare and register a subclass of `netbox.search.SearchIndex` for it. Typically, this will be done within an app's `search.py` module.
|
||||
|
||||
```python
|
||||
from netbox.search import SearchIndex, register_search
|
||||
|
||||
@register_search
|
||||
class MyModelIndex(SearchIndex):
|
||||
model = MyModel
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
```
|
||||
|
||||
A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below.
|
||||
|
||||
### Field Weight Guidance
|
||||
|
||||
| Weight | Field Role | Examples |
|
||||
|--------|--------------------------------------------------|----------------------------------------------------|
|
||||
| 50 | Unique serialized attribute | Device.asset_tag |
|
||||
| 60 | Unique serialized attribute (per related object) | Device.serial |
|
||||
| 100 | Primary human identifier | Device.name, Circuit.cid, Cable.label |
|
||||
| 110 | Slug | Site.slug |
|
||||
| 200 | Secondary identifier | Provider.account, DeviceType.part_number |
|
||||
| 300 | Highly unique descriptive attribute | CircuitTermination.xconnect_id, IPAddress.dns_name |
|
||||
| 500 | Description | Site.description |
|
||||
| 1000 | Custom field default | - |
|
||||
| 2000 | Other discrete attribute | CircuitTermination.port_speed |
|
||||
| 5000 | Comment field | Site.comments |
|
@ -46,7 +46,7 @@ Next, create a file in the same directory as `configuration.py` (typically `/opt
|
||||
### General Server Configuration
|
||||
|
||||
!!! info
|
||||
When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
|
||||
When using Active Directory you may need to specify a port on `AUTH_LDAP_SERVER_URI` to authenticate users from all domains in the forest. Use `3269` for secure, or `3268` for non-secure access to the GC (Global Catalog).
|
||||
|
||||
```python
|
||||
import ldap
|
||||
@ -67,6 +67,16 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
LDAP_IGNORE_CERT_ERRORS = True
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
|
||||
LDAP_CA_CERT_DIR = '/etc/ssl/certs'
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against your own CA.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
|
||||
LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
|
||||
```
|
||||
|
||||
STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
|
||||
|
@ -144,73 +144,73 @@ class MyModelFilterForm(NetBoxModelFilterSetForm):
|
||||
In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
|
||||
|
||||
::: utilities.forms.ColorField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CommentField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.JSONField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.MACAddressField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.SlugField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## Choice Fields
|
||||
|
||||
::: utilities.forms.ChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.MultipleChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## Dynamic Object Fields
|
||||
|
||||
::: utilities.forms.DynamicModelChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.DynamicModelMultipleChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## Content Type Fields
|
||||
|
||||
::: utilities.forms.ContentTypeChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.ContentTypeMultipleChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## CSV Import Fields
|
||||
|
||||
::: utilities.forms.CSVChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CSVMultipleChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CSVModelChoiceField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CSVContentTypeField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: utilities.forms.CSVMultipleContentTypeField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
@ -32,11 +32,11 @@ schema = MyQuery
|
||||
NetBox provides two object type classes for use by plugins.
|
||||
|
||||
::: netbox.graphql.types.BaseObjectType
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.graphql.types.NetBoxObjectType
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
## GraphQL Fields
|
||||
@ -44,9 +44,9 @@ NetBox provides two object type classes for use by plugins.
|
||||
NetBox provides two field classes for use by plugins.
|
||||
|
||||
::: netbox.graphql.fields.ObjectField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.graphql.fields.ObjectListField
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
@ -4,17 +4,16 @@ Plugins can define and register their own models to extend NetBox's core search
|
||||
|
||||
```python
|
||||
# search.py
|
||||
from netbox.search import SearchMixin
|
||||
from .filters import MyModelFilterSet
|
||||
from .tables import MyModelTable
|
||||
from netbox.search import SearchIndex
|
||||
from .models import MyModel
|
||||
|
||||
class MyModelIndex(SearchMixin):
|
||||
class MyModelIndex(SearchIndex):
|
||||
model = MyModel
|
||||
queryset = MyModel.objects.all()
|
||||
filterset = MyModelFilterSet
|
||||
table = MyModelTable
|
||||
url = 'plugins:myplugin:mymodel_list'
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
```
|
||||
|
||||
To register one or more indexes with NetBox, define a list named `indexes` at the end of this 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`.
|
||||
|
||||
::: netbox.tables.BooleanColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ChoiceFieldColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ColorColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ColoredLabelColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ContentTypeColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.ContentTypesColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.MarkdownColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.TagColumn
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.tables.TemplateColumn
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- __init__
|
||||
|
@ -84,24 +84,24 @@ Below are the class definitions for NetBox's object views. These views handle CR
|
||||
::: netbox.views.generic.base.BaseObjectView
|
||||
|
||||
::: netbox.views.generic.ObjectView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_object
|
||||
- get_template_name
|
||||
|
||||
::: netbox.views.generic.ObjectEditView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_object
|
||||
- alter_object
|
||||
|
||||
::: netbox.views.generic.ObjectDeleteView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_object
|
||||
|
||||
::: netbox.views.generic.ObjectChildrenView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_children
|
||||
- prep_table_data
|
||||
@ -113,22 +113,22 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
||||
::: netbox.views.generic.base.BaseMultiObjectView
|
||||
|
||||
::: netbox.views.generic.ObjectListView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_table
|
||||
- export_table
|
||||
- export_template
|
||||
|
||||
::: netbox.views.generic.BulkImportView
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkEditView
|
||||
selection:
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkDeleteView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_form
|
||||
|
||||
@ -137,12 +137,12 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
||||
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
|
||||
|
||||
::: netbox.views.generic.ObjectChangeLogView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_form
|
||||
|
||||
::: netbox.views.generic.ObjectJournalView
|
||||
selection:
|
||||
options:
|
||||
members:
|
||||
- get_form
|
||||
|
||||
|
@ -2,6 +2,21 @@
|
||||
|
||||
## v3.3.6 (FUTURE)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates
|
||||
* [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication
|
||||
* [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth
|
||||
* [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link
|
||||
* [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms
|
||||
* [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable
|
||||
* [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables
|
||||
* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
|
||||
|
||||
---
|
||||
|
||||
## v3.3.5 (2022-10-05)
|
||||
|
@ -11,6 +11,10 @@
|
||||
|
||||
### New Features
|
||||
|
||||
#### New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560))
|
||||
|
||||
NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup.
|
||||
|
||||
#### Top-Level Plugin Navigation Menus ([#9071](https://github.com/netbox-community/netbox/issues/9071))
|
||||
|
||||
A new `PluginMenu` class has been introduced, which enables a plugin to inject a top-level menu in NetBox's navigation menu. This menu can have one or more groups of menu items, just like core items. Backward compatibility with the existing `menu_items` has been maintained.
|
||||
|
@ -30,7 +30,7 @@ plugins:
|
||||
- os.chdir('netbox/')
|
||||
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
|
||||
- django.setup()
|
||||
rendering:
|
||||
options:
|
||||
heading_level: 3
|
||||
members_order: source
|
||||
show_root_heading: true
|
||||
@ -245,6 +245,7 @@ nav:
|
||||
- Adding Models: 'development/adding-models.md'
|
||||
- Extending Models: 'development/extending-models.md'
|
||||
- Signals: 'development/signals.md'
|
||||
- Search: 'development/search.md'
|
||||
- Application Registry: 'development/application-registry.md'
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
- Web UI: 'development/web-ui.md'
|
||||
|
@ -64,6 +64,12 @@ class ProviderNetworkForm(NetBoxModelForm):
|
||||
class CircuitTypeForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Circuit Type', (
|
||||
'name', 'slug', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = [
|
||||
|
@ -1,34 +1,55 @@
|
||||
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
|
||||
from . import models
|
||||
|
||||
|
||||
@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()
|
||||
@register_search
|
||||
class CircuitIndex(SearchIndex):
|
||||
model = Circuit
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
|
||||
model = models.Circuit
|
||||
fields = (
|
||||
('cid', 100),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
filterset = circuits.filtersets.CircuitFilterSet
|
||||
table = circuits.tables.CircuitTable
|
||||
url = 'circuits:circuit_list'
|
||||
|
||||
|
||||
@register_search()
|
||||
@register_search
|
||||
class CircuitTerminationIndex(SearchIndex):
|
||||
model = models.CircuitTermination
|
||||
fields = (
|
||||
('xconnect_id', 300),
|
||||
('pp_info', 300),
|
||||
('description', 500),
|
||||
('port_speed', 2000),
|
||||
('upstream_speed', 2000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class CircuitTypeIndex(SearchIndex):
|
||||
model = models.CircuitType
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class ProviderIndex(SearchIndex):
|
||||
model = models.Provider
|
||||
fields = (
|
||||
('name', 100),
|
||||
('account', 200),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
|
||||
@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'
|
||||
model = models.ProviderNetwork
|
||||
fields = (
|
||||
('name', 100),
|
||||
('service_id', 200),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
@ -1,8 +1,9 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from circuits.models import *
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
from .columns import CommitRateColumn
|
||||
|
||||
__all__ = (
|
||||
@ -39,7 +40,7 @@ class CircuitTypeTable(NetBoxTable):
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
||||
|
||||
|
||||
class CircuitTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
cid = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Circuit ID'
|
||||
@ -58,9 +59,6 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
)
|
||||
|
@ -1,7 +1,8 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from circuits.models import *
|
||||
from django_tables2.utils import Accessor
|
||||
from tenancy.tables import ContactsColumnMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
|
||||
__all__ = (
|
||||
@ -10,7 +11,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderTable(NetBoxTable):
|
||||
class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@ -31,9 +32,6 @@ class ProviderTable(NetBoxTable):
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:provider_list'
|
||||
)
|
||||
|
@ -108,7 +108,7 @@ def get_cable_form(a_type, b_type):
|
||||
label='Power Feed',
|
||||
disabled_indicator='_occupied',
|
||||
query_params={
|
||||
'powerpanel_id': f'$termination_{cable_end}_powerpanel',
|
||||
'power_panel_id': f'$termination_{cable_end}_powerpanel',
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -78,6 +78,12 @@ class RegionForm(NetBoxModelForm):
|
||||
)
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Region', (
|
||||
'parent', 'name', 'slug', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = (
|
||||
@ -92,6 +98,12 @@ class SiteGroupForm(NetBoxModelForm):
|
||||
)
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Site Group', (
|
||||
'parent', 'name', 'slug', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SiteGroup
|
||||
fields = (
|
||||
@ -213,6 +225,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
||||
class RackRoleForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Rack Role', (
|
||||
'name', 'slug', 'color', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = [
|
||||
@ -341,6 +359,12 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
|
||||
class ManufacturerForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Manufacturer', (
|
||||
'name', 'slug', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = [
|
||||
@ -413,6 +437,12 @@ class ModuleTypeForm(NetBoxModelForm):
|
||||
class DeviceRoleForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Device Role', (
|
||||
'name', 'slug', 'color', 'vm_role', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = [
|
||||
@ -429,6 +459,13 @@ class PlatformForm(NetBoxModelForm):
|
||||
max_length=64
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Platform', (
|
||||
'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
||||
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
@ -1584,6 +1621,12 @@ class InventoryItemForm(DeviceComponentForm):
|
||||
class InventoryItemRoleForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Inventory Item Role', (
|
||||
'name', 'slug', 'color', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItemRole
|
||||
fields = [
|
||||
|
@ -1,143 +1,293 @@
|
||||
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
|
||||
from . import models
|
||||
|
||||
|
||||
@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()
|
||||
@register_search
|
||||
class CableIndex(SearchIndex):
|
||||
model = Cable
|
||||
queryset = Cable.objects.all()
|
||||
filterset = dcim.filtersets.CableFilterSet
|
||||
table = dcim.tables.CableTable
|
||||
url = 'dcim:cable_list'
|
||||
model = models.Cable
|
||||
fields = (
|
||||
('label', 100),
|
||||
)
|
||||
|
||||
|
||||
@register_search()
|
||||
@register_search
|
||||
class ConsolePortIndex(SearchIndex):
|
||||
model = models.ConsolePort
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
('speed', 2000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class ConsoleServerPortIndex(SearchIndex):
|
||||
model = models.ConsoleServerPort
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
('speed', 2000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class DeviceIndex(SearchIndex):
|
||||
model = models.Device
|
||||
fields = (
|
||||
('asset_tag', 50),
|
||||
('serial', 60),
|
||||
('name', 100),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class DeviceBayIndex(SearchIndex):
|
||||
model = models.DeviceBay
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class DeviceRoleIndex(SearchIndex):
|
||||
model = models.DeviceRole
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class DeviceTypeIndex(SearchIndex):
|
||||
model = models.DeviceType
|
||||
fields = (
|
||||
('model', 100),
|
||||
('part_number', 200),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class FrontPortIndex(SearchIndex):
|
||||
model = models.FrontPort
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class InterfaceIndex(SearchIndex):
|
||||
model = models.Interface
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('mac_address', 300),
|
||||
('wwn', 300),
|
||||
('description', 500),
|
||||
('mtu', 2000),
|
||||
('speed', 2000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class InventoryItemIndex(SearchIndex):
|
||||
model = models.InventoryItem
|
||||
fields = (
|
||||
('asset_tag', 50),
|
||||
('serial', 60),
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
('part_id', 2000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class LocationIndex(SearchIndex):
|
||||
model = models.Location
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class ManufacturerIndex(SearchIndex):
|
||||
model = models.Manufacturer
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class ModuleIndex(SearchIndex):
|
||||
model = models.Module
|
||||
fields = (
|
||||
('asset_tag', 50),
|
||||
('serial', 60),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class ModuleBayIndex(SearchIndex):
|
||||
model = models.ModuleBay
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class ModuleTypeIndex(SearchIndex):
|
||||
model = models.ModuleType
|
||||
fields = (
|
||||
('model', 100),
|
||||
('part_number', 200),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class PlatformIndex(SearchIndex):
|
||||
model = models.Platform
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('napalm_driver', 300),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class PowerFeedIndex(SearchIndex):
|
||||
model = PowerFeed
|
||||
queryset = PowerFeed.objects.all()
|
||||
filterset = dcim.filtersets.PowerFeedFilterSet
|
||||
table = dcim.tables.PowerFeedTable
|
||||
url = 'dcim:powerfeed_list'
|
||||
model = models.PowerFeed
|
||||
fields = (
|
||||
('name', 100),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class PowerOutletIndex(SearchIndex):
|
||||
model = models.PowerOutlet
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class PowerPanelIndex(SearchIndex):
|
||||
model = models.PowerPanel
|
||||
fields = (
|
||||
('name', 100),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class PowerPortIndex(SearchIndex):
|
||||
model = models.PowerPort
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
('maximum_draw', 2000),
|
||||
('allocated_draw', 2000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class RackIndex(SearchIndex):
|
||||
model = models.Rack
|
||||
fields = (
|
||||
('asset_tag', 50),
|
||||
('serial', 60),
|
||||
('name', 100),
|
||||
('facility_id', 200),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class RackReservationIndex(SearchIndex):
|
||||
model = models.RackReservation
|
||||
fields = (
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class RackRoleIndex(SearchIndex):
|
||||
model = models.RackRole
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class RearPortIndex(SearchIndex):
|
||||
model = models.RearPort
|
||||
fields = (
|
||||
('name', 100),
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class RegionIndex(SearchIndex):
|
||||
model = models.Region
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500)
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class SiteIndex(SearchIndex):
|
||||
model = models.Site
|
||||
fields = (
|
||||
('name', 100),
|
||||
('facility', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('physical_address', 2000),
|
||||
('shipping_address', 2000),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class SiteGroupIndex(SearchIndex):
|
||||
model = models.SiteGroup
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500)
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class VirtualChassisIndex(SearchIndex):
|
||||
model = models.VirtualChassis
|
||||
fields = (
|
||||
('name', 100),
|
||||
('domain', 300)
|
||||
)
|
||||
|
@ -1,12 +1,26 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
|
||||
InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
|
||||
ConsolePort,
|
||||
ConsoleServerPort,
|
||||
Device,
|
||||
DeviceBay,
|
||||
DeviceRole,
|
||||
FrontPort,
|
||||
Interface,
|
||||
InventoryItem,
|
||||
InventoryItemRole,
|
||||
ModuleBay,
|
||||
Platform,
|
||||
PowerOutlet,
|
||||
PowerPort,
|
||||
RearPort,
|
||||
VirtualChassis,
|
||||
)
|
||||
from django_tables2.utils import Accessor
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
from .template_code import *
|
||||
|
||||
__all__ = (
|
||||
@ -137,7 +151,7 @@ class PlatformTable(NetBoxTable):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
order_by=('_name',),
|
||||
template_code=DEVICE_LINK
|
||||
@ -201,9 +215,6 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
|
||||
verbose_name='VC Priority'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:device_list'
|
||||
)
|
||||
|
@ -1,10 +1,21 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
|
||||
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||
ConsolePortTemplate,
|
||||
ConsoleServerPortTemplate,
|
||||
DeviceBayTemplate,
|
||||
DeviceType,
|
||||
FrontPortTemplate,
|
||||
InterfaceTemplate,
|
||||
InventoryItemTemplate,
|
||||
Manufacturer,
|
||||
ModuleBayTemplate,
|
||||
PowerOutletTemplate,
|
||||
PowerPortTemplate,
|
||||
RearPortTemplate,
|
||||
)
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin
|
||||
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
|
||||
|
||||
__all__ = (
|
||||
@ -27,7 +38,7 @@ __all__ = (
|
||||
# Manufacturers
|
||||
#
|
||||
|
||||
class ManufacturerTable(NetBoxTable):
|
||||
class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@ -43,9 +54,6 @@ class ManufacturerTable(NetBoxTable):
|
||||
verbose_name='Platforms'
|
||||
)
|
||||
slug = tables.Column()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:manufacturer_list'
|
||||
)
|
||||
|
@ -1,7 +1,9 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import PowerFeed, PowerPanel
|
||||
from tenancy.tables import ContactsColumnMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
|
||||
from .devices import CableTerminationTable
|
||||
|
||||
__all__ = (
|
||||
@ -14,7 +16,7 @@ __all__ = (
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelTable(NetBoxTable):
|
||||
class PowerPanelTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@ -29,9 +31,6 @@ class PowerPanelTable(NetBoxTable):
|
||||
url_params={'power_panel_id': 'pk'},
|
||||
verbose_name='Feeds'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:powerpanel_list'
|
||||
)
|
||||
|
@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Rack, RackReservation, RackRole
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
from .template_code import DEVICE_WEIGHT
|
||||
|
||||
__all__ = (
|
||||
@ -38,7 +38,7 @@ class RackRoleTable(NetBoxTable):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
order_by=('_name',),
|
||||
linkify=True
|
||||
@ -69,9 +69,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name='Power'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:rack_list'
|
||||
)
|
||||
|
@ -1,8 +1,9 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from dcim.models import Location, Region, Site, SiteGroup
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
from .template_code import LOCATION_BUTTONS
|
||||
|
||||
__all__ = (
|
||||
@ -17,7 +18,7 @@ __all__ = (
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionTable(NetBoxTable):
|
||||
class RegionTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = columns.MPTTColumn(
|
||||
linkify=True
|
||||
)
|
||||
@ -26,9 +27,6 @@ class RegionTable(NetBoxTable):
|
||||
url_params={'region_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:region_list'
|
||||
)
|
||||
@ -46,7 +44,7 @@ class RegionTable(NetBoxTable):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
class SiteGroupTable(NetBoxTable):
|
||||
class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
||||
name = columns.MPTTColumn(
|
||||
linkify=True
|
||||
)
|
||||
@ -55,9 +53,6 @@ class SiteGroupTable(NetBoxTable):
|
||||
url_params={'group_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:sitegroup_list'
|
||||
)
|
||||
@ -75,7 +70,7 @@ class SiteGroupTable(NetBoxTable):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@ -97,9 +92,6 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
||||
verbose_name='ASN Count'
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:site_list'
|
||||
)
|
||||
@ -118,7 +110,7 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = columns.MPTTColumn(
|
||||
linkify=True
|
||||
)
|
||||
@ -136,9 +128,6 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
url_params={'location_id': 'pk'},
|
||||
verbose_name='Devices'
|
||||
)
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:location_list'
|
||||
)
|
||||
|
@ -131,24 +131,3 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
||||
})
|
||||
|
||||
return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
|
||||
|
||||
|
||||
#
|
||||
# Reports & scripts
|
||||
#
|
||||
|
||||
@admin.register(JobResult)
|
||||
class JobResultAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'obj_type', 'name', 'created', 'completed', 'user', 'status',
|
||||
]
|
||||
fields = [
|
||||
'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id'
|
||||
]
|
||||
list_filter = [
|
||||
'status',
|
||||
]
|
||||
readonly_fields = fields
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
@ -38,6 +38,7 @@ __all__ = (
|
||||
'ObjectChangeSerializer',
|
||||
'ReportDetailSerializer',
|
||||
'ReportSerializer',
|
||||
'ReportInputSerializer',
|
||||
'ScriptDetailSerializer',
|
||||
'ScriptInputSerializer',
|
||||
'ScriptLogMessageSerializer',
|
||||
@ -91,8 +92,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
||||
'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum',
|
||||
'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
||||
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'default', 'weight',
|
||||
'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def get_data_type(self, obj):
|
||||
@ -362,7 +363,7 @@ class JobResultSerializer(BaseModelSerializer):
|
||||
class Meta:
|
||||
model = JobResult
|
||||
fields = [
|
||||
'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
|
||||
'id', 'url', 'display', 'created', 'completed', 'scheduled_time', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
|
||||
]
|
||||
|
||||
|
||||
@ -388,6 +389,10 @@ class ReportDetailSerializer(ReportSerializer):
|
||||
result = JobResultSerializer()
|
||||
|
||||
|
||||
class ReportInputSerializer(serializers.Serializer):
|
||||
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
@ -419,6 +424,7 @@ class ScriptDetailSerializer(ScriptSerializer):
|
||||
class ScriptInputSerializer(serializers.Serializer):
|
||||
data = serializers.JSONField()
|
||||
commit = serializers.BooleanField()
|
||||
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class ScriptLogMessageSerializer(serializers.Serializer):
|
||||
|
@ -231,19 +231,26 @@ class ReportViewSet(ViewSet):
|
||||
|
||||
# Retrieve and run the Report. This will create a new JobResult.
|
||||
report = self._retrieve_report(pk)
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
request.user,
|
||||
job_timeout=report.job_timeout
|
||||
)
|
||||
report.result = job_result
|
||||
input_serializer = serializers.ReportInputSerializer(data=request.data)
|
||||
|
||||
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
|
||||
if input_serializer.is_valid():
|
||||
schedule_at = input_serializer.validated_data.get('schedule_at')
|
||||
|
||||
return Response(serializer.data)
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
request.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
)
|
||||
report.result = job_result
|
||||
|
||||
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
#
|
||||
@ -312,6 +319,7 @@ class ScriptViewSet(ViewSet):
|
||||
if input_serializer.is_valid():
|
||||
data = input_serializer.data['data']
|
||||
commit = input_serializer.data['commit']
|
||||
schedule_at = input_serializer.validated_data.get('schedule_at')
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
job_result = JobResult.enqueue_job(
|
||||
@ -323,6 +331,7 @@ class ScriptViewSet(ViewSet):
|
||||
request=copy_safe_request(request),
|
||||
commit=commit,
|
||||
job_timeout=script.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
)
|
||||
script.result = job_result
|
||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||
|
@ -141,6 +141,7 @@ class LogLevelChoices(ChoiceSet):
|
||||
class JobResultStatusChoices(ChoiceSet):
|
||||
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_SCHEDULED = 'scheduled'
|
||||
STATUS_RUNNING = 'running'
|
||||
STATUS_COMPLETED = 'completed'
|
||||
STATUS_ERRORED = 'errored'
|
||||
@ -148,6 +149,7 @@ class JobResultStatusChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(STATUS_PENDING, 'Pending'),
|
||||
(STATUS_SCHEDULED, 'Scheduled'),
|
||||
(STATUS_RUNNING, 'Running'),
|
||||
(STATUS_COMPLETED, 'Completed'),
|
||||
(STATUS_ERRORED, 'Errored'),
|
||||
|
@ -16,6 +16,7 @@ __all__ = (
|
||||
'ConfigContextFilterSet',
|
||||
'ContentTypeFilterSet',
|
||||
'CustomFieldFilterSet',
|
||||
'JobResultFilterSet',
|
||||
'CustomLinkFilterSet',
|
||||
'ExportTemplateFilterSet',
|
||||
'ImageAttachmentFilterSet',
|
||||
@ -72,8 +73,8 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight',
|
||||
'description',
|
||||
'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visibility',
|
||||
'weight', 'description',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@ -435,7 +436,32 @@ class JobResultFilterSet(BaseFilterSet):
|
||||
label='Search',
|
||||
)
|
||||
created = django_filters.DateTimeFilter()
|
||||
created__before = django_filters.DateTimeFilter(
|
||||
field_name='created',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
created__after = django_filters.DateTimeFilter(
|
||||
field_name='created',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
completed = django_filters.DateTimeFilter()
|
||||
completed__before = django_filters.DateTimeFilter(
|
||||
field_name='completed',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
completed__after = django_filters.DateTimeFilter(
|
||||
field_name='completed',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
scheduled_time = django_filters.DateTimeFilter()
|
||||
scheduled_time__before = django_filters.DateTimeFilter(
|
||||
field_name='scheduled_time',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
scheduled_time__after = django_filters.DateTimeFilter(
|
||||
field_name='scheduled_time',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=JobResultStatusChoices,
|
||||
null_value=None
|
||||
@ -444,14 +470,15 @@ class JobResultFilterSet(BaseFilterSet):
|
||||
class Meta:
|
||||
model = JobResult
|
||||
fields = [
|
||||
'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name'
|
||||
'id', 'created', 'completed', 'scheduled_time', 'status', 'user', 'obj_type', 'name'
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(user__username__icontains=value)
|
||||
Q(user__username__icontains=value) |
|
||||
Q(name__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
|
@ -46,8 +46,8 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = (
|
||||
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
|
||||
'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
||||
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
|
||||
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
||||
'validation_regex', 'ui_visibility',
|
||||
)
|
||||
|
||||
|
@ -19,6 +19,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
__all__ = (
|
||||
'ConfigContextFilterForm',
|
||||
'CustomFieldFilterForm',
|
||||
'JobResultFilterForm',
|
||||
'CustomLinkFilterForm',
|
||||
'ExportTemplateFilterForm',
|
||||
'JournalEntryFilterForm',
|
||||
@ -65,6 +66,58 @@ class CustomFieldFilterForm(FilterForm):
|
||||
)
|
||||
|
||||
|
||||
class JobResultFilterForm(FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q',)),
|
||||
('Attributes', ('obj_type', 'status')),
|
||||
('Creation', ('created__before', 'created__after', 'completed__before', 'completed__after',
|
||||
'scheduled_time__before', 'scheduled_time__after', 'user')),
|
||||
)
|
||||
|
||||
obj_type = ContentTypeChoiceField(
|
||||
label=_('Object Type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
|
||||
required=False,
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=JobResultStatusChoices,
|
||||
required=False
|
||||
)
|
||||
created__after = forms.DateTimeField(
|
||||
required=False,
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
created__before = forms.DateTimeField(
|
||||
required=False,
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
completed__after = forms.DateTimeField(
|
||||
required=False,
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
completed__before = forms.DateTimeField(
|
||||
required=False,
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
scheduled_time__after = forms.DateTimeField(
|
||||
required=False,
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
scheduled_time__before = forms.DateTimeField(
|
||||
required=False,
|
||||
widget=DateTimePicker()
|
||||
)
|
||||
user = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
label=_('User'),
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CustomLinkFilterForm(FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q',)),
|
||||
|
@ -41,9 +41,9 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Custom Field', (
|
||||
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description',
|
||||
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
|
||||
)),
|
||||
('Behavior', ('filter_logic', 'ui_visibility')),
|
||||
('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight')),
|
||||
('Values', ('default', 'choices')),
|
||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
||||
)
|
||||
|
16
netbox/extras/forms/reports.py
Normal file
16
netbox/extras/forms/reports.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django import forms
|
||||
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||
|
||||
__all__ = (
|
||||
'ReportForm',
|
||||
)
|
||||
|
||||
|
||||
class ReportForm(BootstrapMixin, forms.Form):
|
||||
schedule_at = forms.DateTimeField(
|
||||
required=False,
|
||||
widget=DateTimePicker(),
|
||||
label="Schedule at",
|
||||
help_text="Schedule execution of report to a set time",
|
||||
)
|
@ -1,6 +1,6 @@
|
||||
from django import forms
|
||||
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||
|
||||
__all__ = (
|
||||
'ScriptForm',
|
||||
@ -14,17 +14,25 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
label="Commit changes",
|
||||
help_text="Commit changes to the database (uncheck for a dry-run)"
|
||||
)
|
||||
_schedule_at = forms.DateTimeField(
|
||||
required=False,
|
||||
widget=DateTimePicker(),
|
||||
label="Schedule at",
|
||||
help_text="Schedule execution of script to a set time",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Move _commit to the end of the form
|
||||
# Move _commit and _schedule_at to the end of the form
|
||||
schedule_at = self.fields.pop('_schedule_at')
|
||||
commit = self.fields.pop('_commit')
|
||||
self.fields['_schedule_at'] = schedule_at
|
||||
self.fields['_commit'] = commit
|
||||
|
||||
@property
|
||||
def requires_input(self):
|
||||
"""
|
||||
A boolean indicating whether the form requires user input (ignore the _commit field).
|
||||
A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields).
|
||||
"""
|
||||
return bool(len(self.fields) > 1)
|
||||
return bool(len(self.fields) > 2)
|
||||
|
@ -81,7 +81,7 @@ class Command(BaseCommand):
|
||||
ending=""
|
||||
)
|
||||
self.stdout.flush()
|
||||
JobResult.objects.filter(created__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
||||
JobResult.objects.filter(created__lt=cutoff).delete()
|
||||
if options['verbosity']:
|
||||
self.stdout.write("Done.", self.style.SUCCESS)
|
||||
elif options['verbosity']:
|
||||
|
77
netbox/extras/management/commands/reindex.py
Normal file
77
netbox/extras/management/commands/reindex.py
Normal file
@ -0,0 +1,77 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from extras.registry import registry
|
||||
from netbox.search.backends import search_backend
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reindex objects for search'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'args',
|
||||
metavar='app_label[.ModelName]',
|
||||
nargs='*',
|
||||
help='One or more apps or models to reindex',
|
||||
)
|
||||
|
||||
def _get_indexers(self, *model_names):
|
||||
indexers = {}
|
||||
|
||||
# No models specified; pull in all registered indexers
|
||||
if not model_names:
|
||||
for idx in registry['search'].values():
|
||||
indexers[idx.model] = idx
|
||||
|
||||
# Return only indexers for the specified models
|
||||
else:
|
||||
for label in model_names:
|
||||
try:
|
||||
app_label, model_name = label.lower().split('.')
|
||||
except ValueError:
|
||||
raise CommandError(
|
||||
f"Invalid model: {label}. Model names must be in the format <app_label>.<model_name>."
|
||||
)
|
||||
try:
|
||||
idx = registry['search'][f'{app_label}.{model_name}']
|
||||
indexers[idx.model] = idx
|
||||
except KeyError:
|
||||
raise CommandError(f"No indexer registered for {label}")
|
||||
|
||||
return indexers
|
||||
|
||||
def handle(self, *model_labels, **kwargs):
|
||||
|
||||
# Determine which models to reindex
|
||||
indexers = self._get_indexers(*model_labels)
|
||||
if not indexers:
|
||||
raise CommandError("No indexers found!")
|
||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||
|
||||
# Clear all cached values for the specified models
|
||||
self.stdout.write('Clearing cached values... ', ending='')
|
||||
self.stdout.flush()
|
||||
content_types = [
|
||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
||||
]
|
||||
deleted_count = search_backend.clear(content_types)
|
||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||
|
||||
# Index models
|
||||
self.stdout.write('Indexing models')
|
||||
for model, idx in indexers.items():
|
||||
app_label = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
self.stdout.write(f' {app_label}.{model_name}... ', ending='')
|
||||
self.stdout.flush()
|
||||
i = search_backend.cache(model.objects.iterator(), remove_existing=False)
|
||||
if i:
|
||||
self.stdout.write(f'{i} entries cached.')
|
||||
else:
|
||||
self.stdout.write(f'None found.')
|
||||
|
||||
msg = f'Completed.'
|
||||
if total_count := search_backend.size:
|
||||
msg += f' Total entries: {total_count}'
|
||||
self.stdout.write(msg, self.style.SUCCESS)
|
@ -14,6 +14,8 @@ class Command(_Command):
|
||||
of only the 'default' queue).
|
||||
"""
|
||||
def handle(self, *args, **options):
|
||||
# Run the worker with scheduler functionality
|
||||
options['with_scheduler'] = True
|
||||
|
||||
# If no queues have been specified on the command line, listen on all configured queues.
|
||||
if len(args) < 1:
|
||||
|
20
netbox/extras/migrations/0079_jobresult_scheduled_time.py
Normal file
20
netbox/extras/migrations/0079_jobresult_scheduled_time.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0078_unique_constraints'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='jobresult',
|
||||
name='scheduled_time',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='jobresult',
|
||||
options={'ordering': ['-created']},
|
||||
),
|
||||
]
|
35
netbox/extras/migrations/0080_search.py
Normal file
35
netbox/extras/migrations/0080_search.py
Normal file
@ -0,0 +1,35 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('extras', '0079_jobresult_scheduled_time'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='search_weight',
|
||||
field=models.PositiveSmallIntegerField(default=1000),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CachedValue',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('object_id', models.PositiveBigIntegerField()),
|
||||
('field', models.CharField(max_length=200)),
|
||||
('type', models.CharField(max_length=30)),
|
||||
('value', models.TextField(db_index=True)),
|
||||
('weight', models.PositiveSmallIntegerField(default=1000)),
|
||||
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('weight', 'object_type', 'object_id'),
|
||||
},
|
||||
),
|
||||
]
|
@ -2,9 +2,11 @@ from .change_logging import ObjectChange
|
||||
from .configcontexts import ConfigContext, ConfigContextModel
|
||||
from .customfields import CustomField
|
||||
from .models import *
|
||||
from .search import *
|
||||
from .tags import Tag, TaggedItem
|
||||
|
||||
__all__ = (
|
||||
'CachedValue',
|
||||
'ConfigContext',
|
||||
'ConfigContextModel',
|
||||
'ConfigRevision',
|
||||
|
@ -16,6 +16,7 @@ from extras.choices import *
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
||||
from netbox.search import FieldTypes
|
||||
from utilities import filters
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
@ -30,6 +31,15 @@ __all__ = (
|
||||
'CustomFieldManager',
|
||||
)
|
||||
|
||||
SEARCH_TYPES = {
|
||||
CustomFieldTypeChoices.TYPE_TEXT: FieldTypes.STRING,
|
||||
CustomFieldTypeChoices.TYPE_LONGTEXT: FieldTypes.STRING,
|
||||
CustomFieldTypeChoices.TYPE_INTEGER: FieldTypes.INTEGER,
|
||||
CustomFieldTypeChoices.TYPE_DECIMAL: FieldTypes.FLOAT,
|
||||
CustomFieldTypeChoices.TYPE_DATE: FieldTypes.STRING,
|
||||
CustomFieldTypeChoices.TYPE_URL: FieldTypes.STRING,
|
||||
}
|
||||
|
||||
|
||||
class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
use_in_migrations = True
|
||||
@ -94,6 +104,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
help_text='If true, this field is required when creating new objects '
|
||||
'or editing an existing object.'
|
||||
)
|
||||
search_weight = models.PositiveSmallIntegerField(
|
||||
default=1000,
|
||||
help_text='Weighting for search. Lower values are considered more important. '
|
||||
'Fields with a search weight of zero will be ignored.'
|
||||
)
|
||||
filter_logic = models.CharField(
|
||||
max_length=50,
|
||||
choices=CustomFieldFilterLogicChoices,
|
||||
@ -109,6 +124,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=100,
|
||||
verbose_name='Display weight',
|
||||
help_text='Fields with higher weights appear lower in a form.'
|
||||
)
|
||||
validation_minimum = models.IntegerField(
|
||||
@ -148,8 +164,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
objects = CustomFieldManager()
|
||||
|
||||
clone_fields = (
|
||||
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'filter_logic', 'default',
|
||||
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'ui_visibility',
|
||||
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
|
||||
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
|
||||
'ui_visibility',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -167,6 +184,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
||||
# Cache instance's original name so we can check later whether it has changed
|
||||
self._name = self.name
|
||||
|
||||
@property
|
||||
def search_type(self):
|
||||
return SEARCH_TYPES.get(self.type)
|
||||
|
||||
def populate_initial_data(self, content_types):
|
||||
"""
|
||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||
|
@ -505,6 +505,10 @@ class JobResult(models.Model):
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
scheduled_time = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=User,
|
||||
on_delete=models.SET_NULL,
|
||||
@ -525,12 +529,26 @@ class JobResult(models.Model):
|
||||
unique=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['obj_type', 'name', '-created']
|
||||
ordering = ['-created']
|
||||
|
||||
def __str__(self):
|
||||
return str(self.job_id)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
queue = django_rq.get_queue("default")
|
||||
job = queue.fetch_job(str(self.job_id))
|
||||
|
||||
if job:
|
||||
job.cancel()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
if not self.completed:
|
||||
@ -551,7 +569,7 @@ class JobResult(models.Model):
|
||||
self.completed = timezone.now()
|
||||
|
||||
@classmethod
|
||||
def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
|
||||
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, *args, **kwargs):
|
||||
"""
|
||||
Create a JobResult instance and enqueue a job using the given callable
|
||||
|
||||
@ -559,10 +577,11 @@ class JobResult(models.Model):
|
||||
name: Name for the JobResult instance
|
||||
obj_type: ContentType to link to the JobResult instance obj_type
|
||||
user: User object to link to the JobResult instance
|
||||
schedule_at: Schedule the job to be executed at the passed date and time
|
||||
args: additional args passed to the callable
|
||||
kwargs: additional kargs passed to the callable
|
||||
"""
|
||||
job_result = cls.objects.create(
|
||||
job_result: JobResult = cls.objects.create(
|
||||
name=name,
|
||||
obj_type=obj_type,
|
||||
user=user,
|
||||
@ -570,7 +589,15 @@ class JobResult(models.Model):
|
||||
)
|
||||
|
||||
queue = django_rq.get_queue("default")
|
||||
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||
|
||||
if schedule_at:
|
||||
job_result.status = JobResultStatusChoices.STATUS_SCHEDULED
|
||||
job_result.scheduled_time = schedule_at
|
||||
job_result.save()
|
||||
|
||||
queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||
else:
|
||||
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||
|
||||
return job_result
|
||||
|
||||
|
50
netbox/extras/models/search.py
Normal file
50
netbox/extras/models/search.py
Normal file
@ -0,0 +1,50 @@
|
||||
import uuid
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
|
||||
from utilities.fields import RestrictedGenericForeignKey
|
||||
|
||||
__all__ = (
|
||||
'CachedValue',
|
||||
)
|
||||
|
||||
|
||||
class CachedValue(models.Model):
|
||||
id = models.UUIDField(
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False
|
||||
)
|
||||
timestamp = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
editable=False
|
||||
)
|
||||
object_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+'
|
||||
)
|
||||
object_id = models.PositiveBigIntegerField()
|
||||
object = RestrictedGenericForeignKey(
|
||||
ct_field='object_type',
|
||||
fk_field='object_id'
|
||||
)
|
||||
field = models.CharField(
|
||||
max_length=200
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=30
|
||||
)
|
||||
value = models.TextField(
|
||||
db_index=True
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=1000
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('weight', 'object_type', 'object_id')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
@ -75,7 +75,7 @@ class PluginConfig(AppConfig):
|
||||
try:
|
||||
search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
|
||||
for idx in search_indexes:
|
||||
register_search()(idx)
|
||||
register_search(idx)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
@ -29,5 +29,5 @@ registry['model_features'] = {
|
||||
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
|
||||
}
|
||||
registry['denormalized_fields'] = collections.defaultdict(list)
|
||||
registry['search'] = collections.defaultdict(dict)
|
||||
registry['search'] = dict()
|
||||
registry['views'] = collections.defaultdict(dict)
|
||||
|
@ -85,7 +85,6 @@ def run_report(job_result, *args, **kwargs):
|
||||
try:
|
||||
report.run(job_result)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
job_result.save()
|
||||
logging.error(f"Error during execution of report {job_result.name}")
|
||||
|
@ -1,14 +1,11 @@
|
||||
import extras.filtersets
|
||||
import extras.tables
|
||||
from extras.models import JournalEntry
|
||||
from netbox.search import SearchIndex, register_search
|
||||
from . import models
|
||||
|
||||
|
||||
@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'
|
||||
model = models.JournalEntry
|
||||
fields = (
|
||||
('comments', 5000),
|
||||
)
|
||||
category = 'Journal'
|
||||
|
@ -8,6 +8,7 @@ from .template_code import *
|
||||
__all__ = (
|
||||
'ConfigContextTable',
|
||||
'CustomFieldTable',
|
||||
'JobResultTable',
|
||||
'CustomLinkTable',
|
||||
'ExportTemplateTable',
|
||||
'JournalEntryTable',
|
||||
@ -33,12 +34,33 @@ class CustomFieldTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default',
|
||||
'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
|
||||
'search_weight', 'filter_logic', 'ui_visibility', 'weight', 'choices', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class JobResultTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',)
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = JobResult
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'obj_type', 'job_id', 'created', 'completed', 'scheduled_time', 'user', 'status',
|
||||
)
|
||||
default_columns = ('pk', 'id', 'name', 'obj_type', 'status', 'created', 'completed', 'user',)
|
||||
|
||||
|
||||
#
|
||||
# Custom links
|
||||
#
|
||||
|
@ -4,8 +4,9 @@ from .models import DummyModel
|
||||
|
||||
class DummyModelIndex(SearchIndex):
|
||||
model = DummyModel
|
||||
queryset = DummyModel.objects.all()
|
||||
url = 'plugins:dummy_plugin:dummy_models'
|
||||
fields = (
|
||||
('name', 100),
|
||||
)
|
||||
|
||||
|
||||
indexes = (
|
||||
|
@ -292,6 +292,7 @@ class CustomFieldTest(TestCase):
|
||||
cf = CustomField.objects.create(
|
||||
name='object_field',
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
object_type=ContentType.objects.get_for_model(VLAN),
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
@ -323,6 +324,7 @@ class CustomFieldTest(TestCase):
|
||||
cf = CustomField.objects.create(
|
||||
name='object_field',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
object_type=ContentType.objects.get_for_model(VLAN),
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
|
@ -32,6 +32,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'label': 'Field X',
|
||||
'type': 'text',
|
||||
'content_types': [site_ct.pk],
|
||||
'search_weight': 2000,
|
||||
'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
|
||||
'default': None,
|
||||
'weight': 200,
|
||||
@ -40,11 +41,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
|
||||
'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write',
|
||||
'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write',
|
||||
'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write',
|
||||
'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write',
|
||||
'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
|
||||
'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write',
|
||||
'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write',
|
||||
'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write',
|
||||
'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write',
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
@ -74,6 +74,11 @@ urlpatterns = [
|
||||
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
||||
re_path(r'^reports/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ReportView.as_view(), name='report'),
|
||||
|
||||
# Job results
|
||||
path('job-results/', views.JobResultListView.as_view(), name='jobresult_list'),
|
||||
path('job-results/delete/', views.JobResultBulkDeleteView.as_view(), name='jobresult_bulk_delete'),
|
||||
path('job-results/<int:pk>/delete/', views.JobResultDeleteView.as_view(), name='jobresult_delete'),
|
||||
|
||||
# Scripts
|
||||
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||
|
@ -15,6 +15,7 @@ from utilities.utils import copy_safe_request, count_related, get_viewname, norm
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import JobResultStatusChoices
|
||||
from .forms.reports import ReportForm
|
||||
from .models import *
|
||||
from .reports import get_report, get_reports, run_report
|
||||
from .scripts import get_scripts, run_script
|
||||
@ -592,7 +593,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'report': report,
|
||||
'run_form': ConfirmationForm(),
|
||||
'form': ReportForm(),
|
||||
})
|
||||
|
||||
def post(self, request, module, name):
|
||||
@ -605,24 +606,36 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
if report is None:
|
||||
raise Http404
|
||||
|
||||
# Allow execution only if RQ worker process is running
|
||||
if not Worker.count(get_connection('default')):
|
||||
messages.error(request, "Unable to run report: RQ worker process not running.")
|
||||
return render(request, 'extras/report.html', {
|
||||
'report': report,
|
||||
})
|
||||
schedule_at = None
|
||||
form = ReportForm(request.POST)
|
||||
|
||||
# Run the Report. A new JobResult is created.
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
request.user,
|
||||
job_timeout=report.job_timeout
|
||||
)
|
||||
if form.is_valid():
|
||||
schedule_at = form.cleaned_data.get("schedule_at")
|
||||
|
||||
return redirect('extras:report_result', job_result_pk=job_result.pk)
|
||||
# Allow execution only if RQ worker process is running
|
||||
if not Worker.count(get_connection('default')):
|
||||
messages.error(request, "Unable to run report: RQ worker process not running.")
|
||||
return render(request, 'extras/report.html', {
|
||||
'report': report,
|
||||
})
|
||||
|
||||
# Run the Report. A new JobResult is created.
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
request.user,
|
||||
job_timeout=report.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
)
|
||||
|
||||
return redirect('extras:report_result', job_result_pk=job_result.pk)
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'report': report,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
|
||||
class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
||||
@ -737,6 +750,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
||||
|
||||
elif form.is_valid():
|
||||
commit = form.cleaned_data.pop('_commit')
|
||||
schedule_at = form.cleaned_data.pop("_schedule_at")
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
|
||||
@ -749,6 +763,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
||||
request=copy_safe_request(request),
|
||||
commit=commit,
|
||||
job_timeout=script.job_timeout,
|
||||
schedule_at=schedule_at,
|
||||
)
|
||||
|
||||
return redirect('extras:script_result', job_result_pk=job_result.pk)
|
||||
@ -788,3 +803,25 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View)
|
||||
'result': result,
|
||||
'class_name': script.__class__.__name__
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Job results
|
||||
#
|
||||
|
||||
class JobResultListView(generic.ObjectListView):
|
||||
queryset = JobResult.objects.all()
|
||||
filterset = filtersets.JobResultFilterSet
|
||||
filterset_form = forms.JobResultFilterForm
|
||||
table = tables.JobResultTable
|
||||
actions = ('export', 'delete', 'bulk_delete', )
|
||||
|
||||
|
||||
class JobResultDeleteView(generic.ObjectDeleteView):
|
||||
queryset = JobResult.objects.all()
|
||||
|
||||
|
||||
class JobResultBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = JobResult.objects.all()
|
||||
filterset = filtersets.JobResultFilterSet
|
||||
table = tables.JobResultTable
|
||||
|
@ -88,6 +88,12 @@ class RouteTargetForm(TenancyForm, NetBoxModelForm):
|
||||
class RIRForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('RIR', (
|
||||
'name', 'slug', 'is_private', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = [
|
||||
@ -164,6 +170,12 @@ class ASNForm(TenancyForm, NetBoxModelForm):
|
||||
class RoleForm(NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Role', (
|
||||
'name', 'slug', 'weight', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = [
|
||||
@ -784,6 +796,12 @@ class ServiceTemplateForm(NetBoxModelForm):
|
||||
help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Service Template', (
|
||||
'name', 'protocol', 'ports', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ServiceTemplate
|
||||
fields = ('name', 'protocol', 'ports', 'description', 'tags')
|
||||
|
@ -92,6 +92,8 @@ class Service(ServiceBase, NetBoxModel):
|
||||
verbose_name='IP addresses'
|
||||
)
|
||||
|
||||
clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ]
|
||||
|
||||
class Meta:
|
||||
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
|
||||
|
||||
|
@ -1,69 +1,139 @@
|
||||
import ipam.filtersets
|
||||
import ipam.tables
|
||||
from ipam.models import ASN, VLAN, VRF, Aggregate, IPAddress, Prefix, Service
|
||||
from . import models
|
||||
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()
|
||||
@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'
|
||||
model = models.Aggregate
|
||||
fields = (
|
||||
('prefix', 100),
|
||||
('description', 500),
|
||||
('date_added', 2000),
|
||||
)
|
||||
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()
|
||||
@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'
|
||||
model = models.ASN
|
||||
fields = (
|
||||
('asn', 100),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search()
|
||||
@register_search
|
||||
class FHRPGroupIndex(SearchIndex):
|
||||
model = models.FHRPGroup
|
||||
fields = (
|
||||
('name', 100),
|
||||
('group_id', 2000),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class IPAddressIndex(SearchIndex):
|
||||
model = models.IPAddress
|
||||
fields = (
|
||||
('address', 100),
|
||||
('dns_name', 300),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class IPRangeIndex(SearchIndex):
|
||||
model = models.IPRange
|
||||
fields = (
|
||||
('start_address', 100),
|
||||
('end_address', 300),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class L2VPNIndex(SearchIndex):
|
||||
model = models.L2VPN
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class PrefixIndex(SearchIndex):
|
||||
model = models.Prefix
|
||||
fields = (
|
||||
('prefix', 100),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class RIRIndex(SearchIndex):
|
||||
model = models.RIR
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class RoleIndex(SearchIndex):
|
||||
model = models.Role
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class RouteTargetIndex(SearchIndex):
|
||||
model = models.RouteTarget
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@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'
|
||||
model = models.Service
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class VLANIndex(SearchIndex):
|
||||
model = models.VLAN
|
||||
fields = (
|
||||
('name', 100),
|
||||
('vid', 100),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class VLANGroupIndex(SearchIndex):
|
||||
model = models.VLANGroup
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('max_vid', 2000),
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
class VRFIndex(SearchIndex):
|
||||
model = models.VRF
|
||||
fields = (
|
||||
('name', 100),
|
||||
('rd', 200),
|
||||
('description', 500),
|
||||
)
|
||||
|
@ -351,6 +351,14 @@ class LDAPBackend:
|
||||
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
|
||||
# Optionally set CA cert directory
|
||||
if ca_cert_dir := getattr(ldap_config, 'LDAP_CA_CERT_DIR', None):
|
||||
ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, ca_cert_dir)
|
||||
|
||||
# Optionally set CA cert file
|
||||
if ca_cert_file := getattr(ldap_config, 'LDAP_CA_CERT_FILE', None):
|
||||
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_cert_file)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
|
@ -1,5 +1,2 @@
|
||||
# Prefix for nested serializers
|
||||
NESTED_SERIALIZER_PREFIX = 'Nested'
|
||||
|
||||
# Max results per object type
|
||||
SEARCH_MAX_RESULTS = 15
|
||||
|
@ -1,38 +1,45 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.search.backends import default_search_engine
|
||||
from utilities.forms import BootstrapMixin
|
||||
from netbox.search import LookupTypes
|
||||
from netbox.search.backends import search_backend
|
||||
from utilities.forms import BootstrapMixin, StaticSelect, StaticSelectMultiple
|
||||
|
||||
from .base import *
|
||||
|
||||
|
||||
def build_options(choices):
|
||||
options = [{"label": choices[0][1], "items": []}]
|
||||
|
||||
for label, choices in choices[1:]:
|
||||
items = []
|
||||
|
||||
for value, choice_label in choices:
|
||||
items.append({"label": choice_label, "value": value})
|
||||
|
||||
options.append({"label": label, "items": items})
|
||||
return options
|
||||
LOOKUP_CHOICES = (
|
||||
('', _('Partial match')),
|
||||
(LookupTypes.EXACT, _('Exact match')),
|
||||
(LookupTypes.STARTSWITH, _('Starts with')),
|
||||
(LookupTypes.ENDSWITH, _('Ends with')),
|
||||
)
|
||||
|
||||
|
||||
class SearchForm(BootstrapMixin, forms.Form):
|
||||
q = forms.CharField(label='Search')
|
||||
options = None
|
||||
q = forms.CharField(
|
||||
label='Search',
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
'hx-get': '',
|
||||
'hx-target': '#object_list',
|
||||
'hx-trigger': 'keyup[target.value.length >= 3] changed delay:500ms',
|
||||
}
|
||||
)
|
||||
)
|
||||
obj_types = forms.MultipleChoiceField(
|
||||
choices=[],
|
||||
required=False,
|
||||
label='Object type(s)',
|
||||
widget=StaticSelectMultiple()
|
||||
)
|
||||
lookup = forms.ChoiceField(
|
||||
choices=LOOKUP_CHOICES,
|
||||
initial=LookupTypes.PARTIAL,
|
||||
required=False,
|
||||
widget=StaticSelect()
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["obj_type"] = forms.ChoiceField(
|
||||
choices=default_search_engine.get_search_choices(),
|
||||
required=False,
|
||||
label='Type'
|
||||
)
|
||||
|
||||
def get_options(self):
|
||||
if not self.options:
|
||||
self.options = build_options(default_search_engine.get_search_choices())
|
||||
|
||||
return self.options
|
||||
self.fields['obj_types'].choices = search_backend.get_object_types()
|
||||
|
@ -294,6 +294,11 @@ OTHER_MENU = Menu(
|
||||
link_text='Scripts',
|
||||
permissions=['extras.view_script']
|
||||
),
|
||||
MenuItem(
|
||||
link='extras:jobresult_list',
|
||||
link_text='Job Results',
|
||||
permissions=['extras.view_jobresult'],
|
||||
),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
|
@ -1,5 +1,24 @@
|
||||
from collections import namedtuple
|
||||
|
||||
from django.db import models
|
||||
|
||||
from extras.registry import registry
|
||||
|
||||
ObjectFieldValue = namedtuple('ObjectFieldValue', ('name', 'type', 'weight', 'value'))
|
||||
|
||||
|
||||
class FieldTypes:
|
||||
FLOAT = 'float'
|
||||
INTEGER = 'int'
|
||||
STRING = 'str'
|
||||
|
||||
|
||||
class LookupTypes:
|
||||
PARTIAL = 'icontains'
|
||||
EXACT = 'iexact'
|
||||
STARTSWITH = 'istartswith'
|
||||
ENDSWITH = 'iendswith'
|
||||
|
||||
|
||||
class SearchIndex:
|
||||
"""
|
||||
@ -7,27 +26,90 @@ class SearchIndex:
|
||||
|
||||
Attrs:
|
||||
model: The model class for which this index is used.
|
||||
category: The label of the group under which this indexer is categorized (for form field display). If none,
|
||||
the name of the model's app will be used.
|
||||
fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each.
|
||||
"""
|
||||
model = None
|
||||
category = None
|
||||
fields = ()
|
||||
|
||||
@staticmethod
|
||||
def get_field_type(instance, field_name):
|
||||
"""
|
||||
Return the data type of the specified model field.
|
||||
"""
|
||||
field_cls = instance._meta.get_field(field_name).__class__
|
||||
if issubclass(field_cls, (models.FloatField, models.DecimalField)):
|
||||
return FieldTypes.FLOAT
|
||||
if issubclass(field_cls, models.IntegerField):
|
||||
return FieldTypes.INTEGER
|
||||
return FieldTypes.STRING
|
||||
|
||||
@staticmethod
|
||||
def get_field_value(instance, field_name):
|
||||
"""
|
||||
Return the value of the specified model field as a string.
|
||||
"""
|
||||
return str(getattr(instance, field_name))
|
||||
|
||||
@classmethod
|
||||
def get_category(cls):
|
||||
return cls.category or cls.model._meta.app_config.verbose_name
|
||||
|
||||
@classmethod
|
||||
def to_cache(cls, instance, custom_fields=None):
|
||||
"""
|
||||
Return the title of the search category under which this model is registered.
|
||||
Return a list of ObjectFieldValue representing the instance fields to be cached.
|
||||
|
||||
Args:
|
||||
instance: The instance being cached.
|
||||
custom_fields: An iterable of CustomFields to include when caching the instance. If None, all custom fields
|
||||
defined for the model will be included. (This can also be provided during bulk caching to avoid looking
|
||||
up the available custom fields for each instance.)
|
||||
"""
|
||||
if hasattr(cls, 'category'):
|
||||
return cls.category
|
||||
return cls.model._meta.app_config.verbose_name
|
||||
values = []
|
||||
|
||||
# Capture built-in fields
|
||||
for name, weight in cls.fields:
|
||||
type_ = cls.get_field_type(instance, name)
|
||||
value = cls.get_field_value(instance, name)
|
||||
if type_ and value:
|
||||
values.append(
|
||||
ObjectFieldValue(name, type_, weight, value)
|
||||
)
|
||||
|
||||
# Capture custom fields
|
||||
if getattr(instance, 'custom_field_data', None):
|
||||
if custom_fields is None:
|
||||
custom_fields = instance.get_custom_fields().keys()
|
||||
for cf in custom_fields:
|
||||
type_ = cf.search_type
|
||||
value = instance.custom_field_data.get(cf.name)
|
||||
weight = cf.search_weight
|
||||
if type_ and value and weight:
|
||||
values.append(
|
||||
ObjectFieldValue(f'cf_{cf.name}', type_, weight, value)
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def register_search():
|
||||
def _wrapper(cls):
|
||||
model = cls.model
|
||||
app_label = model._meta.app_label
|
||||
model_name = model._meta.model_name
|
||||
def get_indexer(model):
|
||||
"""
|
||||
Get the SearchIndex class for the given model.
|
||||
"""
|
||||
label = f'{model._meta.app_label}.{model._meta.model_name}'
|
||||
|
||||
registry['search'][app_label][model_name] = cls
|
||||
return registry['search'][label]
|
||||
|
||||
return cls
|
||||
|
||||
return _wrapper
|
||||
def register_search(cls):
|
||||
"""
|
||||
Decorator for registering a SearchIndex class.
|
||||
"""
|
||||
model = cls.model
|
||||
label = f'{model._meta.app_label}.{model._meta.model_name}'
|
||||
registry['search'][label] = cls
|
||||
|
||||
return cls
|
||||
|
@ -1,125 +1,236 @@
|
||||
from collections import defaultdict
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.urls import reverse
|
||||
from django.db.models import F, Window
|
||||
from django.db.models.functions import window
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from extras.models import CachedValue, CustomField
|
||||
from extras.registry import registry
|
||||
from netbox.constants import SEARCH_MAX_RESULTS
|
||||
from utilities.querysets import RestrictedPrefetch
|
||||
from utilities.templatetags.builtins.filters import bettertitle
|
||||
from . import FieldTypes, LookupTypes, get_indexer
|
||||
|
||||
# The cache for the initialized backend.
|
||||
_backends_cache = {}
|
||||
|
||||
|
||||
class SearchEngineError(Exception):
|
||||
"""Something went wrong with a search engine."""
|
||||
pass
|
||||
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
|
||||
MAX_RESULTS = 1000
|
||||
|
||||
|
||||
class SearchBackend:
|
||||
"""A search engine capable of performing multi-table searches."""
|
||||
_search_choice_options = tuple()
|
||||
"""
|
||||
Base class for search backends. Subclasses must extend the `cache()`, `remove()`, and `clear()` methods below.
|
||||
"""
|
||||
_object_types = None
|
||||
|
||||
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:
|
||||
def get_object_types(self):
|
||||
"""
|
||||
Return a list of all registered object types, organized by category, suitable for populating a form's
|
||||
ChoiceField.
|
||||
"""
|
||||
if not self._object_types:
|
||||
|
||||
# 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
|
||||
for label, idx in registry['search'].items():
|
||||
title = bettertitle(idx.model._meta.verbose_name)
|
||||
categories[idx.get_category()][label] = title
|
||||
|
||||
# Compile a nested tuple of choices for form rendering
|
||||
results = (
|
||||
('', 'All Objects'),
|
||||
*[(category, choices.items()) for category, choices in categories.items()]
|
||||
*[(category, list(choices.items())) for category, choices in categories.items()]
|
||||
)
|
||||
|
||||
self._search_choice_options = results
|
||||
self._object_types = results
|
||||
|
||||
return self._search_choice_options
|
||||
return self._object_types
|
||||
|
||||
def search(self, request, value, **kwargs):
|
||||
"""Execute a search query for the given value."""
|
||||
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
||||
"""
|
||||
Search cached object representations for the given value.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def cache(self, instance):
|
||||
"""Create or update the cached copy of an instance."""
|
||||
def caching_handler(self, sender, instance, **kwargs):
|
||||
"""
|
||||
Receiver for the post_save signal, responsible for caching object creation/changes.
|
||||
"""
|
||||
self.cache(instance)
|
||||
|
||||
def removal_handler(self, sender, instance, **kwargs):
|
||||
"""
|
||||
Receiver for the post_delete signal, responsible for caching object deletion.
|
||||
"""
|
||||
self.remove(instance)
|
||||
|
||||
def cache(self, instances, indexer=None, remove_existing=True):
|
||||
"""
|
||||
Create or update the cached representation of an instance.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def remove(self, instance):
|
||||
"""
|
||||
Delete any cached representation 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 = []
|
||||
def clear(self, object_types=None):
|
||||
"""
|
||||
Delete *all* cached data.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
search_registry = self.get_registry()
|
||||
for obj_type in search_registry.keys():
|
||||
@property
|
||||
def size(self):
|
||||
"""
|
||||
Return a total number of cached entries. The meaning of this value will be
|
||||
backend-dependent.
|
||||
"""
|
||||
return None
|
||||
|
||||
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')
|
||||
class CachedValueSearchBackend(SearchBackend):
|
||||
|
||||
filterset = getattr(search_registry[obj_type], 'filterset', None)
|
||||
if not filterset:
|
||||
# This backend requires a FilterSet class for the model
|
||||
continue
|
||||
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
||||
|
||||
table = getattr(search_registry[obj_type], 'table', None)
|
||||
if not table:
|
||||
# This backend requires a Table class for the model
|
||||
continue
|
||||
# Define the search parameters
|
||||
params = {
|
||||
f'value__{lookup}': value
|
||||
}
|
||||
if lookup != LookupTypes.EXACT:
|
||||
# Partial matches are valid only on string values
|
||||
params['type'] = FieldTypes.STRING
|
||||
if object_types:
|
||||
params['object_type__in'] = object_types
|
||||
|
||||
# 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)
|
||||
# Construct the base queryset to retrieve matching results
|
||||
queryset = CachedValue.objects.filter(**params).annotate(
|
||||
# Annotate the rank of each result for its object according to its weight
|
||||
row_number=Window(
|
||||
expression=window.RowNumber(),
|
||||
partition_by=[F('object_type'), F('object_id')],
|
||||
order_by=[F('weight').asc()],
|
||||
)
|
||||
)[:MAX_RESULTS]
|
||||
|
||||
if table.page:
|
||||
results.append({
|
||||
'name': queryset.model._meta.verbose_name_plural,
|
||||
'table': table,
|
||||
'url': f"{reverse(url)}?q={value}"
|
||||
})
|
||||
# Construct a Prefetch to pre-fetch only those related objects for which the
|
||||
# user has permission to view.
|
||||
if user:
|
||||
prefetch = (RestrictedPrefetch('object', user, 'view'), 'object_type')
|
||||
else:
|
||||
prefetch = ('object', 'object_type')
|
||||
|
||||
return results
|
||||
# Wrap the base query to return only the lowest-weight result for each object
|
||||
# Hat-tip to https://blog.oyam.dev/django-filter-by-window-function/ for the solution
|
||||
sql, params = queryset.query.sql_with_params()
|
||||
results = CachedValue.objects.prefetch_related(*prefetch).raw(
|
||||
f"SELECT * FROM ({sql}) t WHERE row_number = 1",
|
||||
params
|
||||
)
|
||||
|
||||
def cache(self, instance):
|
||||
# This backend does not utilize a cache
|
||||
pass
|
||||
# Omit any results pertaining to an object the user does not have permission to view
|
||||
return [
|
||||
r for r in results if r.object is not None
|
||||
]
|
||||
|
||||
def cache(self, instances, indexer=None, remove_existing=True):
|
||||
content_type = None
|
||||
custom_fields = None
|
||||
|
||||
# Convert a single instance to an iterable
|
||||
if not hasattr(instances, '__iter__'):
|
||||
instances = [instances]
|
||||
|
||||
buffer = []
|
||||
counter = 0
|
||||
for instance in instances:
|
||||
|
||||
# First item
|
||||
if not counter:
|
||||
|
||||
# Determine the indexer
|
||||
if indexer is None:
|
||||
try:
|
||||
indexer = get_indexer(instance)
|
||||
except KeyError:
|
||||
break
|
||||
|
||||
# Prefetch any associated custom fields
|
||||
content_type = ContentType.objects.get_for_model(indexer.model)
|
||||
custom_fields = CustomField.objects.filter(content_types=content_type).exclude(search_weight=0)
|
||||
|
||||
# Wipe out any previously cached values for the object
|
||||
if remove_existing:
|
||||
self.remove(instance)
|
||||
|
||||
# Generate cache data
|
||||
for field in indexer.to_cache(instance, custom_fields=custom_fields):
|
||||
buffer.append(
|
||||
CachedValue(
|
||||
object_type=content_type,
|
||||
object_id=instance.pk,
|
||||
field=field.name,
|
||||
type=field.type,
|
||||
weight=field.weight,
|
||||
value=field.value
|
||||
)
|
||||
)
|
||||
|
||||
# Check whether the buffer needs to be flushed
|
||||
if len(buffer) >= 2000:
|
||||
counter += len(CachedValue.objects.bulk_create(buffer))
|
||||
buffer = []
|
||||
|
||||
# Final buffer flush
|
||||
if buffer:
|
||||
counter += len(CachedValue.objects.bulk_create(buffer))
|
||||
|
||||
return counter
|
||||
|
||||
def remove(self, instance):
|
||||
# Avoid attempting to query for non-cacheable objects
|
||||
try:
|
||||
get_indexer(instance)
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
ct = ContentType.objects.get_for_model(instance)
|
||||
qs = CachedValue.objects.filter(object_type=ct, object_id=instance.pk)
|
||||
|
||||
# Call _raw_delete() on the queryset to avoid first loading instances into memory
|
||||
return qs._raw_delete(using=qs.db)
|
||||
|
||||
def clear(self, object_types=None):
|
||||
qs = CachedValue.objects.all()
|
||||
if object_types:
|
||||
qs = qs.filter(object_type__in=object_types)
|
||||
|
||||
# Call _raw_delete() on the queryset to avoid first loading instances into memory
|
||||
return qs._raw_delete(using=qs.db)
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return CachedValue.objects.count()
|
||||
|
||||
|
||||
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)
|
||||
"""
|
||||
Initializes and returns the configured search backend.
|
||||
"""
|
||||
try:
|
||||
backend_cls = getattr(backend_module, backend_cls_name)
|
||||
backend_cls = import_string(settings.SEARCH_BACKEND)
|
||||
except AttributeError:
|
||||
raise ImproperlyConfigured(f"Could not find a class named {backend_module_name} in {backend_cls_name}")
|
||||
raise ImproperlyConfigured(f"Failed to import configured SEARCH_BACKEND: {settings.SEARCH_BACKEND}")
|
||||
|
||||
# Initialize and return the backend instance
|
||||
return backend_cls()
|
||||
|
||||
|
||||
default_search_engine = get_backend()
|
||||
search = default_search_engine.search
|
||||
search_backend = get_backend()
|
||||
|
||||
# Connect handlers to the appropriate model signals
|
||||
post_save.connect(search_backend.caching_handler)
|
||||
post_delete.connect(search_backend.removal_handler)
|
||||
|
@ -116,7 +116,7 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO
|
||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.FilterSetSearchBackend')
|
||||
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
|
||||
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
|
||||
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
||||
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
|
||||
@ -493,7 +493,7 @@ for param in dir(configuration):
|
||||
|
||||
# Force usage of PostgreSQL's JSONB field for extra data
|
||||
SOCIAL_AUTH_JSONFIELD_ENABLED = True
|
||||
|
||||
SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'netbox.users.utils.clean_username'
|
||||
|
||||
#
|
||||
# Django Prometheus
|
||||
|
@ -4,16 +4,21 @@ from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db.models.fields.related import RelatedField
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django_tables2.data import TableQuerysetData
|
||||
|
||||
from extras.models import CustomField, CustomLink
|
||||
from extras.choices import CustomFieldVisibilityChoices
|
||||
from netbox.tables import columns
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.templatetags.builtins.filters import bettertitle
|
||||
from utilities.utils import highlight_string
|
||||
|
||||
__all__ = (
|
||||
'BaseTable',
|
||||
'NetBoxTable',
|
||||
'SearchTable',
|
||||
)
|
||||
|
||||
|
||||
@ -192,3 +197,39 @@ class NetBoxTable(BaseTable):
|
||||
])
|
||||
|
||||
super().__init__(*args, extra_columns=extra_columns, **kwargs)
|
||||
|
||||
|
||||
class SearchTable(tables.Table):
|
||||
object_type = columns.ContentTypeColumn(
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
object = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
field = tables.Column()
|
||||
value = tables.Column()
|
||||
|
||||
trim_length = 30
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table-hover object-list',
|
||||
}
|
||||
empty_text = _('No results found')
|
||||
|
||||
def __init__(self, data, highlight=None, **kwargs):
|
||||
self.highlight = highlight
|
||||
super().__init__(data, **kwargs)
|
||||
|
||||
def render_field(self, value, record):
|
||||
if hasattr(record.object, value):
|
||||
return bettertitle(record.object._meta.get_field(value).verbose_name)
|
||||
return value
|
||||
|
||||
def render_value(self, value):
|
||||
if not self.highlight:
|
||||
return value
|
||||
|
||||
value = highlight_string(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length)
|
||||
|
||||
return mark_safe(value)
|
||||
|
153
netbox/netbox/tests/test_search.py
Normal file
153
netbox/netbox/tests/test_search.py
Normal file
@ -0,0 +1,153 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Site
|
||||
from dcim.search import SiteIndex
|
||||
from extras.models import CachedValue
|
||||
from netbox.search.backends import search_backend
|
||||
|
||||
|
||||
class SearchBackendTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
# Create sites with a value for each cacheable field defined on SiteIndex
|
||||
sites = (
|
||||
Site(
|
||||
name='Site 1',
|
||||
slug='site-1',
|
||||
facility='Alpha',
|
||||
description='First test site',
|
||||
physical_address='123 Fake St Lincoln NE 68588',
|
||||
shipping_address='123 Fake St Lincoln NE 68588',
|
||||
comments='Lorem ipsum etcetera'
|
||||
),
|
||||
Site(
|
||||
name='Site 2',
|
||||
slug='site-2',
|
||||
facility='Bravo',
|
||||
description='Second test site',
|
||||
physical_address='725 Cyrus Valleys Suite 761 Douglasfort NE 57761',
|
||||
shipping_address='725 Cyrus Valleys Suite 761 Douglasfort NE 57761',
|
||||
comments='Lorem ipsum etcetera'
|
||||
),
|
||||
Site(
|
||||
name='Site 3',
|
||||
slug='site-3',
|
||||
facility='Charlie',
|
||||
description='Third test site',
|
||||
physical_address='2321 Dovie Dale East Cristobal AK 71959',
|
||||
shipping_address='2321 Dovie Dale East Cristobal AK 71959',
|
||||
comments='Lorem ipsum etcetera'
|
||||
),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
def test_cache_single_object(self):
|
||||
"""
|
||||
Test that a single object is cached appropriately
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
search_backend.cache(site)
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
self.assertEqual(
|
||||
CachedValue.objects.filter(object_type=content_type, object_id=site.pk).count(),
|
||||
len(SiteIndex.fields)
|
||||
)
|
||||
for field_name, weight in SiteIndex.fields:
|
||||
self.assertTrue(
|
||||
CachedValue.objects.filter(
|
||||
object_type=content_type,
|
||||
object_id=site.pk,
|
||||
field=field_name,
|
||||
value=getattr(site, field_name),
|
||||
weight=weight
|
||||
),
|
||||
)
|
||||
|
||||
def test_cache_multiple_objects(self):
|
||||
"""
|
||||
Test that multiples objects are cached appropriately
|
||||
"""
|
||||
sites = Site.objects.all()
|
||||
search_backend.cache(sites)
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
self.assertEqual(
|
||||
CachedValue.objects.filter(object_type=content_type).count(),
|
||||
len(SiteIndex.fields) * sites.count()
|
||||
)
|
||||
for site in sites:
|
||||
for field_name, weight in SiteIndex.fields:
|
||||
self.assertTrue(
|
||||
CachedValue.objects.filter(
|
||||
object_type=content_type,
|
||||
object_id=site.pk,
|
||||
field=field_name,
|
||||
value=getattr(site, field_name),
|
||||
weight=weight
|
||||
),
|
||||
)
|
||||
|
||||
def test_cache_on_save(self):
|
||||
"""
|
||||
Test that an object is automatically cached on calling save().
|
||||
"""
|
||||
site = Site(
|
||||
name='Site 4',
|
||||
slug='site-4',
|
||||
facility='Delta',
|
||||
description='Fourth test site',
|
||||
physical_address='7915 Lilla Plains West Ladariusport TX 19429',
|
||||
shipping_address='7915 Lilla Plains West Ladariusport TX 19429',
|
||||
comments='Lorem ipsum etcetera'
|
||||
)
|
||||
site.save()
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
self.assertEqual(
|
||||
CachedValue.objects.filter(object_type=content_type, object_id=site.pk).count(),
|
||||
len(SiteIndex.fields)
|
||||
)
|
||||
|
||||
def test_remove_on_delete(self):
|
||||
"""
|
||||
Test that any cached value for an object are automatically removed on delete().
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
site.delete()
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
self.assertFalse(
|
||||
CachedValue.objects.filter(object_type=content_type, object_id=site.pk).exists()
|
||||
)
|
||||
|
||||
def test_clear_all(self):
|
||||
"""
|
||||
Test that calling clear() on the backend removes all cached entries.
|
||||
"""
|
||||
sites = Site.objects.all()
|
||||
search_backend.cache(sites)
|
||||
self.assertTrue(
|
||||
CachedValue.objects.exists()
|
||||
)
|
||||
|
||||
search_backend.clear()
|
||||
self.assertFalse(
|
||||
CachedValue.objects.exists()
|
||||
)
|
||||
|
||||
def test_search(self):
|
||||
"""
|
||||
Test various searches.
|
||||
"""
|
||||
sites = Site.objects.all()
|
||||
search_backend.cache(sites)
|
||||
|
||||
results = search_backend.search('site')
|
||||
self.assertEqual(len(results), 3)
|
||||
results = search_backend.search('first')
|
||||
self.assertEqual(len(results), 1)
|
||||
results = search_backend.search('xxxxx')
|
||||
self.assertEqual(len(results), 0)
|
@ -2,15 +2,16 @@ import platform
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponseServerError
|
||||
from django.shortcuts import redirect, render
|
||||
from django.template import loader
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
||||
from django.views.generic import View
|
||||
from django_tables2 import RequestConfig
|
||||
from packaging import version
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
@ -21,10 +22,13 @@ from dcim.models import (
|
||||
from extras.models import ObjectChange
|
||||
from extras.tables import ObjectChangeTable
|
||||
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
|
||||
from netbox.constants import SEARCH_MAX_RESULTS
|
||||
from netbox.forms import SearchForm
|
||||
from netbox.search.backends import default_search_engine
|
||||
from netbox.search import LookupTypes
|
||||
from netbox.search.backends import search_backend
|
||||
from netbox.tables import SearchTable
|
||||
from tenancy.models import Tenant
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from wireless.models import WirelessLAN, WirelessLink
|
||||
|
||||
@ -149,22 +153,48 @@ class HomeView(View):
|
||||
class SearchView(View):
|
||||
|
||||
def get(self, request):
|
||||
form = SearchForm(request.GET)
|
||||
results = []
|
||||
highlight = None
|
||||
|
||||
# Initialize search form
|
||||
form = SearchForm(request.GET) if 'q' in request.GET else SearchForm()
|
||||
|
||||
if form.is_valid():
|
||||
search_registry = default_search_engine.get_registry()
|
||||
# If an object type has been specified, redirect to the dedicated view for it
|
||||
if form.cleaned_data['obj_type']:
|
||||
object_type = form.cleaned_data['obj_type']
|
||||
url = reverse(search_registry[object_type].url)
|
||||
return redirect(f"{url}?q={form.cleaned_data['q']}")
|
||||
|
||||
results = default_search_engine.search(request, form.cleaned_data['q'])
|
||||
# Restrict results by object type
|
||||
object_types = []
|
||||
for obj_type in form.cleaned_data['obj_types']:
|
||||
app_label, model_name = obj_type.split('.')
|
||||
object_types.append(ContentType.objects.get_by_natural_key(app_label, model_name))
|
||||
|
||||
lookup = form.cleaned_data['lookup'] or LookupTypes.PARTIAL
|
||||
results = search_backend.search(
|
||||
form.cleaned_data['q'],
|
||||
user=request.user,
|
||||
object_types=object_types,
|
||||
lookup=lookup
|
||||
)
|
||||
|
||||
if form.cleaned_data['lookup'] != LookupTypes.EXACT:
|
||||
highlight = form.cleaned_data['q']
|
||||
|
||||
table = SearchTable(results, highlight=highlight)
|
||||
|
||||
# Paginate the table results
|
||||
RequestConfig(request, {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': get_paginate_count(request)
|
||||
}).configure(table)
|
||||
|
||||
# If this is an HTMX request, return only the rendered table HTML
|
||||
if is_htmx(request):
|
||||
return render(request, 'htmx/table.html', {
|
||||
'table': table,
|
||||
})
|
||||
|
||||
return render(request, 'search.html', {
|
||||
'form': form,
|
||||
'results': results,
|
||||
'table': table,
|
||||
})
|
||||
|
||||
|
||||
|
@ -31,8 +31,7 @@
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars-experimental": "error",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"no-unused-vars": "off",
|
||||
"no-inner-declarations": "off",
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
|
BIN
netbox/project-static/dist/cable_trace.css
vendored
BIN
netbox/project-static/dist/cable_trace.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/config.js
vendored
BIN
netbox/project-static/dist/config.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/config.js.map
vendored
BIN
netbox/project-static/dist/config.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/graphiql.css
vendored
BIN
netbox/project-static/dist/graphiql.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/graphiql.js
vendored
BIN
netbox/project-static/dist/graphiql.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/graphiql.js.map
vendored
BIN
netbox/project-static/dist/graphiql.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js
vendored
BIN
netbox/project-static/dist/lldp.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js.map
vendored
BIN
netbox/project-static/dist/lldp.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff
vendored
Normal file
BIN
netbox/project-static/dist/materialdesignicons-webfont-DWVXV5L5.woff
vendored
Normal file
Binary file not shown.
BIN
netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2
vendored
Normal file
BIN
netbox/project-static/dist/materialdesignicons-webfont-ER2MFQKM.woff2
vendored
Normal file
Binary file not shown.
BIN
netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot
vendored
Normal file
BIN
netbox/project-static/dist/materialdesignicons-webfont-UHEFFMSX.eot
vendored
Normal file
Binary file not shown.
BIN
netbox/project-static/dist/materialdesignicons-webfont-WM6M6ZHQ.ttf
vendored
Normal file
BIN
netbox/project-static/dist/materialdesignicons-webfont-WM6M6ZHQ.ttf
vendored
Normal file
Binary file not shown.
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-external.css
vendored
BIN
netbox/project-static/dist/netbox-external.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/rack_elevation.css
vendored
BIN
netbox/project-static/dist/rack_elevation.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js
vendored
BIN
netbox/project-static/dist/status.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js.map
vendored
BIN
netbox/project-static/dist/status.js.map
vendored
Binary file not shown.
@ -22,43 +22,38 @@
|
||||
"validate:formatting:scripts": "prettier -c src/**/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^5.9.55",
|
||||
"@popperjs/core": "^2.9.2",
|
||||
"@mdi/font": "^7.0.96",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"bootstrap": "~5.0.2",
|
||||
"clipboard": "^2.0.8",
|
||||
"color2k": "^1.2.4",
|
||||
"dayjs": "^1.10.4",
|
||||
"flatpickr": "4.6.3",
|
||||
"htmx.org": "^1.6.1",
|
||||
"just-debounce-it": "^1.4.0",
|
||||
"clipboard": "^2.0.11",
|
||||
"color2k": "^2.0.0",
|
||||
"dayjs": "^1.11.5",
|
||||
"flatpickr": "4.6.13",
|
||||
"htmx.org": "^1.8.0",
|
||||
"just-debounce-it": "^3.1.1",
|
||||
"masonry-layout": "^4.2.2",
|
||||
"query-string": "^6.14.1",
|
||||
"sass": "^1.32.8",
|
||||
"simplebar": "^5.3.4",
|
||||
"slim-select": "^1.27.0"
|
||||
"query-string": "^7.1.1",
|
||||
"sass": "^1.55.0",
|
||||
"simplebar": "^5.3.9",
|
||||
"slim-select": "^1.27.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^5.0.12",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/masonry-layout": "^4.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.3",
|
||||
"@typescript-eslint/parser": "^4.29.3",
|
||||
"esbuild": "^0.12.24",
|
||||
"esbuild-sass-plugin": "^1.5.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-import-resolver-typescript": "^2.4.0",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-prettier": "^3.4.1",
|
||||
"prettier": "^2.3.2",
|
||||
"typescript": "~4.3.5"
|
||||
"@types/bootstrap": "^5.0.17",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/masonry-layout": "^4.2.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.39.0",
|
||||
"@typescript-eslint/parser": "^5.39.0",
|
||||
"esbuild": "^0.13.15",
|
||||
"esbuild-sass-plugin": "^2.3.3",
|
||||
"eslint": "^8.24.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "~4.8.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"eslint-import-resolver-typescript/**/path-parse": "^1.0.7",
|
||||
"slim-select/**/trim-newlines": "^3.0.1",
|
||||
"eslint/glob-parent": "^5.1.2",
|
||||
"esbuild-sass-plugin/**/glob-parent": "^5.1.2",
|
||||
"@typescript-eslint/**/glob-parent": "^5.1.2",
|
||||
"eslint-plugin-import/**/hosted-git-info": "^2.8.9"
|
||||
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { initForms } from './forms';
|
||||
import { initBootstrap } from './bs';
|
||||
import { initSearch } from './search';
|
||||
import { initQuickSearch } from './search';
|
||||
import { initSelect } from './select';
|
||||
import { initButtons } from './buttons';
|
||||
import { initColorMode } from './colorMode';
|
||||
@ -20,7 +20,7 @@ function initDocument(): void {
|
||||
initColorMode,
|
||||
initMessages,
|
||||
initForms,
|
||||
initSearch,
|
||||
initQuickSearch,
|
||||
initSelect,
|
||||
initDateSelector,
|
||||
initButtons,
|
||||
@ -37,14 +37,12 @@ function initDocument(): void {
|
||||
}
|
||||
|
||||
function initWindow(): void {
|
||||
|
||||
const documentForms = document.forms
|
||||
for (var documentForm of documentForms) {
|
||||
const documentForms = document.forms;
|
||||
for (const documentForm of documentForms) {
|
||||
if (documentForm.method.toUpperCase() == 'GET') {
|
||||
// @ts-ignore: Our version of typescript seems to be too old for FormDataEvent
|
||||
documentForm.addEventListener('formdata', function(event: FormDataEvent) {
|
||||
let formData: FormData = event.formData;
|
||||
for (let [name, value] of Array.from(formData.entries())) {
|
||||
documentForm.addEventListener('formdata', function (event: FormDataEvent) {
|
||||
const formData: FormData = event.formData;
|
||||
for (const [name, value] of Array.from(formData.entries())) {
|
||||
if (value === '') formData.delete(name);
|
||||
}
|
||||
});
|
||||
|
@ -1,31 +1,4 @@
|
||||
import { getElements, findFirstAdjacent, isTruthy } from './util';
|
||||
|
||||
/**
|
||||
* Change the display value and hidden input values of the search filter based on dropdown
|
||||
* selection.
|
||||
*
|
||||
* @param event "click" event for each dropdown item.
|
||||
* @param button Each dropdown item element.
|
||||
*/
|
||||
function handleSearchDropdownClick(event: Event, button: HTMLButtonElement): void {
|
||||
const dropdown = event.currentTarget as HTMLButtonElement;
|
||||
const selectedValue = findFirstAdjacent<HTMLSpanElement>(dropdown, 'span.search-obj-selected');
|
||||
const selectedType = findFirstAdjacent<HTMLInputElement>(dropdown, 'input.search-obj-type');
|
||||
const searchValue = dropdown.getAttribute('data-search-value');
|
||||
let selected = '' as string;
|
||||
|
||||
if (selectedValue !== null && selectedType !== null) {
|
||||
if (isTruthy(searchValue) && selected !== searchValue) {
|
||||
selected = searchValue;
|
||||
selectedValue.innerHTML = button.textContent ?? 'Error';
|
||||
selectedType.value = searchValue;
|
||||
} else {
|
||||
selected = '';
|
||||
selectedValue.innerHTML = 'All Objects';
|
||||
selectedType.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
import { isTruthy } from './util';
|
||||
|
||||
/**
|
||||
* Show/hide quicksearch clear button.
|
||||
@ -44,23 +17,10 @@ function quickSearchEventHandler(event: Event): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Search Bar Elements.
|
||||
*/
|
||||
function initSearchBar(): void {
|
||||
for (const dropdown of getElements<HTMLUListElement>('.search-obj-selector')) {
|
||||
for (const button of dropdown.querySelectorAll<HTMLButtonElement>(
|
||||
'li > button.dropdown-item',
|
||||
)) {
|
||||
button.addEventListener('click', event => handleSearchDropdownClick(event, button));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Quicksearch Event listener/handlers.
|
||||
*/
|
||||
function initQuickSearch(): void {
|
||||
export function initQuickSearch(): void {
|
||||
const quicksearch = document.getElementById("quicksearch") as HTMLInputElement;
|
||||
const clearbtn = document.getElementById("quicksearch_clear") as HTMLButtonElement;
|
||||
if (isTruthy(quicksearch)) {
|
||||
@ -82,10 +42,3 @@ function initQuickSearch(): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initSearch(): void {
|
||||
for (const func of [initSearchBar]) {
|
||||
func();
|
||||
}
|
||||
initQuickSearch();
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ $spacing-s: $input-padding-x;
|
||||
}
|
||||
}
|
||||
|
||||
@import './node_modules/slim-select/src/slim-select/slimselect';
|
||||
@import '../node_modules/slim-select/src/slim-select/slimselect';
|
||||
|
||||
.ss-main {
|
||||
color: $form-select-color;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
||||
{# Base layout for the core NetBox UI w/navbar and page content #}
|
||||
{% extends 'base/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load search %}
|
||||
{% load static %}
|
||||
|
||||
{% comment %}
|
||||
@ -41,7 +40,7 @@ Blocks:
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex my-1 flex-grow-1 justify-content-center w-100">
|
||||
{% search_options request %}
|
||||
{% include 'inc/searchbar.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -53,7 +52,7 @@ Blocks:
|
||||
|
||||
{# Search bar #}
|
||||
<div class="col-6 d-flex flex-grow-1 justify-content-center">
|
||||
{% search_options request %}
|
||||
{% include 'inc/searchbar.html' %}
|
||||
</div>
|
||||
|
||||
{# Proflie/login button #}
|
||||
|
@ -60,23 +60,17 @@
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
|
||||
{% include 'inc/panels/contacts.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
|
@ -77,10 +77,10 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Outlet</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Feed</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
|
@ -105,16 +105,16 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Interface</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Interface</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Circuit Termination</a>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
|
@ -39,13 +39,23 @@
|
||||
<td>{% checkmark object.required %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Weight</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
<th scope="row">Search Weight</th>
|
||||
<td>
|
||||
{% if object.search_weight %}
|
||||
{{ object.search_weight }}
|
||||
{% else %}
|
||||
<span class="text-muted">Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Filter Logic</th>
|
||||
<td>{{ object.get_filter_logic_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Display Weight</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">UI Visibility</th>
|
||||
<td>{{ object.get_ui_visibility_display }}</td>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user