mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-25 08:46:10 -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
|
label: Area
|
||||||
description: To what section of the documentation does this change primarily pertain?
|
description: To what section of the documentation does this change primarily pertain?
|
||||||
options:
|
options:
|
||||||
- Installation instructions
|
- Features
|
||||||
- Configuration parameters
|
- Installation/upgrade
|
||||||
- Functionality/features
|
- Getting started
|
||||||
- REST API
|
- Configuration
|
||||||
- Administration/development
|
- Customization
|
||||||
|
- Integrations/API
|
||||||
|
- Plugins
|
||||||
|
- Administration
|
||||||
|
- Development
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
@ -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
|
## STORAGE_BACKEND
|
||||||
|
|
||||||
Default: None (local storage)
|
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
|
### 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
|
### Via the API
|
||||||
|
|
||||||
@ -282,6 +282,8 @@ http://netbox/api/extras/scripts/example.MyReport/ \
|
|||||||
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
|
--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
|
### Via the CLI
|
||||||
|
|
||||||
Scripts can be run on the CLI by invoking the management command:
|
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
|
### 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
|
### Via the API
|
||||||
|
|
||||||
@ -152,6 +152,8 @@ Our example report above would be called as:
|
|||||||
POST /api/extras/reports/devices.DeviceConnectionsReport/run/
|
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
|
### Via the CLI
|
||||||
|
|
||||||
Reports can be run on the CLI by invoking the management command:
|
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
|
### General Server Configuration
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
|
When using Active Directory you may need to specify a port on `AUTH_LDAP_SERVER_URI` to authenticate users from all domains in the forest. Use `3269` for secure, or `3268` for non-secure access to the GC (Global Catalog).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import ldap
|
import ldap
|
||||||
@ -67,6 +67,16 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
|
|||||||
# Note that this is a NetBox-specific setting which sets:
|
# Note that this is a NetBox-specific setting which sets:
|
||||||
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||||
LDAP_IGNORE_CERT_ERRORS = True
|
LDAP_IGNORE_CERT_ERRORS = True
|
||||||
|
|
||||||
|
# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
|
||||||
|
# Note that this is a NetBox-specific setting which sets:
|
||||||
|
# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
|
||||||
|
LDAP_CA_CERT_DIR = '/etc/ssl/certs'
|
||||||
|
|
||||||
|
# Include this setting if you want to validate the LDAP server certificates against your own CA.
|
||||||
|
# Note that this is a NetBox-specific setting which sets:
|
||||||
|
# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
|
||||||
|
LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
|
||||||
```
|
```
|
||||||
|
|
||||||
STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
|
STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
|
||||||
|
@ -144,73 +144,73 @@ class MyModelFilterForm(NetBoxModelFilterSetForm):
|
|||||||
In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
|
In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
|
||||||
|
|
||||||
::: utilities.forms.ColorField
|
::: utilities.forms.ColorField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.CommentField
|
::: utilities.forms.CommentField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.JSONField
|
::: utilities.forms.JSONField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.MACAddressField
|
::: utilities.forms.MACAddressField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.SlugField
|
::: utilities.forms.SlugField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
## Choice Fields
|
## Choice Fields
|
||||||
|
|
||||||
::: utilities.forms.ChoiceField
|
::: utilities.forms.ChoiceField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.MultipleChoiceField
|
::: utilities.forms.MultipleChoiceField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
## Dynamic Object Fields
|
## Dynamic Object Fields
|
||||||
|
|
||||||
::: utilities.forms.DynamicModelChoiceField
|
::: utilities.forms.DynamicModelChoiceField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.DynamicModelMultipleChoiceField
|
::: utilities.forms.DynamicModelMultipleChoiceField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
## Content Type Fields
|
## Content Type Fields
|
||||||
|
|
||||||
::: utilities.forms.ContentTypeChoiceField
|
::: utilities.forms.ContentTypeChoiceField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.ContentTypeMultipleChoiceField
|
::: utilities.forms.ContentTypeMultipleChoiceField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
## CSV Import Fields
|
## CSV Import Fields
|
||||||
|
|
||||||
::: utilities.forms.CSVChoiceField
|
::: utilities.forms.CSVChoiceField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.CSVMultipleChoiceField
|
::: utilities.forms.CSVMultipleChoiceField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.CSVModelChoiceField
|
::: utilities.forms.CSVModelChoiceField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.CSVContentTypeField
|
::: utilities.forms.CSVContentTypeField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: utilities.forms.CSVMultipleContentTypeField
|
::: utilities.forms.CSVMultipleContentTypeField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
@ -32,11 +32,11 @@ schema = MyQuery
|
|||||||
NetBox provides two object type classes for use by plugins.
|
NetBox provides two object type classes for use by plugins.
|
||||||
|
|
||||||
::: netbox.graphql.types.BaseObjectType
|
::: netbox.graphql.types.BaseObjectType
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.graphql.types.NetBoxObjectType
|
::: netbox.graphql.types.NetBoxObjectType
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
## GraphQL Fields
|
## GraphQL Fields
|
||||||
@ -44,9 +44,9 @@ NetBox provides two object type classes for use by plugins.
|
|||||||
NetBox provides two field classes for use by plugins.
|
NetBox provides two field classes for use by plugins.
|
||||||
|
|
||||||
::: netbox.graphql.fields.ObjectField
|
::: netbox.graphql.fields.ObjectField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.graphql.fields.ObjectListField
|
::: netbox.graphql.fields.ObjectListField
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
@ -4,17 +4,16 @@ Plugins can define and register their own models to extend NetBox's core search
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# search.py
|
# search.py
|
||||||
from netbox.search import SearchMixin
|
from netbox.search import SearchIndex
|
||||||
from .filters import MyModelFilterSet
|
|
||||||
from .tables import MyModelTable
|
|
||||||
from .models import MyModel
|
from .models import MyModel
|
||||||
|
|
||||||
class MyModelIndex(SearchMixin):
|
class MyModelIndex(SearchIndex):
|
||||||
model = MyModel
|
model = MyModel
|
||||||
queryset = MyModel.objects.all()
|
fields = (
|
||||||
filterset = MyModelFilterSet
|
('name', 100),
|
||||||
table = MyModelTable
|
('description', 500),
|
||||||
url = 'plugins:myplugin:mymodel_list'
|
('comments', 5000),
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
|
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`.
|
The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
|
||||||
|
|
||||||
::: netbox.tables.BooleanColumn
|
::: netbox.tables.BooleanColumn
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.tables.ChoiceFieldColumn
|
::: netbox.tables.ChoiceFieldColumn
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.tables.ColorColumn
|
::: netbox.tables.ColorColumn
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.tables.ColoredLabelColumn
|
::: netbox.tables.ColoredLabelColumn
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.tables.ContentTypeColumn
|
::: netbox.tables.ContentTypeColumn
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.tables.ContentTypesColumn
|
::: netbox.tables.ContentTypesColumn
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.tables.MarkdownColumn
|
::: netbox.tables.MarkdownColumn
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.tables.TagColumn
|
::: netbox.tables.TagColumn
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.tables.TemplateColumn
|
::: netbox.tables.TemplateColumn
|
||||||
selection:
|
options:
|
||||||
members:
|
members:
|
||||||
- __init__
|
- __init__
|
||||||
|
@ -84,24 +84,24 @@ Below are the class definitions for NetBox's object views. These views handle CR
|
|||||||
::: netbox.views.generic.base.BaseObjectView
|
::: netbox.views.generic.base.BaseObjectView
|
||||||
|
|
||||||
::: netbox.views.generic.ObjectView
|
::: netbox.views.generic.ObjectView
|
||||||
selection:
|
options:
|
||||||
members:
|
members:
|
||||||
- get_object
|
- get_object
|
||||||
- get_template_name
|
- get_template_name
|
||||||
|
|
||||||
::: netbox.views.generic.ObjectEditView
|
::: netbox.views.generic.ObjectEditView
|
||||||
selection:
|
options:
|
||||||
members:
|
members:
|
||||||
- get_object
|
- get_object
|
||||||
- alter_object
|
- alter_object
|
||||||
|
|
||||||
::: netbox.views.generic.ObjectDeleteView
|
::: netbox.views.generic.ObjectDeleteView
|
||||||
selection:
|
options:
|
||||||
members:
|
members:
|
||||||
- get_object
|
- get_object
|
||||||
|
|
||||||
::: netbox.views.generic.ObjectChildrenView
|
::: netbox.views.generic.ObjectChildrenView
|
||||||
selection:
|
options:
|
||||||
members:
|
members:
|
||||||
- get_children
|
- get_children
|
||||||
- prep_table_data
|
- prep_table_data
|
||||||
@ -113,22 +113,22 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
|||||||
::: netbox.views.generic.base.BaseMultiObjectView
|
::: netbox.views.generic.base.BaseMultiObjectView
|
||||||
|
|
||||||
::: netbox.views.generic.ObjectListView
|
::: netbox.views.generic.ObjectListView
|
||||||
selection:
|
options:
|
||||||
members:
|
members:
|
||||||
- get_table
|
- get_table
|
||||||
- export_table
|
- export_table
|
||||||
- export_template
|
- export_template
|
||||||
|
|
||||||
::: netbox.views.generic.BulkImportView
|
::: netbox.views.generic.BulkImportView
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.views.generic.BulkEditView
|
::: netbox.views.generic.BulkEditView
|
||||||
selection:
|
options:
|
||||||
members: false
|
members: false
|
||||||
|
|
||||||
::: netbox.views.generic.BulkDeleteView
|
::: netbox.views.generic.BulkDeleteView
|
||||||
selection:
|
options:
|
||||||
members:
|
members:
|
||||||
- get_form
|
- get_form
|
||||||
|
|
||||||
@ -137,12 +137,12 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
|||||||
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
|
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
|
||||||
|
|
||||||
::: netbox.views.generic.ObjectChangeLogView
|
::: netbox.views.generic.ObjectChangeLogView
|
||||||
selection:
|
options:
|
||||||
members:
|
members:
|
||||||
- get_form
|
- get_form
|
||||||
|
|
||||||
::: netbox.views.generic.ObjectJournalView
|
::: netbox.views.generic.ObjectJournalView
|
||||||
selection:
|
options:
|
||||||
members:
|
members:
|
||||||
- get_form
|
- get_form
|
||||||
|
|
||||||
|
@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
## v3.3.6 (FUTURE)
|
## v3.3.6 (FUTURE)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates
|
||||||
|
* [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication
|
||||||
|
* [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth
|
||||||
|
* [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link
|
||||||
|
* [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms
|
||||||
|
* [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable
|
||||||
|
* [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables
|
||||||
|
* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v3.3.5 (2022-10-05)
|
## v3.3.5 (2022-10-05)
|
||||||
|
@ -11,6 +11,10 @@
|
|||||||
|
|
||||||
### New Features
|
### 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))
|
#### 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.
|
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.chdir('netbox/')
|
||||||
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
|
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
|
||||||
- django.setup()
|
- django.setup()
|
||||||
rendering:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
members_order: source
|
members_order: source
|
||||||
show_root_heading: true
|
show_root_heading: true
|
||||||
@ -245,6 +245,7 @@ nav:
|
|||||||
- Adding Models: 'development/adding-models.md'
|
- Adding Models: 'development/adding-models.md'
|
||||||
- Extending Models: 'development/extending-models.md'
|
- Extending Models: 'development/extending-models.md'
|
||||||
- Signals: 'development/signals.md'
|
- Signals: 'development/signals.md'
|
||||||
|
- Search: 'development/search.md'
|
||||||
- Application Registry: 'development/application-registry.md'
|
- Application Registry: 'development/application-registry.md'
|
||||||
- User Preferences: 'development/user-preferences.md'
|
- User Preferences: 'development/user-preferences.md'
|
||||||
- Web UI: 'development/web-ui.md'
|
- Web UI: 'development/web-ui.md'
|
||||||
|
@ -64,6 +64,12 @@ class ProviderNetworkForm(NetBoxModelForm):
|
|||||||
class CircuitTypeForm(NetBoxModelForm):
|
class CircuitTypeForm(NetBoxModelForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Circuit Type', (
|
||||||
|
'name', 'slug', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CircuitType
|
model = CircuitType
|
||||||
fields = [
|
fields = [
|
||||||
|
@ -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 netbox.search import SearchIndex, register_search
|
||||||
from utilities.utils import count_related
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
@register_search
|
||||||
class ProviderIndex(SearchIndex):
|
|
||||||
model = Provider
|
|
||||||
queryset = Provider.objects.annotate(count_circuits=count_related(Circuit, 'provider'))
|
|
||||||
filterset = circuits.filtersets.ProviderFilterSet
|
|
||||||
table = circuits.tables.ProviderTable
|
|
||||||
url = 'circuits:provider_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class CircuitIndex(SearchIndex):
|
class CircuitIndex(SearchIndex):
|
||||||
model = Circuit
|
model = models.Circuit
|
||||||
queryset = Circuit.objects.prefetch_related(
|
fields = (
|
||||||
'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
|
('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):
|
class ProviderNetworkIndex(SearchIndex):
|
||||||
model = ProviderNetwork
|
model = models.ProviderNetwork
|
||||||
queryset = ProviderNetwork.objects.prefetch_related('provider')
|
fields = (
|
||||||
filterset = circuits.filtersets.ProviderNetworkFilterSet
|
('name', 100),
|
||||||
table = circuits.tables.ProviderNetworkTable
|
('service_id', 200),
|
||||||
url = 'circuits:providernetwork_list'
|
('description', 500),
|
||||||
|
('comments', 5000),
|
||||||
|
)
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
|
|
||||||
from circuits.models import *
|
from circuits.models import *
|
||||||
|
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||||
|
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
from tenancy.tables import TenancyColumnsMixin
|
|
||||||
from .columns import CommitRateColumn
|
from .columns import CommitRateColumn
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -39,7 +40,7 @@ class CircuitTypeTable(NetBoxTable):
|
|||||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
||||||
|
|
||||||
|
|
||||||
class CircuitTable(TenancyColumnsMixin, NetBoxTable):
|
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||||
cid = tables.Column(
|
cid = tables.Column(
|
||||||
linkify=True,
|
linkify=True,
|
||||||
verbose_name='Circuit ID'
|
verbose_name='Circuit ID'
|
||||||
@ -58,9 +59,6 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
)
|
)
|
||||||
commit_rate = CommitRateColumn()
|
commit_rate = CommitRateColumn()
|
||||||
comments = columns.MarkdownColumn()
|
comments = columns.MarkdownColumn()
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='circuits:circuit_list'
|
url_name='circuits:circuit_list'
|
||||||
)
|
)
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django_tables2.utils import Accessor
|
|
||||||
|
|
||||||
from circuits.models import *
|
from circuits.models import *
|
||||||
|
from django_tables2.utils import Accessor
|
||||||
|
from tenancy.tables import ContactsColumnMixin
|
||||||
|
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -10,7 +11,7 @@ __all__ = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProviderTable(NetBoxTable):
|
class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
@ -31,9 +32,6 @@ class ProviderTable(NetBoxTable):
|
|||||||
verbose_name='Circuits'
|
verbose_name='Circuits'
|
||||||
)
|
)
|
||||||
comments = columns.MarkdownColumn()
|
comments = columns.MarkdownColumn()
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='circuits:provider_list'
|
url_name='circuits:provider_list'
|
||||||
)
|
)
|
||||||
|
@ -108,7 +108,7 @@ def get_cable_form(a_type, b_type):
|
|||||||
label='Power Feed',
|
label='Power Feed',
|
||||||
disabled_indicator='_occupied',
|
disabled_indicator='_occupied',
|
||||||
query_params={
|
query_params={
|
||||||
'powerpanel_id': f'$termination_{cable_end}_powerpanel',
|
'power_panel_id': f'$termination_{cable_end}_powerpanel',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -78,6 +78,12 @@ class RegionForm(NetBoxModelForm):
|
|||||||
)
|
)
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Region', (
|
||||||
|
'parent', 'name', 'slug', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Region
|
model = Region
|
||||||
fields = (
|
fields = (
|
||||||
@ -92,6 +98,12 @@ class SiteGroupForm(NetBoxModelForm):
|
|||||||
)
|
)
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Site Group', (
|
||||||
|
'parent', 'name', 'slug', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SiteGroup
|
model = SiteGroup
|
||||||
fields = (
|
fields = (
|
||||||
@ -213,6 +225,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
|||||||
class RackRoleForm(NetBoxModelForm):
|
class RackRoleForm(NetBoxModelForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Rack Role', (
|
||||||
|
'name', 'slug', 'color', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RackRole
|
model = RackRole
|
||||||
fields = [
|
fields = [
|
||||||
@ -341,6 +359,12 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
|
|||||||
class ManufacturerForm(NetBoxModelForm):
|
class ManufacturerForm(NetBoxModelForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Manufacturer', (
|
||||||
|
'name', 'slug', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Manufacturer
|
model = Manufacturer
|
||||||
fields = [
|
fields = [
|
||||||
@ -413,6 +437,12 @@ class ModuleTypeForm(NetBoxModelForm):
|
|||||||
class DeviceRoleForm(NetBoxModelForm):
|
class DeviceRoleForm(NetBoxModelForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Device Role', (
|
||||||
|
'name', 'slug', 'color', 'vm_role', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceRole
|
model = DeviceRole
|
||||||
fields = [
|
fields = [
|
||||||
@ -429,6 +459,13 @@ class PlatformForm(NetBoxModelForm):
|
|||||||
max_length=64
|
max_length=64
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Platform', (
|
||||||
|
'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
||||||
|
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Platform
|
model = Platform
|
||||||
fields = [
|
fields = [
|
||||||
@ -1584,6 +1621,12 @@ class InventoryItemForm(DeviceComponentForm):
|
|||||||
class InventoryItemRoleForm(NetBoxModelForm):
|
class InventoryItemRoleForm(NetBoxModelForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Inventory Item Role', (
|
||||||
|
'name', 'slug', 'color', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryItemRole
|
model = InventoryItemRole
|
||||||
fields = [
|
fields = [
|
||||||
|
@ -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 netbox.search import SearchIndex, register_search
|
||||||
from utilities.utils import count_related
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
@register_search
|
||||||
class SiteIndex(SearchIndex):
|
|
||||||
model = Site
|
|
||||||
queryset = Site.objects.prefetch_related('region', 'tenant', 'tenant__group')
|
|
||||||
filterset = dcim.filtersets.SiteFilterSet
|
|
||||||
table = dcim.tables.SiteTable
|
|
||||||
url = 'dcim:site_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class RackIndex(SearchIndex):
|
|
||||||
model = Rack
|
|
||||||
queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate(
|
|
||||||
device_count=count_related(Device, 'rack')
|
|
||||||
)
|
|
||||||
filterset = dcim.filtersets.RackFilterSet
|
|
||||||
table = dcim.tables.RackTable
|
|
||||||
url = 'dcim:rack_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class RackReservationIndex(SearchIndex):
|
|
||||||
model = RackReservation
|
|
||||||
queryset = RackReservation.objects.prefetch_related('rack', 'user')
|
|
||||||
filterset = dcim.filtersets.RackReservationFilterSet
|
|
||||||
table = dcim.tables.RackReservationTable
|
|
||||||
url = 'dcim:rackreservation_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class LocationIndex(SearchIndex):
|
|
||||||
model = Location
|
|
||||||
queryset = Location.objects.add_related_count(
|
|
||||||
Location.objects.add_related_count(Location.objects.all(), Device, 'location', 'device_count', cumulative=True),
|
|
||||||
Rack,
|
|
||||||
'location',
|
|
||||||
'rack_count',
|
|
||||||
cumulative=True,
|
|
||||||
).prefetch_related('site')
|
|
||||||
filterset = dcim.filtersets.LocationFilterSet
|
|
||||||
table = dcim.tables.LocationTable
|
|
||||||
url = 'dcim:location_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class DeviceTypeIndex(SearchIndex):
|
|
||||||
model = DeviceType
|
|
||||||
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
|
|
||||||
instance_count=count_related(Device, 'device_type')
|
|
||||||
)
|
|
||||||
filterset = dcim.filtersets.DeviceTypeFilterSet
|
|
||||||
table = dcim.tables.DeviceTypeTable
|
|
||||||
url = 'dcim:devicetype_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class DeviceIndex(SearchIndex):
|
|
||||||
model = Device
|
|
||||||
queryset = Device.objects.prefetch_related(
|
|
||||||
'device_type__manufacturer',
|
|
||||||
'device_role',
|
|
||||||
'tenant',
|
|
||||||
'tenant__group',
|
|
||||||
'site',
|
|
||||||
'rack',
|
|
||||||
'primary_ip4',
|
|
||||||
'primary_ip6',
|
|
||||||
)
|
|
||||||
filterset = dcim.filtersets.DeviceFilterSet
|
|
||||||
table = dcim.tables.DeviceTable
|
|
||||||
url = 'dcim:device_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class ModuleTypeIndex(SearchIndex):
|
|
||||||
model = ModuleType
|
|
||||||
queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
|
|
||||||
instance_count=count_related(Module, 'module_type')
|
|
||||||
)
|
|
||||||
filterset = dcim.filtersets.ModuleTypeFilterSet
|
|
||||||
table = dcim.tables.ModuleTypeTable
|
|
||||||
url = 'dcim:moduletype_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class ModuleIndex(SearchIndex):
|
|
||||||
model = Module
|
|
||||||
queryset = Module.objects.prefetch_related(
|
|
||||||
'module_type__manufacturer',
|
|
||||||
'device',
|
|
||||||
'module_bay',
|
|
||||||
)
|
|
||||||
filterset = dcim.filtersets.ModuleFilterSet
|
|
||||||
table = dcim.tables.ModuleTable
|
|
||||||
url = 'dcim:module_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class VirtualChassisIndex(SearchIndex):
|
|
||||||
model = VirtualChassis
|
|
||||||
queryset = VirtualChassis.objects.prefetch_related('master').annotate(
|
|
||||||
member_count=count_related(Device, 'virtual_chassis')
|
|
||||||
)
|
|
||||||
filterset = dcim.filtersets.VirtualChassisFilterSet
|
|
||||||
table = dcim.tables.VirtualChassisTable
|
|
||||||
url = 'dcim:virtualchassis_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class CableIndex(SearchIndex):
|
class CableIndex(SearchIndex):
|
||||||
model = Cable
|
model = models.Cable
|
||||||
queryset = Cable.objects.all()
|
fields = (
|
||||||
filterset = dcim.filtersets.CableFilterSet
|
('label', 100),
|
||||||
table = dcim.tables.CableTable
|
)
|
||||||
url = 'dcim:cable_list'
|
|
||||||
|
|
||||||
|
|
||||||
@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):
|
class PowerFeedIndex(SearchIndex):
|
||||||
model = PowerFeed
|
model = models.PowerFeed
|
||||||
queryset = PowerFeed.objects.all()
|
fields = (
|
||||||
filterset = dcim.filtersets.PowerFeedFilterSet
|
('name', 100),
|
||||||
table = dcim.tables.PowerFeedTable
|
('comments', 5000),
|
||||||
url = 'dcim:powerfeed_list'
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
import django_tables2 as tables
|
||||||
from django_tables2.utils import Accessor
|
|
||||||
|
|
||||||
from dcim.models import (
|
from dcim.models import (
|
||||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
|
ConsolePort,
|
||||||
InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
|
ConsoleServerPort,
|
||||||
|
Device,
|
||||||
|
DeviceBay,
|
||||||
|
DeviceRole,
|
||||||
|
FrontPort,
|
||||||
|
Interface,
|
||||||
|
InventoryItem,
|
||||||
|
InventoryItemRole,
|
||||||
|
ModuleBay,
|
||||||
|
Platform,
|
||||||
|
PowerOutlet,
|
||||||
|
PowerPort,
|
||||||
|
RearPort,
|
||||||
|
VirtualChassis,
|
||||||
)
|
)
|
||||||
|
from django_tables2.utils import Accessor
|
||||||
|
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||||
|
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
from tenancy.tables import TenancyColumnsMixin
|
|
||||||
from .template_code import *
|
from .template_code import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -137,7 +151,7 @@ class PlatformTable(NetBoxTable):
|
|||||||
# Devices
|
# Devices
|
||||||
#
|
#
|
||||||
|
|
||||||
class DeviceTable(TenancyColumnsMixin, NetBoxTable):
|
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||||
name = tables.TemplateColumn(
|
name = tables.TemplateColumn(
|
||||||
order_by=('_name',),
|
order_by=('_name',),
|
||||||
template_code=DEVICE_LINK
|
template_code=DEVICE_LINK
|
||||||
@ -201,9 +215,6 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
verbose_name='VC Priority'
|
verbose_name='VC Priority'
|
||||||
)
|
)
|
||||||
comments = columns.MarkdownColumn()
|
comments = columns.MarkdownColumn()
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:device_list'
|
url_name='dcim:device_list'
|
||||||
)
|
)
|
||||||
|
@ -1,10 +1,21 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
|
|
||||||
from dcim.models import (
|
from dcim.models import (
|
||||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
|
ConsolePortTemplate,
|
||||||
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
ConsoleServerPortTemplate,
|
||||||
|
DeviceBayTemplate,
|
||||||
|
DeviceType,
|
||||||
|
FrontPortTemplate,
|
||||||
|
InterfaceTemplate,
|
||||||
|
InventoryItemTemplate,
|
||||||
|
Manufacturer,
|
||||||
|
ModuleBayTemplate,
|
||||||
|
PowerOutletTemplate,
|
||||||
|
PowerPortTemplate,
|
||||||
|
RearPortTemplate,
|
||||||
)
|
)
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
|
from tenancy.tables import ContactsColumnMixin
|
||||||
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
|
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -27,7 +38,7 @@ __all__ = (
|
|||||||
# Manufacturers
|
# Manufacturers
|
||||||
#
|
#
|
||||||
|
|
||||||
class ManufacturerTable(NetBoxTable):
|
class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
@ -43,9 +54,6 @@ class ManufacturerTable(NetBoxTable):
|
|||||||
verbose_name='Platforms'
|
verbose_name='Platforms'
|
||||||
)
|
)
|
||||||
slug = tables.Column()
|
slug = tables.Column()
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:manufacturer_list'
|
url_name='dcim:manufacturer_list'
|
||||||
)
|
)
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
|
|
||||||
from dcim.models import PowerFeed, PowerPanel
|
from dcim.models import PowerFeed, PowerPanel
|
||||||
|
from tenancy.tables import ContactsColumnMixin
|
||||||
|
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
|
|
||||||
from .devices import CableTerminationTable
|
from .devices import CableTerminationTable
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -14,7 +16,7 @@ __all__ = (
|
|||||||
# Power panels
|
# Power panels
|
||||||
#
|
#
|
||||||
|
|
||||||
class PowerPanelTable(NetBoxTable):
|
class PowerPanelTable(ContactsColumnMixin, NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
@ -29,9 +31,6 @@ class PowerPanelTable(NetBoxTable):
|
|||||||
url_params={'power_panel_id': 'pk'},
|
url_params={'power_panel_id': 'pk'},
|
||||||
verbose_name='Feeds'
|
verbose_name='Feeds'
|
||||||
)
|
)
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:powerpanel_list'
|
url_name='dcim:powerpanel_list'
|
||||||
)
|
)
|
||||||
|
@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
|
|||||||
|
|
||||||
from dcim.models import Rack, RackReservation, RackRole
|
from dcim.models import Rack, RackReservation, RackRole
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
from tenancy.tables import TenancyColumnsMixin
|
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||||
from .template_code import DEVICE_WEIGHT
|
from .template_code import DEVICE_WEIGHT
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -38,7 +38,7 @@ class RackRoleTable(NetBoxTable):
|
|||||||
# Racks
|
# Racks
|
||||||
#
|
#
|
||||||
|
|
||||||
class RackTable(TenancyColumnsMixin, NetBoxTable):
|
class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
order_by=('_name',),
|
order_by=('_name',),
|
||||||
linkify=True
|
linkify=True
|
||||||
@ -69,9 +69,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
orderable=False,
|
orderable=False,
|
||||||
verbose_name='Power'
|
verbose_name='Power'
|
||||||
)
|
)
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:rack_list'
|
url_name='dcim:rack_list'
|
||||||
)
|
)
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
|
|
||||||
from dcim.models import Location, Region, Site, SiteGroup
|
from dcim.models import Location, Region, Site, SiteGroup
|
||||||
|
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||||
|
|
||||||
from netbox.tables import NetBoxTable, columns
|
from netbox.tables import NetBoxTable, columns
|
||||||
from tenancy.tables import TenancyColumnsMixin
|
|
||||||
from .template_code import LOCATION_BUTTONS
|
from .template_code import LOCATION_BUTTONS
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -17,7 +18,7 @@ __all__ = (
|
|||||||
# Regions
|
# Regions
|
||||||
#
|
#
|
||||||
|
|
||||||
class RegionTable(NetBoxTable):
|
class RegionTable(ContactsColumnMixin, NetBoxTable):
|
||||||
name = columns.MPTTColumn(
|
name = columns.MPTTColumn(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
@ -26,9 +27,6 @@ class RegionTable(NetBoxTable):
|
|||||||
url_params={'region_id': 'pk'},
|
url_params={'region_id': 'pk'},
|
||||||
verbose_name='Sites'
|
verbose_name='Sites'
|
||||||
)
|
)
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:region_list'
|
url_name='dcim:region_list'
|
||||||
)
|
)
|
||||||
@ -46,7 +44,7 @@ class RegionTable(NetBoxTable):
|
|||||||
# Site groups
|
# Site groups
|
||||||
#
|
#
|
||||||
|
|
||||||
class SiteGroupTable(NetBoxTable):
|
class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
||||||
name = columns.MPTTColumn(
|
name = columns.MPTTColumn(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
@ -55,9 +53,6 @@ class SiteGroupTable(NetBoxTable):
|
|||||||
url_params={'group_id': 'pk'},
|
url_params={'group_id': 'pk'},
|
||||||
verbose_name='Sites'
|
verbose_name='Sites'
|
||||||
)
|
)
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:sitegroup_list'
|
url_name='dcim:sitegroup_list'
|
||||||
)
|
)
|
||||||
@ -75,7 +70,7 @@ class SiteGroupTable(NetBoxTable):
|
|||||||
# Sites
|
# Sites
|
||||||
#
|
#
|
||||||
|
|
||||||
class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
@ -97,9 +92,6 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
verbose_name='ASN Count'
|
verbose_name='ASN Count'
|
||||||
)
|
)
|
||||||
comments = columns.MarkdownColumn()
|
comments = columns.MarkdownColumn()
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:site_list'
|
url_name='dcim:site_list'
|
||||||
)
|
)
|
||||||
@ -118,7 +110,7 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
# Locations
|
# Locations
|
||||||
#
|
#
|
||||||
|
|
||||||
class LocationTable(TenancyColumnsMixin, NetBoxTable):
|
class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||||
name = columns.MPTTColumn(
|
name = columns.MPTTColumn(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
@ -136,9 +128,6 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
url_params={'location_id': 'pk'},
|
url_params={'location_id': 'pk'},
|
||||||
verbose_name='Devices'
|
verbose_name='Devices'
|
||||||
)
|
)
|
||||||
contacts = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:location_list'
|
url_name='dcim:location_list'
|
||||||
)
|
)
|
||||||
|
@ -131,24 +131,3 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
|||||||
})
|
})
|
||||||
|
|
||||||
return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
|
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',
|
'ObjectChangeSerializer',
|
||||||
'ReportDetailSerializer',
|
'ReportDetailSerializer',
|
||||||
'ReportSerializer',
|
'ReportSerializer',
|
||||||
|
'ReportInputSerializer',
|
||||||
'ScriptDetailSerializer',
|
'ScriptDetailSerializer',
|
||||||
'ScriptInputSerializer',
|
'ScriptInputSerializer',
|
||||||
'ScriptLogMessageSerializer',
|
'ScriptLogMessageSerializer',
|
||||||
@ -91,8 +92,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
|||||||
model = CustomField
|
model = CustomField
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
||||||
'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum',
|
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'default', 'weight',
|
||||||
'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_data_type(self, obj):
|
def get_data_type(self, obj):
|
||||||
@ -362,7 +363,7 @@ class JobResultSerializer(BaseModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = JobResult
|
model = JobResult
|
||||||
fields = [
|
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()
|
result = JobResultSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class ReportInputSerializer(serializers.Serializer):
|
||||||
|
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Scripts
|
# Scripts
|
||||||
#
|
#
|
||||||
@ -419,6 +424,7 @@ class ScriptDetailSerializer(ScriptSerializer):
|
|||||||
class ScriptInputSerializer(serializers.Serializer):
|
class ScriptInputSerializer(serializers.Serializer):
|
||||||
data = serializers.JSONField()
|
data = serializers.JSONField()
|
||||||
commit = serializers.BooleanField()
|
commit = serializers.BooleanField()
|
||||||
|
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
class ScriptLogMessageSerializer(serializers.Serializer):
|
class ScriptLogMessageSerializer(serializers.Serializer):
|
||||||
|
@ -231,19 +231,26 @@ class ReportViewSet(ViewSet):
|
|||||||
|
|
||||||
# Retrieve and run the Report. This will create a new JobResult.
|
# Retrieve and run the Report. This will create a new JobResult.
|
||||||
report = self._retrieve_report(pk)
|
report = self._retrieve_report(pk)
|
||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
input_serializer = serializers.ReportInputSerializer(data=request.data)
|
||||||
job_result = JobResult.enqueue_job(
|
|
||||||
run_report,
|
|
||||||
report.full_name,
|
|
||||||
report_content_type,
|
|
||||||
request.user,
|
|
||||||
job_timeout=report.job_timeout
|
|
||||||
)
|
|
||||||
report.result = job_result
|
|
||||||
|
|
||||||
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():
|
if input_serializer.is_valid():
|
||||||
data = input_serializer.data['data']
|
data = input_serializer.data['data']
|
||||||
commit = input_serializer.data['commit']
|
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')
|
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||||
job_result = JobResult.enqueue_job(
|
job_result = JobResult.enqueue_job(
|
||||||
@ -323,6 +331,7 @@ class ScriptViewSet(ViewSet):
|
|||||||
request=copy_safe_request(request),
|
request=copy_safe_request(request),
|
||||||
commit=commit,
|
commit=commit,
|
||||||
job_timeout=script.job_timeout,
|
job_timeout=script.job_timeout,
|
||||||
|
schedule_at=schedule_at,
|
||||||
)
|
)
|
||||||
script.result = job_result
|
script.result = job_result
|
||||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||||
|
@ -141,6 +141,7 @@ class LogLevelChoices(ChoiceSet):
|
|||||||
class JobResultStatusChoices(ChoiceSet):
|
class JobResultStatusChoices(ChoiceSet):
|
||||||
|
|
||||||
STATUS_PENDING = 'pending'
|
STATUS_PENDING = 'pending'
|
||||||
|
STATUS_SCHEDULED = 'scheduled'
|
||||||
STATUS_RUNNING = 'running'
|
STATUS_RUNNING = 'running'
|
||||||
STATUS_COMPLETED = 'completed'
|
STATUS_COMPLETED = 'completed'
|
||||||
STATUS_ERRORED = 'errored'
|
STATUS_ERRORED = 'errored'
|
||||||
@ -148,6 +149,7 @@ class JobResultStatusChoices(ChoiceSet):
|
|||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(STATUS_PENDING, 'Pending'),
|
(STATUS_PENDING, 'Pending'),
|
||||||
|
(STATUS_SCHEDULED, 'Scheduled'),
|
||||||
(STATUS_RUNNING, 'Running'),
|
(STATUS_RUNNING, 'Running'),
|
||||||
(STATUS_COMPLETED, 'Completed'),
|
(STATUS_COMPLETED, 'Completed'),
|
||||||
(STATUS_ERRORED, 'Errored'),
|
(STATUS_ERRORED, 'Errored'),
|
||||||
|
@ -16,6 +16,7 @@ __all__ = (
|
|||||||
'ConfigContextFilterSet',
|
'ConfigContextFilterSet',
|
||||||
'ContentTypeFilterSet',
|
'ContentTypeFilterSet',
|
||||||
'CustomFieldFilterSet',
|
'CustomFieldFilterSet',
|
||||||
|
'JobResultFilterSet',
|
||||||
'CustomLinkFilterSet',
|
'CustomLinkFilterSet',
|
||||||
'ExportTemplateFilterSet',
|
'ExportTemplateFilterSet',
|
||||||
'ImageAttachmentFilterSet',
|
'ImageAttachmentFilterSet',
|
||||||
@ -72,8 +73,8 @@ class CustomFieldFilterSet(BaseFilterSet):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = CustomField
|
model = CustomField
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight',
|
'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visibility',
|
||||||
'description',
|
'weight', 'description',
|
||||||
]
|
]
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
@ -435,7 +436,32 @@ class JobResultFilterSet(BaseFilterSet):
|
|||||||
label='Search',
|
label='Search',
|
||||||
)
|
)
|
||||||
created = django_filters.DateTimeFilter()
|
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 = 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(
|
status = django_filters.MultipleChoiceFilter(
|
||||||
choices=JobResultStatusChoices,
|
choices=JobResultStatusChoices,
|
||||||
null_value=None
|
null_value=None
|
||||||
@ -444,14 +470,15 @@ class JobResultFilterSet(BaseFilterSet):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = JobResult
|
model = JobResult
|
||||||
fields = [
|
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):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
return queryset
|
return queryset
|
||||||
return queryset.filter(
|
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:
|
class Meta:
|
||||||
model = CustomField
|
model = CustomField
|
||||||
fields = (
|
fields = (
|
||||||
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight',
|
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
|
||||||
'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
||||||
'validation_regex', 'ui_visibility',
|
'validation_regex', 'ui_visibility',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextFilterForm',
|
'ConfigContextFilterForm',
|
||||||
'CustomFieldFilterForm',
|
'CustomFieldFilterForm',
|
||||||
|
'JobResultFilterForm',
|
||||||
'CustomLinkFilterForm',
|
'CustomLinkFilterForm',
|
||||||
'ExportTemplateFilterForm',
|
'ExportTemplateFilterForm',
|
||||||
'JournalEntryFilterForm',
|
'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):
|
class CustomLinkFilterForm(FilterForm):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('q',)),
|
(None, ('q',)),
|
||||||
|
@ -41,9 +41,9 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
|||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Custom Field', (
|
('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')),
|
('Values', ('default', 'choices')),
|
||||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
('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 django import forms
|
||||||
|
|
||||||
from utilities.forms import BootstrapMixin
|
from utilities.forms import BootstrapMixin, DateTimePicker
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ScriptForm',
|
'ScriptForm',
|
||||||
@ -14,17 +14,25 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
|||||||
label="Commit changes",
|
label="Commit changes",
|
||||||
help_text="Commit changes to the database (uncheck for a dry-run)"
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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')
|
commit = self.fields.pop('_commit')
|
||||||
|
self.fields['_schedule_at'] = schedule_at
|
||||||
self.fields['_commit'] = commit
|
self.fields['_commit'] = commit
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_input(self):
|
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=""
|
ending=""
|
||||||
)
|
)
|
||||||
self.stdout.flush()
|
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']:
|
if options['verbosity']:
|
||||||
self.stdout.write("Done.", self.style.SUCCESS)
|
self.stdout.write("Done.", self.style.SUCCESS)
|
||||||
elif options['verbosity']:
|
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).
|
of only the 'default' queue).
|
||||||
"""
|
"""
|
||||||
def handle(self, *args, **options):
|
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 no queues have been specified on the command line, listen on all configured queues.
|
||||||
if len(args) < 1:
|
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 .configcontexts import ConfigContext, ConfigContextModel
|
||||||
from .customfields import CustomField
|
from .customfields import CustomField
|
||||||
from .models import *
|
from .models import *
|
||||||
|
from .search import *
|
||||||
from .tags import Tag, TaggedItem
|
from .tags import Tag, TaggedItem
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'CachedValue',
|
||||||
'ConfigContext',
|
'ConfigContext',
|
||||||
'ConfigContextModel',
|
'ConfigContextModel',
|
||||||
'ConfigRevision',
|
'ConfigRevision',
|
||||||
|
@ -16,6 +16,7 @@ from extras.choices import *
|
|||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
|
||||||
|
from netbox.search import FieldTypes
|
||||||
from utilities import filters
|
from utilities import filters
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||||
@ -30,6 +31,15 @@ __all__ = (
|
|||||||
'CustomFieldManager',
|
'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)):
|
class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||||
use_in_migrations = True
|
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 '
|
help_text='If true, this field is required when creating new objects '
|
||||||
'or editing an existing object.'
|
'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(
|
filter_logic = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=CustomFieldFilterLogicChoices,
|
choices=CustomFieldFilterLogicChoices,
|
||||||
@ -109,6 +124,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
)
|
)
|
||||||
weight = models.PositiveSmallIntegerField(
|
weight = models.PositiveSmallIntegerField(
|
||||||
default=100,
|
default=100,
|
||||||
|
verbose_name='Display weight',
|
||||||
help_text='Fields with higher weights appear lower in a form.'
|
help_text='Fields with higher weights appear lower in a form.'
|
||||||
)
|
)
|
||||||
validation_minimum = models.IntegerField(
|
validation_minimum = models.IntegerField(
|
||||||
@ -148,8 +164,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
|
|||||||
objects = CustomFieldManager()
|
objects = CustomFieldManager()
|
||||||
|
|
||||||
clone_fields = (
|
clone_fields = (
|
||||||
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'filter_logic', 'default',
|
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
|
||||||
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'ui_visibility',
|
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
|
||||||
|
'ui_visibility',
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
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
|
# Cache instance's original name so we can check later whether it has changed
|
||||||
self._name = self.name
|
self._name = self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def search_type(self):
|
||||||
|
return SEARCH_TYPES.get(self.type)
|
||||||
|
|
||||||
def populate_initial_data(self, content_types):
|
def populate_initial_data(self, content_types):
|
||||||
"""
|
"""
|
||||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
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,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
|
scheduled_time = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
to=User,
|
to=User,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -525,12 +529,26 @@ class JobResult(models.Model):
|
|||||||
unique=True
|
unique=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = RestrictedQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['obj_type', 'name', '-created']
|
ordering = ['-created']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.job_id)
|
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
|
@property
|
||||||
def duration(self):
|
def duration(self):
|
||||||
if not self.completed:
|
if not self.completed:
|
||||||
@ -551,7 +569,7 @@ class JobResult(models.Model):
|
|||||||
self.completed = timezone.now()
|
self.completed = timezone.now()
|
||||||
|
|
||||||
@classmethod
|
@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
|
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
|
name: Name for the JobResult instance
|
||||||
obj_type: ContentType to link to the JobResult instance obj_type
|
obj_type: ContentType to link to the JobResult instance obj_type
|
||||||
user: User object to link to the JobResult instance
|
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
|
args: additional args passed to the callable
|
||||||
kwargs: additional kargs passed to the callable
|
kwargs: additional kargs passed to the callable
|
||||||
"""
|
"""
|
||||||
job_result = cls.objects.create(
|
job_result: JobResult = cls.objects.create(
|
||||||
name=name,
|
name=name,
|
||||||
obj_type=obj_type,
|
obj_type=obj_type,
|
||||||
user=user,
|
user=user,
|
||||||
@ -570,7 +589,15 @@ class JobResult(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
queue = django_rq.get_queue("default")
|
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
|
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:
|
try:
|
||||||
search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
|
search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
|
||||||
for idx in search_indexes:
|
for idx in search_indexes:
|
||||||
register_search()(idx)
|
register_search(idx)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -29,5 +29,5 @@ registry['model_features'] = {
|
|||||||
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
|
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
|
||||||
}
|
}
|
||||||
registry['denormalized_fields'] = collections.defaultdict(list)
|
registry['denormalized_fields'] = collections.defaultdict(list)
|
||||||
registry['search'] = collections.defaultdict(dict)
|
registry['search'] = dict()
|
||||||
registry['views'] = collections.defaultdict(dict)
|
registry['views'] = collections.defaultdict(dict)
|
||||||
|
@ -85,7 +85,6 @@ def run_report(job_result, *args, **kwargs):
|
|||||||
try:
|
try:
|
||||||
report.run(job_result)
|
report.run(job_result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||||
job_result.save()
|
job_result.save()
|
||||||
logging.error(f"Error during execution of report {job_result.name}")
|
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 netbox.search import SearchIndex, register_search
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
@register_search
|
||||||
class JournalEntryIndex(SearchIndex):
|
class JournalEntryIndex(SearchIndex):
|
||||||
model = JournalEntry
|
model = models.JournalEntry
|
||||||
queryset = JournalEntry.objects.prefetch_related('assigned_object', 'created_by')
|
fields = (
|
||||||
filterset = extras.filtersets.JournalEntryFilterSet
|
('comments', 5000),
|
||||||
table = extras.tables.JournalEntryTable
|
)
|
||||||
url = 'extras:journalentry_list'
|
|
||||||
category = 'Journal'
|
category = 'Journal'
|
||||||
|
@ -8,6 +8,7 @@ from .template_code import *
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextTable',
|
'ConfigContextTable',
|
||||||
'CustomFieldTable',
|
'CustomFieldTable',
|
||||||
|
'JobResultTable',
|
||||||
'CustomLinkTable',
|
'CustomLinkTable',
|
||||||
'ExportTemplateTable',
|
'ExportTemplateTable',
|
||||||
'JournalEntryTable',
|
'JournalEntryTable',
|
||||||
@ -33,12 +34,33 @@ class CustomFieldTable(NetBoxTable):
|
|||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = CustomField
|
model = CustomField
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default',
|
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
|
||||||
'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated',
|
'search_weight', 'filter_logic', 'ui_visibility', 'weight', 'choices', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
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
|
# Custom links
|
||||||
#
|
#
|
||||||
|
@ -4,8 +4,9 @@ from .models import DummyModel
|
|||||||
|
|
||||||
class DummyModelIndex(SearchIndex):
|
class DummyModelIndex(SearchIndex):
|
||||||
model = DummyModel
|
model = DummyModel
|
||||||
queryset = DummyModel.objects.all()
|
fields = (
|
||||||
url = 'plugins:dummy_plugin:dummy_models'
|
('name', 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
indexes = (
|
indexes = (
|
||||||
|
@ -292,6 +292,7 @@ class CustomFieldTest(TestCase):
|
|||||||
cf = CustomField.objects.create(
|
cf = CustomField.objects.create(
|
||||||
name='object_field',
|
name='object_field',
|
||||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||||
|
object_type=ContentType.objects.get_for_model(VLAN),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
cf.content_types.set([self.object_type])
|
cf.content_types.set([self.object_type])
|
||||||
@ -323,6 +324,7 @@ class CustomFieldTest(TestCase):
|
|||||||
cf = CustomField.objects.create(
|
cf = CustomField.objects.create(
|
||||||
name='object_field',
|
name='object_field',
|
||||||
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||||
|
object_type=ContentType.objects.get_for_model(VLAN),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
cf.content_types.set([self.object_type])
|
cf.content_types.set([self.object_type])
|
||||||
|
@ -32,6 +32,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
'label': 'Field X',
|
'label': 'Field X',
|
||||||
'type': 'text',
|
'type': 'text',
|
||||||
'content_types': [site_ct.pk],
|
'content_types': [site_ct.pk],
|
||||||
|
'search_weight': 2000,
|
||||||
'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
|
'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
|
||||||
'default': None,
|
'default': None,
|
||||||
'weight': 200,
|
'weight': 200,
|
||||||
@ -40,11 +41,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
|
'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,exact,,,,[a-z]{3},read-write',
|
'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write',
|
||||||
'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write',
|
'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write',
|
||||||
'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,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,exact,,,,,read-write',
|
'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write',
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
@ -74,6 +74,11 @@ urlpatterns = [
|
|||||||
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
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'),
|
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
|
# Scripts
|
||||||
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||||
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
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 utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .choices import JobResultStatusChoices
|
from .choices import JobResultStatusChoices
|
||||||
|
from .forms.reports import ReportForm
|
||||||
from .models import *
|
from .models import *
|
||||||
from .reports import get_report, get_reports, run_report
|
from .reports import get_report, get_reports, run_report
|
||||||
from .scripts import get_scripts, run_script
|
from .scripts import get_scripts, run_script
|
||||||
@ -592,7 +593,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||||||
|
|
||||||
return render(request, 'extras/report.html', {
|
return render(request, 'extras/report.html', {
|
||||||
'report': report,
|
'report': report,
|
||||||
'run_form': ConfirmationForm(),
|
'form': ReportForm(),
|
||||||
})
|
})
|
||||||
|
|
||||||
def post(self, request, module, name):
|
def post(self, request, module, name):
|
||||||
@ -605,24 +606,36 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
|||||||
if report is None:
|
if report is None:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
# Allow execution only if RQ worker process is running
|
schedule_at = None
|
||||||
if not Worker.count(get_connection('default')):
|
form = ReportForm(request.POST)
|
||||||
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.
|
if form.is_valid():
|
||||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
schedule_at = form.cleaned_data.get("schedule_at")
|
||||||
job_result = JobResult.enqueue_job(
|
|
||||||
run_report,
|
|
||||||
report.full_name,
|
|
||||||
report_content_type,
|
|
||||||
request.user,
|
|
||||||
job_timeout=report.job_timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
class ReportResultView(ContentTypePermissionRequiredMixin, View):
|
||||||
@ -737,6 +750,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
|||||||
|
|
||||||
elif form.is_valid():
|
elif form.is_valid():
|
||||||
commit = form.cleaned_data.pop('_commit')
|
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')
|
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),
|
request=copy_safe_request(request),
|
||||||
commit=commit,
|
commit=commit,
|
||||||
job_timeout=script.job_timeout,
|
job_timeout=script.job_timeout,
|
||||||
|
schedule_at=schedule_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
return redirect('extras:script_result', job_result_pk=job_result.pk)
|
return redirect('extras:script_result', job_result_pk=job_result.pk)
|
||||||
@ -788,3 +803,25 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View)
|
|||||||
'result': result,
|
'result': result,
|
||||||
'class_name': script.__class__.__name__
|
'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):
|
class RIRForm(NetBoxModelForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('RIR', (
|
||||||
|
'name', 'slug', 'is_private', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RIR
|
model = RIR
|
||||||
fields = [
|
fields = [
|
||||||
@ -164,6 +170,12 @@ class ASNForm(TenancyForm, NetBoxModelForm):
|
|||||||
class RoleForm(NetBoxModelForm):
|
class RoleForm(NetBoxModelForm):
|
||||||
slug = SlugField()
|
slug = SlugField()
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Role', (
|
||||||
|
'name', 'slug', 'weight', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Role
|
model = Role
|
||||||
fields = [
|
fields = [
|
||||||
@ -784,6 +796,12 @@ class ServiceTemplateForm(NetBoxModelForm):
|
|||||||
help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
|
help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Service Template', (
|
||||||
|
'name', 'protocol', 'ports', 'description', 'tags',
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ServiceTemplate
|
model = ServiceTemplate
|
||||||
fields = ('name', 'protocol', 'ports', 'description', 'tags')
|
fields = ('name', 'protocol', 'ports', 'description', 'tags')
|
||||||
|
@ -92,6 +92,8 @@ class Service(ServiceBase, NetBoxModel):
|
|||||||
verbose_name='IP addresses'
|
verbose_name='IP addresses'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
|
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
|
||||||
|
|
||||||
|
@ -1,69 +1,139 @@
|
|||||||
import ipam.filtersets
|
from . import models
|
||||||
import ipam.tables
|
|
||||||
from ipam.models import ASN, VLAN, VRF, Aggregate, IPAddress, Prefix, Service
|
|
||||||
from netbox.search import SearchIndex, register_search
|
from netbox.search import SearchIndex, register_search
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
@register_search
|
||||||
class VRFIndex(SearchIndex):
|
|
||||||
model = VRF
|
|
||||||
queryset = VRF.objects.prefetch_related('tenant', 'tenant__group')
|
|
||||||
filterset = ipam.filtersets.VRFFilterSet
|
|
||||||
table = ipam.tables.VRFTable
|
|
||||||
url = 'ipam:vrf_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class AggregateIndex(SearchIndex):
|
class AggregateIndex(SearchIndex):
|
||||||
model = Aggregate
|
model = models.Aggregate
|
||||||
queryset = Aggregate.objects.prefetch_related('rir')
|
fields = (
|
||||||
filterset = ipam.filtersets.AggregateFilterSet
|
('prefix', 100),
|
||||||
table = ipam.tables.AggregateTable
|
('description', 500),
|
||||||
url = 'ipam:aggregate_list'
|
('date_added', 2000),
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class PrefixIndex(SearchIndex):
|
|
||||||
model = Prefix
|
|
||||||
queryset = Prefix.objects.prefetch_related(
|
|
||||||
'site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'
|
|
||||||
)
|
)
|
||||||
filterset = ipam.filtersets.PrefixFilterSet
|
|
||||||
table = ipam.tables.PrefixTable
|
|
||||||
url = 'ipam:prefix_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
@register_search
|
||||||
class IPAddressIndex(SearchIndex):
|
|
||||||
model = IPAddress
|
|
||||||
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group')
|
|
||||||
filterset = ipam.filtersets.IPAddressFilterSet
|
|
||||||
table = ipam.tables.IPAddressTable
|
|
||||||
url = 'ipam:ipaddress_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class VLANIndex(SearchIndex):
|
|
||||||
model = VLAN
|
|
||||||
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role')
|
|
||||||
filterset = ipam.filtersets.VLANFilterSet
|
|
||||||
table = ipam.tables.VLANTable
|
|
||||||
url = 'ipam:vlan_list'
|
|
||||||
|
|
||||||
|
|
||||||
@register_search()
|
|
||||||
class ASNIndex(SearchIndex):
|
class ASNIndex(SearchIndex):
|
||||||
model = ASN
|
model = models.ASN
|
||||||
queryset = ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group')
|
fields = (
|
||||||
filterset = ipam.filtersets.ASNFilterSet
|
('asn', 100),
|
||||||
table = ipam.tables.ASNTable
|
('description', 500),
|
||||||
url = 'ipam:asn_list'
|
)
|
||||||
|
|
||||||
|
|
||||||
@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):
|
class ServiceIndex(SearchIndex):
|
||||||
model = Service
|
model = models.Service
|
||||||
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
|
fields = (
|
||||||
filterset = ipam.filtersets.ServiceFilterSet
|
('name', 100),
|
||||||
table = ipam.tables.ServiceTable
|
('description', 500),
|
||||||
url = 'ipam:service_list'
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
|
||||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||||
|
|
||||||
|
# Optionally set CA cert directory
|
||||||
|
if ca_cert_dir := getattr(ldap_config, 'LDAP_CA_CERT_DIR', None):
|
||||||
|
ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, ca_cert_dir)
|
||||||
|
|
||||||
|
# Optionally set CA cert file
|
||||||
|
if ca_cert_file := getattr(ldap_config, 'LDAP_CA_CERT_FILE', None):
|
||||||
|
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_cert_file)
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,5 +1,2 @@
|
|||||||
# Prefix for nested serializers
|
# Prefix for nested serializers
|
||||||
NESTED_SERIALIZER_PREFIX = 'Nested'
|
NESTED_SERIALIZER_PREFIX = 'Nested'
|
||||||
|
|
||||||
# Max results per object type
|
|
||||||
SEARCH_MAX_RESULTS = 15
|
|
||||||
|
@ -1,38 +1,45 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from netbox.search.backends import default_search_engine
|
from netbox.search import LookupTypes
|
||||||
from utilities.forms import BootstrapMixin
|
from netbox.search.backends import search_backend
|
||||||
|
from utilities.forms import BootstrapMixin, StaticSelect, StaticSelectMultiple
|
||||||
|
|
||||||
from .base import *
|
from .base import *
|
||||||
|
|
||||||
|
LOOKUP_CHOICES = (
|
||||||
def build_options(choices):
|
('', _('Partial match')),
|
||||||
options = [{"label": choices[0][1], "items": []}]
|
(LookupTypes.EXACT, _('Exact match')),
|
||||||
|
(LookupTypes.STARTSWITH, _('Starts with')),
|
||||||
for label, choices in choices[1:]:
|
(LookupTypes.ENDSWITH, _('Ends with')),
|
||||||
items = []
|
)
|
||||||
|
|
||||||
for value, choice_label in choices:
|
|
||||||
items.append({"label": choice_label, "value": value})
|
|
||||||
|
|
||||||
options.append({"label": label, "items": items})
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
class SearchForm(BootstrapMixin, forms.Form):
|
class SearchForm(BootstrapMixin, forms.Form):
|
||||||
q = forms.CharField(label='Search')
|
q = forms.CharField(
|
||||||
options = None
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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):
|
self.fields['obj_types'].choices = search_backend.get_object_types()
|
||||||
if not self.options:
|
|
||||||
self.options = build_options(default_search_engine.get_search_choices())
|
|
||||||
|
|
||||||
return self.options
|
|
||||||
|
@ -294,6 +294,11 @@ OTHER_MENU = Menu(
|
|||||||
link_text='Scripts',
|
link_text='Scripts',
|
||||||
permissions=['extras.view_script']
|
permissions=['extras.view_script']
|
||||||
),
|
),
|
||||||
|
MenuItem(
|
||||||
|
link='extras:jobresult_list',
|
||||||
|
link_text='Job Results',
|
||||||
|
permissions=['extras.view_jobresult'],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MenuGroup(
|
MenuGroup(
|
||||||
|
@ -1,5 +1,24 @@
|
|||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
from extras.registry import registry
|
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:
|
class SearchIndex:
|
||||||
"""
|
"""
|
||||||
@ -7,27 +26,90 @@ class SearchIndex:
|
|||||||
|
|
||||||
Attrs:
|
Attrs:
|
||||||
model: The model class for which this index is used.
|
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
|
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
|
@classmethod
|
||||||
def get_category(cls):
|
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'):
|
values = []
|
||||||
return cls.category
|
|
||||||
return cls.model._meta.app_config.verbose_name
|
# 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 get_indexer(model):
|
||||||
def _wrapper(cls):
|
"""
|
||||||
model = cls.model
|
Get the SearchIndex class for the given model.
|
||||||
app_label = model._meta.app_label
|
"""
|
||||||
model_name = model._meta.model_name
|
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 collections import defaultdict
|
||||||
from importlib import import_module
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
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 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.
|
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
|
||||||
_backends_cache = {}
|
MAX_RESULTS = 1000
|
||||||
|
|
||||||
|
|
||||||
class SearchEngineError(Exception):
|
|
||||||
"""Something went wrong with a search engine."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SearchBackend:
|
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):
|
def get_object_types(self):
|
||||||
r = {}
|
"""
|
||||||
for app_label, models in registry['search'].items():
|
Return a list of all registered object types, organized by category, suitable for populating a form's
|
||||||
r.update(**models)
|
ChoiceField.
|
||||||
|
"""
|
||||||
return r
|
if not self._object_types:
|
||||||
|
|
||||||
def get_search_choices(self):
|
|
||||||
"""Return the set of choices for individual object types, organized by category."""
|
|
||||||
if not self._search_choice_options:
|
|
||||||
|
|
||||||
# Organize choices by category
|
# Organize choices by category
|
||||||
categories = defaultdict(dict)
|
categories = defaultdict(dict)
|
||||||
for app_label, models in registry['search'].items():
|
for label, idx in registry['search'].items():
|
||||||
for name, cls in models.items():
|
title = bettertitle(idx.model._meta.verbose_name)
|
||||||
title = cls.model._meta.verbose_name.title()
|
categories[idx.get_category()][label] = title
|
||||||
categories[cls.get_category()][name] = title
|
|
||||||
|
|
||||||
# Compile a nested tuple of choices for form rendering
|
# Compile a nested tuple of choices for form rendering
|
||||||
results = (
|
results = (
|
||||||
('', 'All Objects'),
|
('', '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):
|
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
||||||
"""Execute a search query for the given value."""
|
"""
|
||||||
|
Search cached object representations for the given value.
|
||||||
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def cache(self, instance):
|
def caching_handler(self, sender, instance, **kwargs):
|
||||||
"""Create or update the cached copy of an instance."""
|
"""
|
||||||
|
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
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def remove(self, instance):
|
||||||
|
"""
|
||||||
|
Delete any cached representation of an instance.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
class FilterSetSearchBackend(SearchBackend):
|
def clear(self, object_types=None):
|
||||||
"""
|
"""
|
||||||
Legacy search backend. Performs a discrete database query for each registered object type, using the FilterSet
|
Delete *all* cached data.
|
||||||
class specified by the index for each.
|
"""
|
||||||
"""
|
raise NotImplementedError
|
||||||
def search(self, request, value, **kwargs):
|
|
||||||
results = []
|
|
||||||
|
|
||||||
search_registry = self.get_registry()
|
@property
|
||||||
for obj_type in search_registry.keys():
|
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
|
class CachedValueSearchBackend(SearchBackend):
|
||||||
if hasattr(queryset, 'restrict'):
|
|
||||||
queryset = queryset.restrict(request.user, 'view')
|
|
||||||
|
|
||||||
filterset = getattr(search_registry[obj_type], 'filterset', None)
|
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
|
||||||
if not filterset:
|
|
||||||
# This backend requires a FilterSet class for the model
|
|
||||||
continue
|
|
||||||
|
|
||||||
table = getattr(search_registry[obj_type], 'table', None)
|
# Define the search parameters
|
||||||
if not table:
|
params = {
|
||||||
# This backend requires a Table class for the model
|
f'value__{lookup}': value
|
||||||
continue
|
}
|
||||||
|
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
|
# Construct the base queryset to retrieve matching results
|
||||||
filtered_queryset = filterset({'q': value}, queryset=queryset).qs
|
queryset = CachedValue.objects.filter(**params).annotate(
|
||||||
table = table(filtered_queryset, orderable=False)
|
# Annotate the rank of each result for its object according to its weight
|
||||||
table.paginate(per_page=SEARCH_MAX_RESULTS)
|
row_number=Window(
|
||||||
|
expression=window.RowNumber(),
|
||||||
|
partition_by=[F('object_type'), F('object_id')],
|
||||||
|
order_by=[F('weight').asc()],
|
||||||
|
)
|
||||||
|
)[:MAX_RESULTS]
|
||||||
|
|
||||||
if table.page:
|
# Construct a Prefetch to pre-fetch only those related objects for which the
|
||||||
results.append({
|
# user has permission to view.
|
||||||
'name': queryset.model._meta.verbose_name_plural,
|
if user:
|
||||||
'table': table,
|
prefetch = (RestrictedPrefetch('object', user, 'view'), 'object_type')
|
||||||
'url': f"{reverse(url)}?q={value}"
|
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):
|
# Omit any results pertaining to an object the user does not have permission to view
|
||||||
# This backend does not utilize a cache
|
return [
|
||||||
pass
|
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():
|
def get_backend():
|
||||||
"""Initializes and returns the configured search backend."""
|
"""
|
||||||
backend_name = settings.SEARCH_BACKEND
|
Initializes and returns the configured search backend.
|
||||||
|
"""
|
||||||
# Load the backend class
|
|
||||||
backend_module_name, backend_cls_name = backend_name.rsplit('.', 1)
|
|
||||||
backend_module = import_module(backend_module_name)
|
|
||||||
try:
|
try:
|
||||||
backend_cls = getattr(backend_module, backend_cls_name)
|
backend_cls = import_string(settings.SEARCH_BACKEND)
|
||||||
except AttributeError:
|
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
|
# Initialize and return the backend instance
|
||||||
return backend_cls()
|
return backend_cls()
|
||||||
|
|
||||||
|
|
||||||
default_search_engine = get_backend()
|
search_backend = get_backend()
|
||||||
search = default_search_engine.search
|
|
||||||
|
# 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('/')
|
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||||
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.FilterSetSearchBackend')
|
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
|
||||||
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
|
SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
|
||||||
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
|
||||||
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
|
SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
|
||||||
@ -493,7 +493,7 @@ for param in dir(configuration):
|
|||||||
|
|
||||||
# Force usage of PostgreSQL's JSONB field for extra data
|
# Force usage of PostgreSQL's JSONB field for extra data
|
||||||
SOCIAL_AUTH_JSONFIELD_ENABLED = True
|
SOCIAL_AUTH_JSONFIELD_ENABLED = True
|
||||||
|
SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'netbox.users.utils.clean_username'
|
||||||
|
|
||||||
#
|
#
|
||||||
# Django Prometheus
|
# Django Prometheus
|
||||||
|
@ -4,16 +4,21 @@ from django.contrib.contenttypes.fields import GenericForeignKey
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import FieldDoesNotExist
|
from django.core.exceptions import FieldDoesNotExist
|
||||||
from django.db.models.fields.related import RelatedField
|
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 django_tables2.data import TableQuerysetData
|
||||||
|
|
||||||
from extras.models import CustomField, CustomLink
|
from extras.models import CustomField, CustomLink
|
||||||
from extras.choices import CustomFieldVisibilityChoices
|
from extras.choices import CustomFieldVisibilityChoices
|
||||||
from netbox.tables import columns
|
from netbox.tables import columns
|
||||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||||
|
from utilities.templatetags.builtins.filters import bettertitle
|
||||||
|
from utilities.utils import highlight_string
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BaseTable',
|
'BaseTable',
|
||||||
'NetBoxTable',
|
'NetBoxTable',
|
||||||
|
'SearchTable',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -192,3 +197,39 @@ class NetBoxTable(BaseTable):
|
|||||||
])
|
])
|
||||||
|
|
||||||
super().__init__(*args, extra_columns=extra_columns, **kwargs)
|
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
|
import sys
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponseServerError
|
from django.http import HttpResponseServerError
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.template import loader
|
from django.template import loader
|
||||||
from django.template.exceptions import TemplateDoesNotExist
|
from django.template.exceptions import TemplateDoesNotExist
|
||||||
from django.urls import reverse
|
|
||||||
from django.views.decorators.csrf import requires_csrf_token
|
from django.views.decorators.csrf import requires_csrf_token
|
||||||
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
from django_tables2 import RequestConfig
|
||||||
from packaging import version
|
from packaging import version
|
||||||
from sentry_sdk import capture_message
|
from sentry_sdk import capture_message
|
||||||
|
|
||||||
@ -21,10 +22,13 @@ from dcim.models import (
|
|||||||
from extras.models import ObjectChange
|
from extras.models import ObjectChange
|
||||||
from extras.tables import ObjectChangeTable
|
from extras.tables import ObjectChangeTable
|
||||||
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
|
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
|
||||||
from netbox.constants import SEARCH_MAX_RESULTS
|
|
||||||
from netbox.forms import SearchForm
|
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 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 virtualization.models import Cluster, VirtualMachine
|
||||||
from wireless.models import WirelessLAN, WirelessLink
|
from wireless.models import WirelessLAN, WirelessLink
|
||||||
|
|
||||||
@ -149,22 +153,48 @@ class HomeView(View):
|
|||||||
class SearchView(View):
|
class SearchView(View):
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
form = SearchForm(request.GET)
|
|
||||||
results = []
|
results = []
|
||||||
|
highlight = None
|
||||||
|
|
||||||
|
# Initialize search form
|
||||||
|
form = SearchForm(request.GET) if 'q' in request.GET else SearchForm()
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
search_registry = default_search_engine.get_registry()
|
|
||||||
# If an object type has been specified, redirect to the dedicated view for it
|
|
||||||
if 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', {
|
return render(request, 'search.html', {
|
||||||
'form': form,
|
'form': form,
|
||||||
'results': results,
|
'table': table,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -31,8 +31,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
"@typescript-eslint/no-unused-vars-experimental": "error",
|
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"no-inner-declarations": "off",
|
"no-inner-declarations": "off",
|
||||||
"comma-dangle": ["error", "always-multiline"],
|
"comma-dangle": ["error", "always-multiline"],
|
||||||
|
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"
|
"validate:formatting:scripts": "prettier -c src/**/*.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "^5.9.55",
|
"@mdi/font": "^7.0.96",
|
||||||
"@popperjs/core": "^2.9.2",
|
"@popperjs/core": "^2.11.6",
|
||||||
"bootstrap": "~5.0.2",
|
"bootstrap": "~5.0.2",
|
||||||
"clipboard": "^2.0.8",
|
"clipboard": "^2.0.11",
|
||||||
"color2k": "^1.2.4",
|
"color2k": "^2.0.0",
|
||||||
"dayjs": "^1.10.4",
|
"dayjs": "^1.11.5",
|
||||||
"flatpickr": "4.6.3",
|
"flatpickr": "4.6.13",
|
||||||
"htmx.org": "^1.6.1",
|
"htmx.org": "^1.8.0",
|
||||||
"just-debounce-it": "^1.4.0",
|
"just-debounce-it": "^3.1.1",
|
||||||
"masonry-layout": "^4.2.2",
|
"masonry-layout": "^4.2.2",
|
||||||
"query-string": "^6.14.1",
|
"query-string": "^7.1.1",
|
||||||
"sass": "^1.32.8",
|
"sass": "^1.55.0",
|
||||||
"simplebar": "^5.3.4",
|
"simplebar": "^5.3.9",
|
||||||
"slim-select": "^1.27.0"
|
"slim-select": "^1.27.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bootstrap": "^5.0.12",
|
"@types/bootstrap": "^5.0.17",
|
||||||
"@types/cookie": "^0.4.0",
|
"@types/cookie": "^0.5.1",
|
||||||
"@types/masonry-layout": "^4.2.2",
|
"@types/masonry-layout": "^4.2.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.29.3",
|
"@typescript-eslint/eslint-plugin": "^5.39.0",
|
||||||
"@typescript-eslint/parser": "^4.29.3",
|
"@typescript-eslint/parser": "^5.39.0",
|
||||||
"esbuild": "^0.12.24",
|
"esbuild": "^0.13.15",
|
||||||
"esbuild-sass-plugin": "^1.5.2",
|
"esbuild-sass-plugin": "^2.3.3",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^8.24.0",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"eslint-import-resolver-typescript": "^2.4.0",
|
"eslint-import-resolver-typescript": "^3.5.1",
|
||||||
"eslint-plugin-import": "^2.24.2",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"eslint-plugin-prettier": "^3.4.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"prettier": "^2.3.2",
|
"prettier": "^2.7.1",
|
||||||
"typescript": "~4.3.5"
|
"typescript": "~4.8.4"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"eslint-import-resolver-typescript/**/path-parse": "^1.0.7",
|
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
||||||
"slim-select/**/trim-newlines": "^3.0.1",
|
|
||||||
"eslint/glob-parent": "^5.1.2",
|
|
||||||
"esbuild-sass-plugin/**/glob-parent": "^5.1.2",
|
|
||||||
"@typescript-eslint/**/glob-parent": "^5.1.2",
|
|
||||||
"eslint-plugin-import/**/hosted-git-info": "^2.8.9"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { initForms } from './forms';
|
import { initForms } from './forms';
|
||||||
import { initBootstrap } from './bs';
|
import { initBootstrap } from './bs';
|
||||||
import { initSearch } from './search';
|
import { initQuickSearch } from './search';
|
||||||
import { initSelect } from './select';
|
import { initSelect } from './select';
|
||||||
import { initButtons } from './buttons';
|
import { initButtons } from './buttons';
|
||||||
import { initColorMode } from './colorMode';
|
import { initColorMode } from './colorMode';
|
||||||
@ -20,7 +20,7 @@ function initDocument(): void {
|
|||||||
initColorMode,
|
initColorMode,
|
||||||
initMessages,
|
initMessages,
|
||||||
initForms,
|
initForms,
|
||||||
initSearch,
|
initQuickSearch,
|
||||||
initSelect,
|
initSelect,
|
||||||
initDateSelector,
|
initDateSelector,
|
||||||
initButtons,
|
initButtons,
|
||||||
@ -37,14 +37,12 @@ function initDocument(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initWindow(): void {
|
function initWindow(): void {
|
||||||
|
const documentForms = document.forms;
|
||||||
const documentForms = document.forms
|
for (const documentForm of documentForms) {
|
||||||
for (var documentForm of documentForms) {
|
|
||||||
if (documentForm.method.toUpperCase() == 'GET') {
|
if (documentForm.method.toUpperCase() == 'GET') {
|
||||||
// @ts-ignore: Our version of typescript seems to be too old for FormDataEvent
|
documentForm.addEventListener('formdata', function (event: FormDataEvent) {
|
||||||
documentForm.addEventListener('formdata', function(event: FormDataEvent) {
|
const formData: FormData = event.formData;
|
||||||
let formData: FormData = event.formData;
|
for (const [name, value] of Array.from(formData.entries())) {
|
||||||
for (let [name, value] of Array.from(formData.entries())) {
|
|
||||||
if (value === '') formData.delete(name);
|
if (value === '') formData.delete(name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1,31 +1,4 @@
|
|||||||
import { getElements, findFirstAdjacent, isTruthy } from './util';
|
import { 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 = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show/hide quicksearch clear button.
|
* 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.
|
* Initialize Quicksearch Event listener/handlers.
|
||||||
*/
|
*/
|
||||||
function initQuickSearch(): void {
|
export function initQuickSearch(): void {
|
||||||
const quicksearch = document.getElementById("quicksearch") as HTMLInputElement;
|
const quicksearch = document.getElementById("quicksearch") as HTMLInputElement;
|
||||||
const clearbtn = document.getElementById("quicksearch_clear") as HTMLButtonElement;
|
const clearbtn = document.getElementById("quicksearch_clear") as HTMLButtonElement;
|
||||||
if (isTruthy(quicksearch)) {
|
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 {
|
.ss-main {
|
||||||
color: $form-select-color;
|
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 #}
|
{# Base layout for the core NetBox UI w/navbar and page content #}
|
||||||
{% extends 'base/base.html' %}
|
{% extends 'base/base.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load search %}
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
@ -41,7 +40,7 @@ Blocks:
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex my-1 flex-grow-1 justify-content-center w-100">
|
<div class="d-flex my-1 flex-grow-1 justify-content-center w-100">
|
||||||
{% search_options request %}
|
{% include 'inc/searchbar.html' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -53,7 +52,7 @@ Blocks:
|
|||||||
|
|
||||||
{# Search bar #}
|
{# Search bar #}
|
||||||
<div class="col-6 d-flex flex-grow-1 justify-content-center">
|
<div class="col-6 d-flex flex-grow-1 justify-content-center">
|
||||||
{% search_options request %}
|
{% include 'inc/searchbar.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Proflie/login button #}
|
{# Proflie/login button #}
|
||||||
|
@ -60,23 +60,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% include 'inc/panels/custom_fields.html' %}
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
{% include 'inc/panels/tags.html' %}
|
{% include 'inc/panels/tags.html' %}
|
||||||
|
{% include 'inc/panels/comments.html' %}
|
||||||
{% plugin_left_page object %}
|
{% plugin_left_page object %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
{% include 'inc/panels/comments.html' %}
|
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
|
||||||
|
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
|
||||||
{% include 'inc/panels/contacts.html' %}
|
{% include 'inc/panels/contacts.html' %}
|
||||||
{% include 'inc/panels/image_attachments.html' %}
|
{% include 'inc/panels/image_attachments.html' %}
|
||||||
{% plugin_right_page object %}
|
{% plugin_right_page object %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<div class="col col-md-6">
|
|
||||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
|
|
||||||
</div>
|
|
||||||
<div class="col col-md-6">
|
|
||||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
{% plugin_full_width_page object %}
|
{% plugin_full_width_page object %}
|
||||||
|
@ -77,10 +77,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Outlet</a>
|
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Feed</a>
|
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
|
@ -105,16 +105,16 @@
|
|||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Interface</a>
|
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Interface</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a>
|
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a>
|
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-link" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
|
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Circuit Termination</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
|
@ -39,13 +39,23 @@
|
|||||||
<td>{% checkmark object.required %}</td>
|
<td>{% checkmark object.required %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Weight</th>
|
<th scope="row">Search Weight</th>
|
||||||
<td>{{ object.weight }}</td>
|
<td>
|
||||||
|
{% if object.search_weight %}
|
||||||
|
{{ object.search_weight }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Disabled</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Filter Logic</th>
|
<th scope="row">Filter Logic</th>
|
||||||
<td>{{ object.get_filter_logic_display }}</td>
|
<td>{{ object.get_filter_logic_display }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Display Weight</th>
|
||||||
|
<td>{{ object.weight }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">UI Visibility</th>
|
<th scope="row">UI Visibility</th>
|
||||||
<td>{{ object.get_ui_visibility_display }}</td>
|
<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